Verify sensitive requests actually came through Traefik (#125)#179
Open
ClaydeCode wants to merge 3 commits into
Open
Verify sensitive requests actually came through Traefik (#125)#179ClaydeCode wants to merge 3 commits into
ClaydeCode wants to merge 3 commits into
Conversation
Every installed app shares the `portal` docker network with shard_core, so an app can reach `/protected` and `/management` directly and bypass the Traefik forwardAuth gate. As the first half of closing that hole, introduce a shared secret that only Traefik knows. `service/traefik_secret.py` generates the secret once (stored in kv_store, matching the terminal_jwt_secret pattern) and exposes a FastAPI dependency to verify it. `write_traefik_dyn_config` now compiles a `verify-traefik` middleware that injects the secret as the `X-Ptl-Traefik-Secret` request header, wired onto the `shard_core_private` and `shard_core_management` routers. Enforcement is added in the next commit; on its own this change only adds an ignored header, so behaviour is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Gate the `/protected` and `/management` routers on the shared secret injected by the `verify-traefik` middleware: a request arriving directly from an app on the `portal` network lacks the `X-Ptl-Traefik-Secret` header and is now rejected with 403. `/internal` stays ungated by design — its forwardAuth endpoints are called by Traefik directly (never through the middleware chain) and `call_backend`/`call_peer` are called by apps directly. Test fixtures (`app_client`, `api_client`) now carry the secret header to represent requests that arrived through Traefik, so existing tests are unaffected. New `tests/test_traefik_secret.py` proves the bypass is rejected, the correct/absent/wrong-secret cases, that public and forwardAuth routes stay reachable, and that the middleware is attached with the stored secret. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Review panel flagged that the shared secret only raises the bar: because Postgres (default creds) and the unauthenticated Traefik dashboard both sit on the `portal` network, an app that actively targets shard_core can still read the secret. Reword the agents.md note so it no longer overstates the protection and points at the network-isolation follow-up (the long-term half of #125) as what actually seals the gap. Add two tests closing advisory coverage gaps: the `/protected/ws/updates` websocket handshake is denied without the secret (with an accept-with-secret positive control so the check has teeth), and a missing kv_store secret fails closed with 500 rather than opening the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Closes the short-term half of #125.
Auth on a shard is enforced entirely by Traefik forwardAuth + docker network topology; FastAPI routes carry no auth dependency. Every installed app joins the same
portaldocker network as shard_core, which serves plain HTTP with no in-app auth — so any app container cancurl http://shard_core/protected/...or/management/...directly and bypass all auth.This adds a shared secret that only Traefik injects:
service/traefik_secret.pygenerates a secret once (stored inkv_store, matching theterminal_jwt_secretpattern) and exposes averify_traefik_secretFastAPI dependency that constant-time-compares theX-Ptl-Traefik-Secretrequest header.verify-traefikmiddleware (customRequestHeaders) that injects the secret, wired onto theshard_core_privateandshard_core_managementrouters./protectedand/managementrouters now depend onverify_traefik_secret. A request that did not traverse Traefik lacks the header and is rejected with 403./internalis deliberately left ungated: its forwardAuth endpoints (authenticate_terminal,authenticate_management,auth) are called by Traefik directly (they never pass through the middleware chain, so they never carry the header), andcall_backend/call_peerare called by apps directly by design.The review panel (below) found this mitigation is intentionally partial and only raises the bar. Because both Postgres (
kv_store, default creds) and the Traefik dashboard (api.insecure: true) sit on the sameportalnetwork as the apps, an app that actively targets shard_core can still read the secret (psql ... SELECT value FROM kv_storeorcurl http://traefik:8080/api/rawdata) and forge the header. So this stops accidental/naive cross-app calls and any app not specifically hunting the secret — but not a determined one.Fully sealing it needs the network-isolation work that #125 itself defers to its long-term half: isolate Postgres from
portal(+ non-default creds) and disable the unauthenticated Traefik API. I kept this PR surgical (no topology changes) and did not fold that in, since #125 explicitly scopes it separately and it carries real deployment blast radius. Decision for you: merge this as the documented short-term step and track the network-hardening follow-up, or you'd prefer I expand scope here. Theagents.mdnote is written to not overstate the protection.Acceptance criteria
/protected/...request from insideportalwithout the injected secret is rejected (test_protected_without_secret_is_rejected).test_app_lifecycle.py::test_app_starts_and_stops, a pre-existing environmental collision — the grind VM's ownclayde-traefik-1permanently holds host port 80, which thequick_stopmock app publishes; the test fails atdocker compose up -d, a path this change never touches. It is green on CI (port 80 free).Review panel
Three adversarial reviewers ran (each given only the diff + issue, instructed to refute):
Adversarial correctness — 1 blocking, 1 advisory.
api.insecure: trueontraefik:8080, reachable onportal) → an app readscustomRequestHeadersand forges the header. Resolved: confirmed real; it is a facet of the co-location limitation now documented inagents.mdand in the scope section above, and escalated to you rather than silently fixed (dashboard hardening belongs with the deferred network-isolation half). Not fixed in-PR by design./protected/ws/updatesgated viaHTTPException(works today, unpinned). Fixed ine60f242— added a behavioral handshake test (with an accept-with-secret positive control)./internalcorrectly ungated, controller/managementcalls unaffected, no dead code.Security — 2 blocking, 1 advisory, all the same co-location root cause.
portal(default creds) viaSELECT value FROM kv_store. BLOCKING: secret readable from the Traefik dashboard (same as above). Advisory: readable from the dyn-config file via a path-traversal volume mount. Resolved: these are the co-location limitation — documented honestly and escalated as the network-isolation follow-up; not fixable inside this PR's scope (reviewer agreed: "cannot be fixed insidetraefik_secret.py… requires network segmentation"). Cleared as sound:compare_digestusage, 512-bit entropy, no spoof-through-Traefik, no leak viaauthResponseHeadersRegex ^X-Ptl-.*(that copies response headers on the app routers only;verify-traefikis a request header on theshard_core_*routers), no secret in logs, fail-closed persistence.Test adversary — 0 blocking, 2 advisory. Verified the committed negative/positive tests have real teeth by stripping the dependency and watching them fail.
e60f242). Advisory: 500-on-missing-secret branch untested → fixed (e60f242,test_missing_secret_fails_closed_with_500). Confirmed the conftest header injection masks nothing (routes had no gate before).Recommended reading order
shard_core/service/traefik_secret.py— the secret + verify dependency (new)shard_core/service/traefik_dynamic_config.py—verify-traefikmiddleware, wired onto the two routersshard_core/service/app_installation/util.py— fetch/ensure secret when compiling the configshard_core/web/protected/__init__.py,shard_core/web/management/__init__.py— the router gatetests/conftest.py— fixtures carry the secret header (simulate Traefik traversal)tests/test_traefik_secret.py,tests/test_traefik_dyn_spec.py— coverageagents.md— auth-model note incl. the scope limitation🤖 Generated with Claude Code