CVE-2026-3351: Github.com/canonical/lxd Lxd's Non-recursive Certificate Listing Bypasses Vulnerability

MODERATE
Advisory Identifier: GHSA-crmg-9m86-636r
CWE-862
Published
Advisory Summary

CVE-2026-3351 is a MODERATE lxd's non-recursive certificate listing bypasses vulnerability (CWE-862: Missing Authorization) affecting Github.com/canonical/lxd libraries in the Go ecosystem. The issue involves lxd's non-recursive certificate listing bypasses per-object authorization and leaks all fingerprints. Remediation is available by upgrading affected packages to version 0.0.0-20260224152359-d936c90d47cf.

CVE-2026-3351 at a Glance

CVE Identifier CVE-2026-3351
GHSA Identifier GHSA-crmg-9m86-636r
Severity MODERATE
CVSS v3.1 Base Score N/A
CWE Classification CWE-862: Missing Authorization
Affected Product Github.com/canonical/lxd
Package Ecosystem Go
Published Date
Last Updated

How to Fix CVE-2026-3351

To remediate this vulnerability, upgrade all affected Github.com/canonical/lxd dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/canonical/lxd fixed: 0.0.0-20260224152359-d936c90d47cf
Upgrade github.com/canonical/lxd to 0.0.0-20260224152359-d936c90d47cf
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/canonical/lxd

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-3351, inspect your project dependencies for Github.com/canonical/lxd:

inspect dependencies for github.com/canonical/lxd

Analyze the terminal output to locate any version of Github.com/canonical/lxd in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.0.0-20260224152359-d936c90d47cf), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-862: Missing Authorization, where the product does not perform an authorization check when an actor attempts to access a resource or perform an action. In Github.com/canonical/lxd, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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 is an oversight rather than a design choice. ### Access handler provides no defense The endpoint uses `allowAuthenticated` as its `AccessHandler` (`certificates.go:45`), which only checks `requestor.IsTrusted()`: ```go // lxd/daemon.go:255-267 // allowAuthenticated is an AccessHandler which allows only authenticated requests. // This should be used in conjunction with further access control within the handler // (e.g. to filter resources the user is able to view/edit). func allowAuthenticated(_ *Daemon, r *http.Request) response.Response { requestor, err := request.GetRequestor(r.Context()) // ... if requestor.IsTrusted() { return response.EmptySyncResponse } return response.Forbidden(nil) } ``` The comment explicitly states that `allowAuthenticated` should be "used in conjunction with further access control within the handler" — which the non-recursive path fails to do. ### Execution chain 1. Restricted authenticated user sends `GET /1.0/certificates` (no `recursion` parameter) 2. `allowAuthenticated` access handler passes because user is trusted (`daemon.go:263`) 3. `certificatesGet` creates permission checker for `EntitlementCanView` on `TypeCertificate` (line 143) 4. Loop at lines 163-176 filters `baseCerts` by permission — but only populates `certResponses` for recursive mode 5. Since `!recursion`, control reaches lines 185-192 6. New loop iterates ALL `baseCerts` (unfiltered) and builds URL list with fingerprints 7. Full list of certificate fingerprints returned to restricted user ## Proof of Concept ```bash # Preconditions: restricted (non-admin) trusted client certificate HOST=target.example PORT=8443 # 1) Non-recursive list: returns ALL certificate fingerprints (UNFILTERED) curl -sk --cert restricted.crt --key restricted.key \ "https://${HOST}:${PORT}/1.0/certificates" | jq '.metadata | length' # 2) Recursive list: returns only authorized certificates (FILTERED) curl -sk --cert restricted.crt --key restricted.key \ "https://${HOST}:${PORT}/1.0/certificates?recursion=1" | jq '.metadata | length' # Expected: (1) returns MORE fingerprints than (2), proving the authorization bypass. # The difference reveals fingerprints of certificates the restricted user should not see. ``` ## Impact - **Identity enumeration**: A restricted user can discover the fingerprints of all trusted certificates, revealing the complete set of identities in the LXD trust store. - **Reconnaissance for targeted attacks**: Fingerprints identify specific certificates used for inter-cluster communication, admin access, and other privileged operations. - **RBAC bypass**: In deployments using fine-grained RBAC (OpenFGA or built-in TLS authorization), the non-recursive path completely bypasses the intended per-object visibility controls. - **Information asymmetry**: Restricted users gain knowledge of the full trust topology, which the administrator explicitly intended to hide via per-certificate `can_view` entitlements. ## Recommended Remediation ### Option 1: Apply the permission filter to the non-recursive path (preferred) Replace the unfiltered loop with one that checks `userHasPermission`, matching the pattern used in the recursive path and in all other list endpoints: ```go // lxd/certificates.go — replace lines 185-192 if !recursion { body := []string{} for _, baseCert := range baseCerts { if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) { continue } certificateURL := api.NewURL().Path(version.APIVersion, "certificates", baseCert.Fingerprint).String() body = append(body, certificateURL) } return response.SyncResponse(true, body) } ``` ### Option 2: Build both response types in a single filtered loop Restructure the function to build both the URL list and the recursive response in the same permission-checked loop, eliminating the possibility of divergent filtering: ```go err = d.State().DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { baseCerts, err = dbCluster.GetCertificates(ctx, tx.Tx()) if err != nil { return err } certResponses = make([]*api.Certificate, 0, len(baseCerts)) certURLs = make([]string, 0, len(baseCerts)) for _, baseCert := range baseCerts { if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) { continue } certURLs = append(certURLs, api.NewURL().Path(version.APIVersion, "certificates", baseCert.Fingerprint).String()) if recursion { apiCert, err := baseCert.ToAPI(ctx, tx.Tx()) if err != nil { return err } certResponses = append(certResponses, apiCert) urlToCertificate[entity.CertificateURL(apiCert.Fingerprint)] = apiCert } } return nil }) ``` Option 2 is structurally safer as it prevents the two paths from diverging in the future. ## Credit This vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).

CVE-2026-3351 Affected Github.com/canonical/lxd Versions

1 component(s)
Fixed Versions:
0.0.0-20260224152359-d936c90d47cf
Product / Package Ecosystem Affected Versions Fixed Version
github.com/canonical/lxd
Go
< 0.0.0-20260224152359-d936c90d47cf
0.0.0-20260224152359-d936c90d47cf

References

Frequently Asked Questions

What is CVE-2026-3351?
CVE-2026-3351 is a security vulnerability in Github.com/canonical/lxd involving lxd's non-recursive certificate listing bypasses per-object authorization and leaks all fingerprints under CWE-862: Missing Authorization.
Which Github.com/canonical/lxd versions are affected?
Affected versions include releases prior to 0.0.0-20260224152359-d936c90d47cf across affected packages: github.com/canonical/lxd.
What version fixes CVE-2026-3351?
Upgrading to 0.0.0-20260224152359-d936c90d47cf (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/canonical/lxd affected?
Yes, github.com/canonical/lxd is affected in releases prior to 0.0.0-20260224152359-d936c90d47cf. Upgrade your dependency to 0.0.0-20260224152359-d936c90d47cf or later.
What CWE is associated with CVE-2026-3351?
CVE-2026-3351 is classified under CWE-862: Missing Authorization (The product does not perform an authorization check when an actor attempts to access a resource or perform an action.).
How can I check Go dependencies for CVE-2026-3351?
Run the dependency tree command: 'inspect dependencies for github.com/canonical/lxd'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-crmg-9m86-636r",
  "modified" : "2026-03-04T20:18:56Z",
  "published" : "2026-03-04T20:18:56Z",
  "aliases" : [ "CVE-2026-3351" ],
  "summary" : "lxd's non-recursive certificate listing bypasses per-object authorization and leaks all fingerprints",
  "details" : "## Summary\nThe `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.\n\n## Affected Component\n- `lxd/certificates.go` — `certificatesGet` (lines 185–192) — Non-recursive code path returns unfiltered certificate list.\n\n## CWE\n- **CWE-862**: Missing Authorization\n\n## Description\n\n### Core vulnerability: missing permission filter in non-recursive listing path\n\nThe `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:\n\n```go\n// lxd/certificates.go:139-193\nfunc certificatesGet(d *Daemon, r *http.Request) response.Response {\n    recursion := util.IsRecursionRequest(r)\n    s := d.State()\n\n    userHasPermission, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeCertificate)\n    // ...\n\n    for _, baseCert := range baseCerts {\n        if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) {\n            continue  // Correctly filters unauthorized certs\n        }\n\n        if recursion {\n            // ... builds filtered certResponses ...\n        }\n        // NOTE: when !recursion, nothing is recorded — the filter result is discarded\n    }\n\n    if !recursion {\n        body := []string{}\n        for _, baseCert := range baseCerts {  // <-- iterates UNFILTERED baseCerts\n            certificateURL := api.NewURL().Path(version.APIVersion, \"certificates\", baseCert.Fingerprint).String()\n            body = append(body, certificateURL)\n        }\n        return response.SyncResponse(true, body)  // Returns ALL certificate fingerprints\n    }\n\n    return response.SyncResponse(true, certResponses)  // Recursive path is correctly filtered\n}\n```\n\n### Inconsistency with other list endpoints confirms the bug\n\nFive other list endpoints in the same codebase correctly filter results in both recursive and non-recursive paths:\n\n| Endpoint | File | Filters non-recursive? |\n|----------|------|----------------------|\n| Instances | `lxd/instances_get.go` — `instancesGet` | Yes — filters before either path |\n| Images | `lxd/images.go` — `doImagesGet` | Yes — checks `hasPermission` for both paths |\n| Networks | `lxd/networks.go` — `networksGet` | Yes — filters outside recursion check |\n| Profiles | `lxd/profiles.go` — `profilesGet` | Yes — separate filter in non-recursive path |\n| **Certificates** | **`lxd/certificates.go` — `certificatesGet`** | **No — unfiltered** |\n\nThe certificates endpoint is the sole outlier, confirming this is an oversight rather than a design choice.\n\n### Access handler provides no defense\n\nThe endpoint uses `allowAuthenticated` as its `AccessHandler` (`certificates.go:45`), which only checks `requestor.IsTrusted()`:\n\n```go\n// lxd/daemon.go:255-267\n// allowAuthenticated is an AccessHandler which allows only authenticated requests.\n// This should be used in conjunction with further access control within the handler\n// (e.g. to filter resources the user is able to view/edit).\nfunc allowAuthenticated(_ *Daemon, r *http.Request) response.Response {\n    requestor, err := request.GetRequestor(r.Context())\n    // ...\n    if requestor.IsTrusted() {\n        return response.EmptySyncResponse\n    }\n    return response.Forbidden(nil)\n}\n```\n\nThe comment explicitly states that `allowAuthenticated` should be \"used in conjunction with further access control within the handler\" — which the non-recursive path fails to do.\n\n### Execution chain\n\n1. Restricted authenticated user sends `GET /1.0/certificates` (no `recursion` parameter)\n2. `allowAuthenticated` access handler passes because user is trusted (`daemon.go:263`)\n3. `certificatesGet` creates permission checker for `EntitlementCanView` on `TypeCertificate` (line 143)\n4. Loop at lines 163-176 filters `baseCerts` by permission — but only populates `certResponses` for recursive mode\n5. Since `!recursion`, control reaches lines 185-192\n6. New loop iterates ALL `baseCerts` (unfiltered) and builds URL list with fingerprints\n7. Full list of certificate fingerprints returned to restricted user\n\n## Proof of Concept\n\n```bash\n# Preconditions: restricted (non-admin) trusted client certificate\nHOST=target.example\nPORT=8443\n\n# 1) Non-recursive list: returns ALL certificate fingerprints (UNFILTERED)\ncurl -sk --cert restricted.crt --key restricted.key \\\n  \"https://${HOST}:${PORT}/1.0/certificates\" | jq '.metadata | length'\n\n# 2) Recursive list: returns only authorized certificates (FILTERED)\ncurl -sk --cert restricted.crt --key restricted.key \\\n  \"https://${HOST}:${PORT}/1.0/certificates?recursion=1\" | jq '.metadata | length'\n\n# Expected: (1) returns MORE fingerprints than (2), proving the authorization bypass.\n# The difference reveals fingerprints of certificates the restricted user should not see.\n```\n\n## Impact\n\n- **Identity enumeration**: A restricted user can discover the fingerprints of all trusted certificates, revealing the complete set of identities in the LXD trust store.\n- **Reconnaissance for targeted attacks**: Fingerprints identify specific certificates used for inter-cluster communication, admin access, and other privileged operations.\n- **RBAC bypass**: In deployments using fine-grained RBAC (OpenFGA or built-in TLS authorization), the non-recursive path completely bypasses the intended per-object visibility controls.\n- **Information asymmetry**: Restricted users gain knowledge of the full trust topology, which the administrator explicitly intended to hide via per-certificate `can_view` entitlements.\n\n## Recommended Remediation\n\n### Option 1: Apply the permission filter to the non-recursive path (preferred)\n\nReplace the unfiltered loop with one that checks `userHasPermission`, matching the pattern used in the recursive path and in all other list endpoints:\n\n```go\n// lxd/certificates.go — replace lines 185-192\nif !recursion {\n    body := []string{}\n    for _, baseCert := range baseCerts {\n        if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) {\n            continue\n        }\n        certificateURL := api.NewURL().Path(version.APIVersion, \"certificates\", baseCert.Fingerprint).String()\n        body = append(body, certificateURL)\n    }\n    return response.SyncResponse(true, body)\n}\n```\n\n### Option 2: Build both response types in a single filtered loop\n\nRestructure the function to build both the URL list and the recursive response in the same permission-checked loop, eliminating the possibility of divergent filtering:\n\n```go\nerr = d.State().DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {\n    baseCerts, err = dbCluster.GetCertificates(ctx, tx.Tx())\n    if err != nil {\n        return err\n    }\n\n    certResponses = make([]*api.Certificate, 0, len(baseCerts))\n    certURLs = make([]string, 0, len(baseCerts))\n    for _, baseCert := range baseCerts {\n        if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) {\n            continue\n        }\n\n        certURLs = append(certURLs, api.NewURL().Path(version.APIVersion, \"certificates\", baseCert.Fingerprint).String())\n\n        if recursion {\n            apiCert, err := baseCert.ToAPI(ctx, tx.Tx())\n            if err != nil {\n                return err\n            }\n            certResponses = append(certResponses, apiCert)\n            urlToCertificate[entity.CertificateURL(apiCert.Fingerprint)] = apiCert\n        }\n    }\n    return nil\n})\n```\n\nOption 2 is structurally safer as it prevents the two paths from diverging in the future.\n\n## Credit\nThis vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:L/SI:N/SA:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/canonical/lxd"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "0.0.0-20260224152359-d936c90d47cf"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/canonical/lxd/security/advisories/GHSA-crmg-9m86-636r"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-3351"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/canonical/lxd/pull/17738"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/canonical/lxd/commit/d936c90d47cf0be1e9757df897f769e9887ebde1"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/canonical/lxd"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-862" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-03-04T20:18:56Z",
    "nvd_published_at" : "2026-03-03T13:16:21Z"
  }
}