Skip to content

fix(security): tighten CSP and add hardening headers (OWASP A02) - #52

Merged
carbon-evolution merged 1 commit into
mainfrom
fix/security-headers
Oct 18, 2026
Merged

carbon-evolution merged 1 commit into
mainfrom
fix/security-headers

Conversation

@carbon-evolution

Copy link
Copy Markdown
Owner

Fixes two OWASP A01 (Broken Access Control) findings.

  1. The recon scanner proxy was open to anyone. Whenever SCANNER_KEY was set, /api/scanner let any visitor make the server scan arbitrary public hosts, and the targets saw the operator's IP.
  2. The client IP came from forgeable headers. clientIp() (guard.ts) and getClientIp() (ssrf-guard.ts) read x-forwarded-for / x-real-ip / cf-connecting-ip as sent by the client. Anyone could get around per-IP rate limits and forge the IP in the audit log.

What Next.js 16 route handlers can see (tested, not assumed)

I added a temporary route that echoed the request headers (removed before commit), ran it under next start -p 3022 (Next 16.4.2), and called it with curl:

Request What the handler saw
plain curl 127.0.0.1:3022 x-forwarded-for: ::ffff:127.0.0.1, x-forwarded-host/port/proto filled in
with X-Forwarded-For: 6.6.6.6, X-Real-IP: 7.7.7.7, CF-Connecting-IP: 8.8.8.8, X-Forwarded-Host: evil.com all four passed through unchanged: x-forwarded-for: 6.6.6.6, no socket address added
Host: localhost:3022 from anywhere Host passed through as sent

The cause is in next/dist/server/base-server.js: req.headers['x-forwarded-for'] ??= socket.remoteAddress. App-router handlers have no request.ip, and the socket address only shows up when the client didn't send its own XFF. So without a trusted proxy, no header can tell a handler where a request really came from. A proxy-free check like "allow if the request is from loopback" can always be faked by a non-browser client.

Design and threat model

Scanner gate (src/lib/scanner-access.ts, applied in src/app/api/scanner/route.ts)

A request is allowed, in this order, when:

  1. SCANNER_ALLOW_ANONYMOUS=true: the operator explicitly makes the scanner public (documented as not recommended).
  2. SCANNER_ACCESS_TOKEN is set and the request sends it in X-Scanner-Token. The comparison uses crypto.timingSafeEqual on SHA-256 digests, so differing lengths don't leak timing either. A custom header can't be attached cross-origin without a CORS preflight, so another site can't use a token holder's browser to run scans.
  3. Local dev, zero config: NODE_ENV === 'development' (npm run dev / npm run osiris) and the request looks like it came from loopback:
    • Host is localhost / 127.x / [::1]
    • XFF is absent or a single loopback address
    • no x-real-ip / cf-connecting-ip / forwarded header
    • Sec-Fetch-Site is absent, same-origin or none

Anything else is refused:

  • 401 {auth:"token"} when a token is configured (the UI then prompts for it).
  • 403 when the operator has configured no way in for remote clients.

The gate runs after the rate limit, so token guesses are throttled too.

Trade-off for rule 3: because of what the table above shows, the loopback check uses headers the client controls. It does block browser-based abuse: DNS rebinding (wrong Host), a LAN visitor's browser (wrong Host), and a cross-site page firing requests at localhost:3000 (Sec-Fetch-Site). It does not stop a deliberate non-browser client on the same network that forges those headers. That's why the rule only applies to next dev, which should never be publicly exposed anyway. Every production build (next start, Docker, standalone) needs rule 1 or 2. I considered loopback detection for production builds and rejected it: the handler can't learn the socket address, and Docker requests arrive from the bridge gateway, not loopback.

Recon panel (OsintPanel.tsx): on a 401 from /api/scanner, the panel:

  • prompts once for the token and retries;
  • stores the token in sessionStorage only if the server accepts it (never localStorage);
  • clears a rejected stored token and asks again;
  • shows the server's error (e.g. "Scanner disabled for remote clients") if the user cancels or gets a 403.

Trusted client IP (src/lib/client-ip.ts)

There is now one helper. guard.clientIp and ssrf-guard.getClientIp both re-export it, so all callers keep their signatures. Behaviour by TRUST_PROXY_HEADERS value:

  • unset / false: forwarding headers are ignored and the IP is 'unknown'. Per-IP limits become one shared bucket per endpoint. That's a hard cap on abuse instead of a limit anyone can bypass.
  • true / 1: uses the rightmost XFF entry, which is the address the nearest trusted proxy appended. The old code took the first entry, and that one is client-supplied even behind nginx.
  • N: N proxies in a chain; uses the Nth entry from the right.
  • cloudflare: uses cf-connecting-ip.

Values that aren't IP addresses become 'unknown', which also stops log injection through the header.

src/app/api/geo/route.ts still reads the headers on its own. That route only geolocates the visitor for their own map, so a forged value only affects the person forging it; I left it unchanged.

Also fixed

  • The scan-type allowlist now checks own keys only. Before, type=constructor resolved through Object.prototype and only failed later, at AbortSignal.timeout(undefined) (502).

New env vars (appended at the end of .env.example)

Var Meaning
SCANNER_ACCESS_TOKEN Shared secret for trusted users; the panel sends it as X-Scanner-Token
SCANNER_ALLOW_ANONYMOUS true = any visitor can run scans (not recommended)
TRUST_PROXY_HEADERS true / <N> / cloudflare. Set it only when the app port is reachable solely through that proxy

README and DOCKER.md explain all three. nginx/nginx.conf now has a comment explaining that its $proxy_add_x_forwarded_for appends the real client address and OSIRIS reads that rightmost entry when TRUST_PROXY_HEADERS=true. The nginx config itself is unchanged: it already overwrites X-Real-IP and appends to XFF.

⚠️ Migration note for operators

  • If you use the RECON panel on a production, Docker or public host, set SCANNER_ACCESS_TOKEN and give it to trusted users, or the recon scans will return 403. npm run osiris / npm run dev on localhost needs no change.
  • Behind the bundled nginx, set TRUST_PROXY_HEADERS=true to keep per-client rate limits. Without it, all clients share one bucket. Note that docker-compose.yml also publishes the app on OSIRIS_PORT directly. Only enable trust if that port isn't reachable from outside, otherwise clients can bypass nginx and forge the header.

Tests

  • src/app/api/scanner-access.test.ts (25 tests). It lives next to route-hardening.test.ts because .gitignore's scanner/ entry ignores test files inside api/scanner/. Covered:
    • default config → 403 with no upstream fetch;
    • token configured but missing → 401 (auth:"token"), no fetch;
    • wrong token → 401, no fetch;
    • correct token → proxied to the sidecar;
    • anonymous opt-in → proxied;
    • next dev + loopback → proxied;
    • four "not local" cases → 403, no fetch: production build with localhost Host, dev with a public Host, dev with a non-loopback XFF, dev with a cross-site request;
    • unconfigured scanner → 503;
    • SSRF blocks unchanged (127.0.0.1, 169.254.169.254, 10.0.0.1, ::1, localhost, 2130706433);
    • restricted scan types refused (deep, ports, banner, traceroute, constructor);
    • missing target → 400;
    • rotating a forged XFF doesn't reset the rate limit;
    • with trust enabled, separate proxy-reported clients get separate buckets.
  • src/lib/client-ip.test.ts (20 tests, run against both guard.clientIp and ssrf-guard.getClientIp): headers ignored for unset / '' / false / 0 / nonsense; true → rightmost entry; 2 → second from the right; fallback to X-Real-IP; cloudflare; non-IP values rejected; ip:port normalised.

Against the previous code, 28 of the 45 new tests fail:

  • every gate case that expects 401/403;
  • type=constructor;
  • both rate-limit spoofing tests;
  • all client-IP trust tests.

Full suite: npx vitest run (with the usual 4 infra excludes) → 111/111 pass. Also passing: tsc --noEmit (0 errors), eslint . (0 errors; OsintPanel still has 39 warnings, same as before), npm run build.

Live evidence (production build, next start -p 3022)

The sidecar wasn't running, so SCANNER_URL pointed at an unused port. In these results, 502 "Scanner unreachable / fetch failed" means the request passed the gate and reached the upstream-fetch stage.

  • Default config:
    • curl /api/scanner?target=93.184.215.14&type=headers → 403 "Scanner disabled for remote clients"
    • same with Host: localhost + X-Forwarded-For: 127.0.0.1 → 403
    • the debug route → 404 (it's gone)
  • SCANNER_ACCESS_TOKEN set:
    • no token → 401 "Scanner access token required"
    • wrong token → 401 "Invalid scanner access token"
    • correct token → 502 (proxied)
    • correct token with a different forged XFF on each call: 502, 502, 502, then the 6th request in the window → 429. The forged headers didn't reset the limit.
  • Token + TRUST_PROXY_HEADERS=true:
    • 6 calls XFF: 1.1.1.1, 203.0.113.$i (rightmost entry varies) → six 502s, separate buckets
    • 6 calls XFF: 198.51.100.$i, 203.0.113.77 (only the forged leftmost entry varies) → 502 ×5, then 429
  • next dev -p 3022, no token:
    • from localhost → 502 (proxied, zero config)
    • forged Host: osiris.example.com → 403
    • Sec-Fetch-Site: cross-site → 403
  • Browser (Playwright, production build with a token): HEADERS tab → the panel prompted "This OSIRIS instance requires a scanner access token…". I entered a wrong token and was prompted again, then entered the right one. The token ended up in sessionStorage (osiris.scannerAccessToken), and localStorage was empty. The next scan sent X-Scanner-Token automatically with no prompt; it returned 429 because my earlier tries had used up the 5/min scanner budget.

🤖 Generated with Claude Code

ZAP baseline findings (10055, 10063, 90004, 10037):
- Replace the catch-all default-src CSP with explicit directives:
  default-src 'self', object-src 'none', base-uri/form-action/
  frame-ancestors 'self', same-origin scripts and workers, Google Fonts
  only for style/font. 'unsafe-eval' is now dev-only.
- Add Permissions-Policy denying unused features.
- Add Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy
  (same-origin). COEP intentionally omitted: it would block third-party
  camera images, HLS video and embedded players.
- poweredByHeader: false (drops X-Powered-By).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@carbon-evolution
carbon-evolution merged commit bba2c3b into main Oct 18, 2026
8 checks passed
@carbon-evolution
carbon-evolution deleted the fix/security-headers branch October 18, 2026 04:54
carbon-evolution added a commit that referenced this pull request Oct 18, 2026
- A01 Broken Access Control: Medium -> Low. Recon scanner closed by
  default (token / explicit opt-in), client IP from forwarding headers
  only with TRUST_PROXY_HEADERS (#51).
- A06 Insecure Design: Medium -> Low. Analytics opt-in, visitor-IP
  event removed (#50); recon no longer anonymous by default.
- A02 Security Misconfiguration: stays Medium (improved). Hardening
  headers and a tighter CSP without unsafe-eval (#52); ZAP on main now
  0 High / 3 Medium / 1 Low / 4 Info (was 0/5/5/5). Remaining Medium
  items are script/style 'unsafe-inline' and wildcard media sources.
- A07: scanner access token compared in constant time.
- Overall: 2 Medium, 8 Low (was 4 Medium, 6 Low); Scorecard 8.0.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant