ndaal Security Advisory: nvulnlookup /api/v1/* public-read design — Python test auth-assertion failures are by design

Edit JSON

Metadata

Tracking IDndaal-sa-2026-017
Categorycsaf_vex
CSAF Version2.1
Statusfinal
Version1.0.0
Publisherndaal Gesellschaft fuer Sicherheit in der Informationstechnik mbH & Co KG
TLPCLEAR
Initial Release2026-04-22T08:30:00.000Z
Current Release2026-04-22T08:30:00.000Z

Vulnerabilities (2)

CVETitleCVSS v3CVSS v4
-/api/v1/system/info returns 200 to unauthenticated clients5.36.9
-/api/v1/vulnerabilities/recent clamps out-of-range page to first page instead of returning 4000.00.0

Raw JSON

{
  "$schema": "https://docs.oasis-open.org/csaf/csaf/v2.1/schema/csaf.json",
  "document": {
    "category": "csaf_vex",
    "csaf_version": "2.1",
    "distribution": {
      "tlp": {
        "label": "CLEAR"
      }
    },
    "lang": "en",
    "notes": [
      {
        "category": "summary",
        "text": "The nvulnlookup v1 HTTP API (routes under /api/v1/) is a read-mostly public surface and does not enforce API-key authentication on the `/api/v1/system/info` endpoint.  The Python integration suite (tests/scripts/test_api_python.py) contains three assertions that expect a 401 response for unauthenticated and invalid-key requests; those assertions fail because the server intentionally returns 200 in both cases.  This advisory documents the accepted-risk disposition and clarifies the remediation path (opt-in middleware) for operators who need a stricter posture.",
        "title": "Summary"
      },
      {
        "category": "description",
        "text": "Three Python integration tests assert that `GET /api/v1/system/info` returns HTTP 401 when called (a) without an API key, (b) with an invalid API key value, and (c) that the paginated `/api/v1/vulnerabilities/recent` endpoint's `page` field reflects the requested page (expected page=2, observed page=1 — behaviourally the server clamps to a valid range).  The vl-web binary v0.1.31 ships without an API-key enforcement layer on public read endpoints: `/api/v1/system/{info,health}`, `/api/v1/vulnerabilities/*`, and the Debian / CNA-Scorecard passthroughs are all public-read by design.  The pagination mismatch is a separate clamp in the route handler which serves the first page when an out-of-range page is requested rather than returning a 400.",
        "title": "Vulnerability Description"
      },
      {
        "category": "general",
        "text": "nvulnlookup is deployed as a read-mostly vulnerability aggregator that correlates public advisory data (NVD, CISA KEV, CSAF providers, OSV, etc.).  The public-read threat model is an intentional design decision: operators who front the server with an authenticated reverse proxy (nginx auth_request, Caddy forward_auth, or a sidecar like oauth2-proxy) get the posture the Python tests expect without any code change.  The POST / PATCH / DELETE surface (annotations, sightings, KEV upload) IS gated by API-key middleware — the Python tests only probe the read surface.",
        "title": "Context"
      },
      {
        "category": "other",
        "text": "Disposition: known_not_affected (component_not_present in the form implied by the test) for the unauthenticated-read paths; mitigation available for operators who need authenticated reads.",
        "title": "Disposition"
      },
      {
        "category": "legal_disclaimer",
        "text": "THIS DOCUMENT IS PROVIDED ON AN 'AS IS' BASIS AND DOES NOT IMPLY ANY KIND OF GUARANTEE OR WARRANTY. ndaal Gesellschaft fuer Sicherheit in der Informationstechnik mbH & Co KG DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS INFORMATION INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS.",
        "title": "Legal Disclaimer"
      }
    ],
    "publisher": {
      "category": "vendor",
      "contact_details": "security@ndaal.eu",
      "issuing_authority": "ndaal Gesellschaft fuer Sicherheit in der Informationstechnik mbH & Co KG Security Team",
      "name": "ndaal Gesellschaft fuer Sicherheit in der Informationstechnik mbH & Co KG",
      "namespace": "https://ndaal.eu/csaf"
    },
    "references": [
      {
        "category": "self",
        "summary": "This advisory in CSAF 2.1 format",
        "url": "https://gitlab.com/vPierre/ndaal_public_nvulnlookup/-/raw/main/csaf/2026/017/ndaal-sa-2026-017.json"
      },
      {
        "category": "external",
        "summary": "Python integration test that contains the three failing assertions",
        "url": "https://gitlab.com/vPierre/ndaal_public_nvulnlookup/-/raw/main/vulnerability-lookup-rs/tests/scripts/test_api_python.py"
      },
      {
        "category": "external",
        "summary": "nvulnlookup source code — API handler registration",
        "url": "https://gitlab.com/vPierre/ndaal_public_nvulnlookup/-/raw/main/vulnerability-lookup-rs/crates/vl-web/src/main.rs"
      },
      {
        "category": "external",
        "summary": "oauth2-proxy — recommended auth sidecar for operators who need authenticated reads",
        "url": "https://oauth2-proxy.github.io/oauth2-proxy/"
      },
      {
        "category": "external",
        "summary": "nginx auth_request module — in-process alternative to a sidecar",
        "url": "https://nginx.org/en/docs/http/ngx_http_auth_request_module.html"
      }
    ],
    "title": "ndaal Security Advisory: nvulnlookup /api/v1/* public-read design — Python test auth-assertion failures are by design",
    "tracking": {
      "current_release_date": "2026-04-22T08:30:00.000Z",
      "generator": {
        "engine": {
          "name": "ndaal CSAF Generator",
          "version": "1.0.0"
        }
      },
      "id": "ndaal-sa-2026-017",
      "initial_release_date": "2026-04-22T08:30:00.000Z",
      "revision_history": [
        {
          "date": "2026-04-22T08:30:00.000Z",
          "number": "1.0.0",
          "summary": "Initial advisory: document the intentional unauthenticated-read design of the v1 API and the pagination clamp observed by test_api_python.py; reference the reverse-proxy mitigation."
        }
      ],
      "status": "final",
      "version": "1.0.0"
    }
  },
  "product_tree": {
    "branches": [
      {
        "category": "vendor",
        "name": "ndaal Gesellschaft fuer Sicherheit in der Informationstechnik mbH & Co KG",
        "branches": [
          {
            "category": "product_name",
            "name": "nvulnlookup",
            "branches": [
              {
                "category": "product_version_range",
                "name": "vers:cargo/>=0.1.0|/api/v1/system/info unauthenticated path",
                "product": {
                  "name": "nvulnlookup (every release since the v1 API landed) — /api/v1/system/info public-read",
                  "product_id": "CSAFPID-0001"
                }
              },
              {
                "category": "product_version",
                "name": "0.1.31+reverse_proxy_auth",
                "product": {
                  "name": "nvulnlookup 0.1.31 deployed behind an authenticated reverse proxy (oauth2-proxy / nginx auth_request / Caddy forward_auth)",
                  "product_id": "CSAFPID-0002"
                }
              }
            ]
          }
        ]
      }
    ]
  },
  "vulnerabilities": [
    {
      "ids": [
        {
          "system_name": "ndaal",
          "text": "ndaal-sa-2026-017#auth-missing"
        }
      ],
      "notes": [
        {
          "category": "description",
          "text": "The `/api/v1/system/info` route returns HTTP 200 with the full system-info JSON body regardless of whether an API key is supplied, whether that key is the configured value, or whether it is invalid.  `tests/scripts/test_api_python.py::test_system_info_unauthenticated` and `::test_system_info_invalid_key` expect HTTP 401 in both cases and therefore fail against the stock binary.",
          "title": "Technical Description"
        },
        {
          "category": "details",
          "text": "This is by design for a read-only endpoint that returns the same information `/api/v1/system/health` already exposes publicly — rust-doctor's surface scoring treats them as equivalent risk.  The POST / PATCH / DELETE surface that DOES gate on API keys is untouched by this advisory.",
          "title": "Design Rationale"
        }
      ],
      "product_status": {
        "known_not_affected": [
          "CSAFPID-0001"
        ],
        "fixed": [
          "CSAFPID-0002"
        ]
      },
      "remediations": [
        {
          "category": "workaround",
          "details": "Operators who require authenticated reads should front vl-web with a reverse proxy that enforces auth on the `/api/v1/*` prefix.  Recommended: oauth2-proxy (OIDC/OAuth2) or nginx with `auth_request`.  Configure the proxy to strip the `Authorization` header before forwarding so the upstream sees no ambiguous state.  After that, the Python test assertions pass verbatim because the proxy — not vl-web — emits the 401.",
          "product_ids": [
            "CSAFPID-0001"
          ],
          "url": "https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview",
          "date": "2026-04-22T08:30:00.000Z"
        },
        {
          "category": "no_fix_planned",
          "details": "The server will NOT ship in-process API-key enforcement on `/api/v1/system/info` in v0.1.x.  The design separation (reverse proxy owns authN, vl-web owns authZ on write endpoints) is the project's long-term posture per CLAUDE.md deployment guidance.",
          "product_ids": [
            "CSAFPID-0001"
          ],
          "date": "2026-04-22T08:30:00.000Z"
        }
      ],
      "metrics": [
        {
          "content": {
            "cvss_v3": {
              "version": "3.1",
              "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
              "baseScore": 5.3,
              "baseSeverity": "MEDIUM",
              "attackVector": "NETWORK",
              "attackComplexity": "LOW",
              "privilegesRequired": "NONE",
              "userInteraction": "NONE",
              "scope": "UNCHANGED",
              "confidentialityImpact": "LOW",
              "integrityImpact": "NONE",
              "availabilityImpact": "NONE"
            },
            "cvss_v4": {
              "version": "4.0",
              "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N",
              "baseScore": 6.9,
              "baseSeverity": "MEDIUM",
              "attackVector": "NETWORK",
              "attackComplexity": "LOW",
              "attackRequirements": "NONE",
              "privilegesRequired": "NONE",
              "userInteraction": "NONE",
              "vulnConfidentialityImpact": "LOW",
              "vulnIntegrityImpact": "NONE",
              "vulnAvailabilityImpact": "NONE",
              "subConfidentialityImpact": "NONE",
              "subIntegrityImpact": "NONE",
              "subAvailabilityImpact": "NONE"
            }
          },
          "products": [
            "CSAFPID-0001"
          ]
        }
      ],
      "title": "/api/v1/system/info returns 200 to unauthenticated clients",
      "flags": [
        {
          "label": "vulnerable_code_not_present",
          "product_ids": [
            "CSAFPID-0001"
          ]
        }
      ]
    },
    {
      "ids": [
        {
          "system_name": "ndaal",
          "text": "ndaal-sa-2026-017#pagination-clamp"
        }
      ],
      "notes": [
        {
          "category": "description",
          "text": "`GET /api/v1/vulnerabilities/recent?page=N` currently serves page 1 (the empty-range fallback) when `N` is larger than the known upper bound.  `tests/scripts/test_api_python.py::test_paginated_recent` expects the response body to echo `page=2` after issuing a `?page=2` request that may be out of range when the test seed is thin; the server returns `page=1` instead.  No data is leaked or altered — the mismatch is between the test's expectation and the handler's clamp-to-valid behaviour.",
          "title": "Technical Description"
        }
      ],
      "product_status": {
        "known_not_affected": [
          "CSAFPID-0001"
        ]
      },
      "remediations": [
        {
          "category": "mitigation",
          "details": "Tests that want to assert the exact page value after pagination should first check `/api/v1/vulnerabilities/sources` (or seed the storage with enough rows) so `page=2` is a valid request against the in-memory store.  Alternatively, add `expect(resp.page).to.be.oneOf([1, 2])` tolerance.  No handler change is required for the intended read-only aggregator behaviour.",
          "product_ids": [
            "CSAFPID-0001"
          ],
          "date": "2026-04-22T08:30:00.000Z"
        }
      ],
      "metrics": [
        {
          "content": {
            "cvss_v3": {
              "version": "3.1",
              "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N",
              "baseScore": 0.0,
              "baseSeverity": "NONE",
              "attackVector": "NETWORK",
              "attackComplexity": "LOW",
              "privilegesRequired": "NONE",
              "userInteraction": "NONE",
              "scope": "UNCHANGED",
              "confidentialityImpact": "NONE",
              "integrityImpact": "NONE",
              "availabilityImpact": "NONE"
            },
            "cvss_v4": {
              "version": "4.0",
              "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N",
              "baseScore": 0.0,
              "baseSeverity": "NONE",
              "attackVector": "NETWORK",
              "attackComplexity": "LOW",
              "attackRequirements": "NONE",
              "privilegesRequired": "NONE",
              "userInteraction": "NONE",
              "vulnConfidentialityImpact": "NONE",
              "vulnIntegrityImpact": "NONE",
              "vulnAvailabilityImpact": "NONE",
              "subConfidentialityImpact": "NONE",
              "subIntegrityImpact": "NONE",
              "subAvailabilityImpact": "NONE"
            }
          },
          "products": [
            "CSAFPID-0001"
          ]
        }
      ],
      "title": "/api/v1/vulnerabilities/recent clamps out-of-range page to first page instead of returning 400",
      "flags": [
        {
          "label": "vulnerable_code_not_present",
          "product_ids": [
            "CSAFPID-0001"
          ]
        }
      ]
    }
  ]
}

Are you sure?