Skip to content

[P2][security][needs-validation] POST /scan and /start accept a CORS-simple cross-site request with an empty body #2096

Closed

Description

@FabioLeitao

Source: security-audit skill, run data-boar-run-1 (profile quick, source-only: no target code was executed), snapshot origin/main @ 7f354a34, 2026-10-18. Each trace below was found by a hunter agent and then re-read in the snapshot by an independent verifier. Status: needs validation — the source trace holds, the runtime result is not observed. Please start with the bounded local test in the plan; if it reproduces, turn it into a regression test and fix, otherwise close with the evidence.

POST /scan and /start accept a CORS-simple cross-site request (empty body) with no Origin, Sec-Fetch-Site or Content-Type gate

Source re-read confirms the trace. POST /scan and POST /start (api/routes.py 1415-1421) declare body: ScanStartBody | None = None, so a request with no body and no Content-Type is valid. A browser can send that as a CORS-simple cross-site request (fetch with mode no-cors and no body), which needs no preflight. A grep for cors, sec-fetch, Origin and Referer finds no CORS, Origin or Sec-Fetch-Site check anywhere in the application Python. The middleware stack at routes.py 1331-1334 is only the body-size cap and TrustedHost. When api.require_api_key is false, the default and the only mode allowed on loopback without a key, optional_api_key_middleware calls call_next with no check (routes.py 1212-1213). A page on any origin that the operator's browser visits could therefore start a scan against the configured targets on a default-open instance bound to loopback. The attacker cannot read the response, so the impact is an unauthorised state change: a session record is created (1443), engine._run_audit_targets is scheduled in a background task (1518) against the configured targets, and scan-complete notifications fire. Rate limiting applies only if rate_limit.enabled is set (_check_rate_limit, routes.py 898-901). PATCH /sessions/{id} (1837) and POST /scan_database need a JSON body, so under FastAPI strict_content_type they need application/json and a preflight. POST /scan_pdf has no body parameter at all and is a stub gated by the licence check, so it carries no scan impact. Cookie variant: with require_api_key true, a valid WebAuthn session cookie satisfies the gate (routes.py 1232). That cookie is SameSite=Lax (api/webauthn_routes.py 298-305), so it is not sent on a cross-site POST. It is sent from a same-site context, for example another port on localhost, and that overlaps open issue #2079 (stateless Lax cookie, no Origin check). The default-open, no-credential variant is not covered by #2079 (WebAuthn cookie), #2077 (/health) or #2078 (API-key throttling), so a materially different path remains. The decisive unknown is browser behaviour: whether current browsers deliver a cross-site no-cors POST to a loopback address, given Local/Private Network Access prompts or blocking. That cannot be seen from source. Likelihood and impact are not rated: this is needs_validation.

Root cause. State-changing JSON routes rely on a JSON body or credential header for protection, but the optional body (ScanStartBody | None = None) makes the empty-body, no-Content-Type form valid. The server has no Origin, Sec-Fetch-Site or Content-Type enforcement, and CORS preflight does not apply to this request shape. In the default-open loopback mode no credential gate applies either.

Trace.

  1. api/routes.py:1419 (entrypoint) — Handler signature body: ScanStartBody | None = None. An empty body yields None and is accepted; there is no Content-Type, Origin or Sec-Fetch-Site requirement. Under fastapi==0.142.2 strict_content_type, a non-empty body without a JSON content type is not parsed as JSON, but an empty body is valid.
  2. api/routes.py:1212 (propagation) — When api.require_api_key is false (default) the middleware calls call_next with no check. With it true, a valid WebAuthn session cookie satisfies the gate (line 1232, webauthn_session_satisfies_require_api_key in api/webauthn_html_gate.py 72-85). That cookie is SameSite=Lax (api/webauthn_routes.py 298-305).
  3. api/routes.py:1331 (propagation) — Only RequestBodySizeLimitMiddleware and TrustedHostMiddleware are added. No CORS, Origin, Referer or Sec-Fetch-Site validation exists in the application code. TrustedHost accepts the loopback Host names, so a normal cross-site request to http://127.0.0.1:8088 or http://localhost:8088 passes it.
  4. api/routes.py:1443 (sink) — Creates a session record (1443) and schedules run_targets via background_tasks.add_task (1518). run_targets runs the audit against all configured targets, then calls notify_scan_complete_background. _check_rate_limit (1431) returns immediately unless rate_limit.enabled is set.

What blocks confirmation.

Bounded local test (dummy data, sandbox, no network). In a sandbox with loopback only and no external network, use a dummy config with one file target holding synthetic data and require_api_key false. Send POST /scan with no body and no Content-Type, then again with Origin: http://evil.example and Sec-Fetch-Site: cross-site, and confirm HTTP 200 with a session_id each time (wait for the first run to finish between sends to avoid the 409 singleton). Then send a non-empty body with Content-Type text/plain and confirm a 422. Optionally repeat with require_api_key true and a test WebAuthn session cookie to confirm the cookie variant works only from a same-site context.

Owner-observed check. The owner loads a page from a different origin, for example a throwaway local page on another port or host, in current Chrome and Firefox against a dummy instance bound to 127.0.0.1 with synthetic targets. The page runs fetch('http://127.0.0.1:8088/scan', {method:'POST', mode:'no-cors'}). The owner records whether the scan starts and whether any local-network-access prompt or block appears. No traffic goes to a real deployment.

Suggested direction (recommendation). Require Content-Type: application/json and a body (or reject empty bodies), and validate Origin/Sec-Fetch-Site on state-changing routes; the first-party dashboard already sends JSON. Overlaps in theme with #2079 (cookie variant) but this is the default-open, no-credential path.

Activity

  1. added
    P2Medium — important, next sprint
    securitySecurity hardening, abuse/DoS defense, leak prevention
    on Oct 18, 2026
  2. FabioLeitao commented on Oct 20, 2026

    @FabioLeitao
    CollaboratorAuthor

    Resolvida pela PR #2114 (merge 87f1fe0). Commit df36cbe. Teste: test_scan_and_start_reject_missing_json_content_type em tests/test_api_scan.py.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium — important, next sprintsecuritySecurity hardening, abuse/DoS defense, leak prevention

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions