GitHub Security Advisory: GHSA-crmg-9m86-636r
Package: github.com/canonical/lxd (GO)
Severity: MODERATE (CVSS 0.0)
CWE: CWE-862: Missing Authorization
Vulnerable versions: < 0.0.0[PII_REDACTED]2359-d936c90d47cf
Patched in: 0.0.0[PII_REDACTED]2359-d936c90d47cf

Summary: lxd's non-recursive certificate listing bypasses per-object authorization and leaks all fingerprints

Details:
## Summary
The `GET /1.0/certificates` endpoint (non-recursive mode) returns URLs containing fingerprints for all certificates in the trust store, bypassing the per-object `can_view` authorization check that is correctly applied in the recursive path. Any authenticated identity — including restricted, non-admin users — can enumerate all certificate fingerprints, exposing the full set of trusted identities in the LXD deployment.

## Affected Component
- `lxd/certificates.go` — `certificatesGet` (lines 185–192) — Non-recursive code path returns unfiltered certificate list.

## CWE
- **CWE-862**: Missing Authorization

## Description

### Core vulnerability: missing permission filter in non-recursive listing path

The `certificatesGet` handler obtains a permission checker at line 143 and correctly applies it when building the recursive response (lines 163-176). However, the non-recursive code path at lines 185-192 creates a fresh loop over the unfiltered `baseCerts` slice, completely bypassing the authorization check:

```go
// lxd/certificates.go:139-193
func certificatesGet(d *Daemon, r *http.Request) response.Response {
    recursion := util.IsRecursionRequest(r)
    s := d.State()

    userHasPermission, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeCertificate)
    // ...

    for _, baseCert := range baseCerts {
        if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) {
            continue  // Correctly filters unauthorized certs
        }

        if recursion {
            // ... builds filtered certResponses ...
        }
        // NOTE: when !recursion, nothing is recorded — the filter result is discarded
    }

    if !recursion {
        body := []string{}
        for _, baseCert := range baseCerts {  // <-- iterates UNFILTERED baseCerts
            certificateURL := api.NewURL().Path(version.APIVersion, "certificates", baseCert.Fingerprint).String()
            body = append(body, certificateURL)
        }
        return response.SyncResponse(true, body)  // Returns ALL certificate fingerprints
    }

    return response.SyncResponse(true, certResponses)  // Recursive path is correctly filtered
}
```

### Inconsistency with other list endpoints confirms the bug

Five other list endpoints in the same codebase correctly filter results in both recursive and non-recursive paths:

| Endpoint | File | Filters non-recursive? |
|----------|------|----------------------|
| Instances | `lxd/instances_get.go` — `instancesGet` | Yes — filters before either path |
| Images | `lxd/images.go` — `doImagesGet` | Yes — checks `hasPermission` for both paths |
| Networks | `lxd/networks.go` — `networksGet` | Yes — filters outside recursion check |
| Profiles | `lxd/profiles.go` — `profilesGet` | Yes — separate filter in non-recursive path |
| **Certificates** | **`lxd/certificates.go` — `certificatesGet`** | **No — unfiltered** |

The certificates endpoint is the sole outlier, confirming this