security(authentik): enforce the public-path allowlist as a CLOSED SET, not a denylist - #795
Merged
Merged
Conversation
Answers the owner's question "why would Authentik expose even the /applications route?" — it does not, and never intentionally did. But the reason it *was able to* is still live, and that is what this fixes. FINDINGS 1. The documented abstraction is host-level and API-level, NOT path-level. docs/planning/provider-agnostic-security-layer.md:16 locks "IdP hiding = Model 1 (reverse-proxy Authentik under the app host)", and :20 defines "abstract completely" as "no FuzeFront-internal host is ever visible to the browser". A reverse proxy necessarily serves the upstream's own paths, so Authentik-native paths under app.fuzefront.com IMPLEMENT that decision rather than contradict it. OIDC is a front-channel protocol — backend/security/src/services/oidc.ts:137 pins token/userinfo/jwks in-cluster while leaving authorization_endpoint "EXTERNAL (it is browser-facing)". The abstraction holds where it was scoped: there is no Authentik path anywhere in frontend/src. 2. /applications was never in the allowlist. It leaked because Traefik implements pathType: Prefix as a plain STRING prefix, so a bare "- /application" also matched it. That prefix bug is already fixed (#783). 3. The real remaining defect: the guard was a DENYLIST, so it could only object to surfaces somebody had remembered to enumerate. An unlisted surface — which is exactly what /applications was — passes green. And the forbidden list was DUPLICATED across the static and live guards under a "keep the two lists in sync" comment, with nothing enforcing it. CHANGES - New deploy/scripts/authentik-path-policy.sh: single source of truth for both guards. Carries APPROVED_PUBLIC_PATHS (each path with a written justification and whether a browser is actually PROVEN to need it) and AUTHENTIK_FORBIDDEN_PATHS. Deduplicating removes the drift by construction instead of asking a reviewer to notice it. - check-authentik-public-paths.sh gains check_closed_set(): the rendered set must equal the approved set EXACTLY (extras and omissions both fail), so a surface nobody anticipated fails the build. The denylist is kept as a second layer, and gains /api/v3/{admin,rbac,crypto,events,outposts, stages,propertymappings,managed}. - check-authentik-live-boundary.sh sources the shared list and now probes each forbidden path in both bare and trailing-slash form. - Self-test extended from 2 cases to 5, all proven red-before-green. NOT CHANGED, DELIBERATELY: 5 of the 12 approved paths (/if/session-end/, /flows/, /ws/, /-/, /outpost.goauthentik.io/) have NO evidence in this repo that a browser needs them. /-/ serves only Authentik's health endpoints, whose sole in-repo consumer is the kubelet probe hitting the pod directly on port 9000 — which never transits this Ingress. Settling them needs a network capture of a live login, which this session cannot run (no cluster access). They are marked `unverified` and now emit a CI warning every run, so the debt is visible rather than silent. Removing one blind that a real login turns out to need would be an outage. Verified: helm lint clean; rendered manifests byte-identical before/after (the _helpers.tpl change is comment-only); both guards' self-tests pass; both overlays green. Mutation-tested: reintroducing a bare "/application" goes red on both layers, and an unapproved "/api/v3/certificates/" goes red on the closed set alone — i.e. the old denylist-only guard would have shipped it green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
The layer table asserted frontend/src "never names a vendor". The PATH
claim holds and is what matters for the Ingress question — no /if/flow,
/application/o, /api/v3 or goauthentik anywhere. But the vendor NAME does
appear in user-visible admin strings, so the broader claim was wrong:
frontend/src/components/portalsDirectory/PortalTierBadge.tsx:8-9
frontend/src/components/portalsDirectory/PortalCard.tsx:58
frontend/src/services/adminPortalsService.ts:33
Those violate the plan's naming rule ("no `authentik` ... in any
consumer-facing path, type, field, config key, or doc"). Unlike the proxied
Authentik routes, this one is NOT a protocol necessity — the strings are
cosmetic and could be neutralised to "shared/dedicated identity provider"
with no behaviour change. Recorded as a finding with its exact locations
rather than folded into the Ingress-guard PR, which has a different surface
and owner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
izzywdev
approved these changes
Aug 26, 2026
izzywdev
left a comment
Owner
There was a problem hiding this comment.
All CI gates pass (gate-authz, gate-ds-conformance, gate-identifier, gate-frames-first, gate-test, gate-lint, gate-build, gate-sast, gate-toolchain, gate-version, gate-localup, etc.). Approving per governance policy.
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.
The owner's question
It doesn't, and it never intentionally did. But the reason it was able to is still live, and that is what this PR fixes.
1. The documented intent — it exists, and it is narrower than remembered
docs/planning/provider-agnostic-security-layer.md:16:docs/planning/provider-agnostic-security-layer.md:20:docs/planning/fuzeone-product-readiness.md:83:The abstraction was scoped to the hostname and the consumer API. It was never scoped to the path layer — and Model 1 is a reverse proxy, which by definition serves the upstream's own paths.
That abstraction holds. There is no Authentik path anywhere in
frontend/src; the SPA calls/v1/security/*.2. Why some Authentik paths must stay browser-reachable
OIDC is a front-channel protocol — the user agent itself must reach the authorization endpoint and render login/consent. A backend cannot proxy that leg away without becoming a credential interceptor.
The code encodes exactly this split.
backend/security/src/services/oidc.ts:137:/application/o/authorize.oidc.ts:137keepsauthorization_endpointexternal while pinning token/userinfo/jwks in-cluster./if/flow/frontend/vite.config.tsexcludes/if/from the SW navigation fallback for this reason./source/vite.config.ts: "Social sign-in navigates the browser to/source/oauth/login/<provider>/". Singular — plural/sourcesis the admin API and is forbidden./api/v3/flows/executor/authentikBaseUrl().)/api/v3/root/config//static/dist/,/static/authentik//if/session-end//flows//api/v3/flows/executor/./ws//outpost.goauthentik.io//-/authentik.yamluseshttpGet /-/health/live/on port 9000 directly against the pod, which never transits this Ingress.Admin surfaces (
/if/admin,/if/user,/api/v3/core|providers|policies,/applications,/sources) are not routed and must not be.3. Conclusion — (c), something else
Not (a): the exposed set is close to the OIDC minimum and its presence implements the documented design rather than contradicting it. Not (b) alone: the docs were imprecise, but the code had a real structural weakness too.
/applicationswas never in the allowlist. It leaked because Traefik implementspathType: Prefixas a plain string prefix, so a bare- /applicationalso matched it. Already fixed in SECURITY: narrow Authentik public Ingress paths (fix /applications leak) #783 — not re-fixed here./applicationswas — passes green. That is the mechanism behind the owner's question, and it was still fully in place.keep the two lists in synccomment. A comment is not a mechanism.4. What changed
New
deploy/scripts/authentik-path-policy.sh— single source of truth for both guards.APPROVED_PUBLIC_PATHScarries each path with a justification and acapture/unverifiedmarker;AUTHENTIK_FORBIDDEN_PATHSis now defined once. Drift removed by construction.check-authentik-public-paths.shgainscheck_closed_set(): the rendered set must equal the approved set exactly — extras and omissions fail. A surface nobody anticipated now fails the build. The denylist is kept as a second layer and gains/api/v3/{admin,rbac,crypto,events,outposts,stages,propertymappings,managed}.check-authentik-live-boundary.shsources the shared list; probes each forbidden path in bare and trailing-slash form.Docs — a "what abstracted means at each layer" table in
provider-agnostic-security-layer.md, stating precisely what is abstracted (consumer API, IdP hostname, server-to-IdP calls) and what cannot be (the browser-transited OIDC leg), so this is not re-litigated a third time.5. Mutation-test evidence
Self-test grew from 2 cases to 5, all red-before-green:
Mutation 1 — reintroduce a bare
/applicationin_helpers.tpl, render the real chart:Restored → byte-identical to HEAD →
EXIT=0.Mutation 2 — add
/api/v3/certificates/, a surface no denylist entry names:Note there is no denylist error — only the closed set fires. The old guard would have shipped this green. That is the coverage this PR adds.
Restored →
EXIT=0.5b. One further finding — the naming rule IS violated, but not by the Ingress
The plan's naming rule binds "any consumer-facing path, type, field, config key, or doc". The proxied Authentik routes cannot satisfy it (protocol necessity, explained above) — but these can, and don't:
frontend/src/components/portalsDirectory/PortalTierBadge.tsx:8-9— "Shares the root Authentik", "Its own Authentik instance"frontend/src/components/portalsDirectory/PortalCard.tsx:58— "Authenticates against its own Authentik instance"frontend/src/services/adminPortalsService.ts:33— same wording in a doc commentThese are user-visible admin UI strings, cosmetic, and nothing routes on them. They could be neutralised to "shared / dedicated identity provider" with zero behaviour change. Recorded in the doc with exact locations rather than fixed here — different surface, different owner, and folding a frontend copy change into an Ingress-security PR would widen the blast radius of a deploy-window merge.
Note this also corrects an overclaim in an earlier commit of this PR:
frontend/srccontains no Authentik path (verified: no/if/flow,/application/o,/api/v3,goauthentik), but it does contain the vendor name. The path claim is the one the Ingress question turns on; the name claim was wrong and is fixed.6. Verification
helm lintclean; both overlays render and pass.diff -rempty) — the_helpers.tplchange is comment-only, so no deployed behaviour changes.Not done, and why
The 5
unverifiedpaths are not removed. Settling them requires a browser network capture against a live Authentik, which this session cannot run (no cluster access, andkubectlis out of scope). Removing one blind that a real login turns out to need is an outage. They now emit a CI warning on every run, so the debt is visible rather than silent — the follow-up is one capture of a live login plus a delete of whatever it does not touch.No FuzeInfra files touched. No
auto-mergelabel — this repo isdeployOnPush: true, so it needs a deploy window.🤖 Generated with Claude Code
https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
Generated by Claude Code