Skip to content

Make the self-host server safe-by-default; add ReDoS input cap#13

Merged
kurtseifried merged 1 commit into
mainfrom
fix/safe-by-default-server-and-redos-cap
Jun 22, 2026
Merged

Make the self-host server safe-by-default; add ReDoS input cap#13
kurtseifried merged 1 commit into
mainfrom
fix/safe-by-default-server-and-redos-cap

Conversation

@kurtseifried

Copy link
Copy Markdown
Contributor

Summary

SecID-Server-API is the reference / self-host server (for users without Cloudflare). It previously bound to 0.0.0.0, exposed an unauthenticated POST /admin/reload, sent CORS *, and fed unbounded query input to registry regexes. This hardens all four to safe-by-default.

Audit reference: SecID-2026-06-14-claude-skill (findings F-06-01, F-06-02, F-06-03, F-03-02).

Safe-by-default server (python/secid_server.py)

Finding Before After
F-06-03 --host 0.0.0.0 default --host 127.0.0.1 default; 0.0.0.0 is an explicit opt-in with a loud startup warning
F-06-01 POST /admin/reload unauthenticated Gated by a dedicated reload token (SECID_RELOAD_TOKEN / --reload-token, header X-Reload-Token), hmac.compare_digest. Fails closed: no token configured → 401
F-06-02 allow_origins=["*"] CORS opt-in: no --cors-origin / SECID_CORS_ORIGINS → middleware not added at all

The reload token is scoped to reload only — deliberately not a master key. A future admin endpoint should get its own SECID_<CAP>_TOKEN rather than widening this one. The read path (/api/v1/resolve, /api/v1/types, /mcp) stays anonymous by design.

ReDoS runtime bound (python/resolver.py)

Registry-authored regexes run against attacker-controlled input on every request via Python re — no RE2, no timeout — and FastAPI's secid: str = Query(...) was unbounded. resolve() now rejects queries over MAX_SECID_QUERY_CHARS (1024), and over-long components (MAX_REGEX_INPUT, 256) are treated as no-match rather than truncated — so worst-case backtracking is bounded without changing any valid resolution (real SecIDs are < 200 chars). RE2 is the robust follow-up; this cap is the minimal stopgap. (F-03-02)

Tests

+9 regression tests, 43 pass (pytest test_smoke.py): reload 401-without-token / 401-wrong-token / 200-correct-token, read-path-needs-no-token, CORS off-by-default / on-when-allowlisted, oversize-query rejection (direct + HTTP), and the component-bound helper.

⚠️ Intentional backward-compat break (documented in README)

Existing self-hosters must now: (a) pass --host 0.0.0.0 to keep listening on all interfaces, (b) set a reload token to use /admin/reload at all, (c) pass --cors-origin if browser clients call cross-origin. A new "Security & Exposure Defaults" README section + an upgrade note cover this.

Notes for review

  • No real pattern in the registry is catastrophic today (all anchored) — both the CORS and ReDoS changes are defense-in-depth / missing-control fixes.
  • Token-in-header is only as safe as the transport: if 0.0.0.0 is used, front with TLS. Consider rate-limiting /admin/reload.
  • Simpler alternative to a token (noted, not taken): bind /admin/* to loopback only or replace it with a CLI/SIGHUP refresh. The token path was chosen because it was the requested design.

🤖 Generated with Claude Code

This is the reference/self-host server (for users without Cloudflare). It
previously bound to 0.0.0.0, exposed an unauthenticated POST /admin/reload,
sent CORS "*", and fed unbounded query input to registry regexes.

Safe-by-default server (secid_server.py):
- Default bind is now 127.0.0.1; --host 0.0.0.0 is an explicit opt-in with a
  loud startup warning (F-06-03).
- POST /admin/reload is gated by a dedicated reload token (SECID_RELOAD_TOKEN
  / --reload-token, header X-Reload-Token), compared with hmac.compare_digest.
  Fails closed: no token configured => 401. Scoped to reload only, not a master
  key — future admin endpoints get their own token (F-06-01).
- CORS is opt-in: with no --cors-origin / SECID_CORS_ORIGINS configured the
  middleware is not added at all (F-06-02). Read path stays anonymous by design.

ReDoS runtime bound (resolver.py):
- Registry regexes run against attacker input every request (Python re, no
  RE2, no timeout) and FastAPI's secid Query was unbounded. resolve() now
  rejects queries over MAX_SECID_QUERY_CHARS (1024), and over-long components
  (MAX_REGEX_INPUT, 256) are treated as no-match — bounding worst-case
  backtracking without changing any valid resolution (F-03-02).

+9 regression tests (43 pass). README documents the new defaults and the
backward-compat break for existing self-hosters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kurtseifried kurtseifried merged commit e5311dc into main Jun 22, 2026
6 checks passed
@kurtseifried kurtseifried deleted the fix/safe-by-default-server-and-redos-cap branch June 22, 2026 16:14
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