Skip to content

security(authentik): enforce the public-path allowlist as a CLOSED SET, not a denylist - #795

Merged
izzywdev merged 2 commits into
masterfrom
claude/authentik-public-path-closed-set
Aug 26, 2026
Merged

security(authentik): enforce the public-path allowlist as a CLOSED SET, not a denylist#795
izzywdev merged 2 commits into
masterfrom
claude/authentik-public-path-closed-set

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

The owner's question

"Why would Authentik expose even the /applications route? This seems like wrong documentation. We clearly documented that we want to abstract the IdP vendor behind FuzeFront's security service, for both authN and authZ."

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:

IdP hiding = Model 1 (reverse-proxy Authentik under the app host + adapter interface).

docs/planning/provider-agnostic-security-layer.md:20:

"Abstract completely" therefore means: no FuzeFront-internal host (auth.fuzefront.com) is ever visible to the browser or named in any consumer contract.

docs/planning/fuzeone-product-readiness.md:83:

@fuzefront/security-client v0.2.0 already exists and is exactly the right abstraction — products never name Authentik

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:

// Route the SERVER-SIDE OIDC calls (token / userinfo / jwks) over in-cluster
// DNS. The authorization_endpoint stays EXTERNAL (it is browser-facing).
Path Required publicly? Evidence
/application/o/ Yes Browser must reach authorize. oidc.ts:137 keeps authorization_endpoint external while pinning token/userinfo/jwks in-cluster.
/if/flow/ Yes Authorize 302s the user agent here. frontend/vite.config.ts excludes /if/ from the SW navigation fallback for this reason.
/source/ Yes vite.config.ts: "Social sign-in navigates the browser to /source/oauth/login/<provider>/". Singular — plural /sources is the admin API and is forbidden.
/api/v3/flows/executor/ Yes Flow-executor SPA advances stages from the browser. (The security service also drives it, but server-side in-cluster via authentikBaseUrl().)
/api/v3/root/config/ Yes (defensive) Anonymous-safe bootstrap; retained so a flow variant that bootstraps from it still renders.
/static/dist/, /static/authentik/ Yes Assets for the flow-executor UI.
/if/session-end/ Unproven No code constructs or navigates to it anywhere in the repo.
/flows/ Unproven No reference anywhere. Distinct from /api/v3/flows/executor/.
/ws/ Unproven No browser consumer; flow executor is plain HTTP.
/outpost.goauthentik.io/ Unproven No forward-auth consumer in this chart.
/-/ Unproven Serves health endpoints. Its only in-repo consumer is the kubelet probe — authentik.yaml uses httpGet /-/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.

  • /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. Already fixed in SECURITY: narrow Authentik public Ingress paths (fix /applications leak) #783 — not re-fixed here.
  • The real defect: the guard was a DENYLIST. It could only ever object to surfaces somebody remembered to enumerate. An unlisted surface — which is exactly what /applications was — passes green. That is the mechanism behind the owner's question, and it was still fully in place.
  • The two guards duplicated the forbidden list under a keep the two lists in sync comment. A comment is not a mechanism.
  • 5 of 12 approved paths have no evidence that a browser needs them.

4. What changed

New deploy/scripts/authentik-path-policy.sh — single source of truth for both guards. APPROVED_PUBLIC_PATHS carries each path with a justification and a capture/unverified marker; AUTHENTIK_FORBIDDEN_PATHS is now defined once. Drift removed by construction.

check-authentik-public-paths.sh gains check_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.sh sources 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:

Self-test OK: probe correctly failed on bare '/application' (matches '/applications').
Self-test OK: probe correctly passed on narrowed '/application/o/'.
Self-test OK: closed-set check correctly failed on an unapproved extra path.
Self-test OK: closed-set check correctly failed on a missing approved path.
Self-test OK: closed-set check correctly passed on exactly the approved set.

Mutation 1 — reintroduce a bare /application in _helpers.tpl, render the real chart:

::error::path '/application' string-prefix-matches forbidden path '/applications' ...
::error::path '/application' is routed to authentik-server but is NOT in APPROVED_PUBLIC_PATHS ...
::error::'/application/o/' is approved ... but the chart no longer routes it.
EXIT=1

Restored → byte-identical to HEAD → EXIT=0.

Mutation 2 — add /api/v3/certificates/, a surface no denylist entry names:

::error::path '/api/v3/certificates/' is routed to authentik-server but is NOT in APPROVED_PUBLIC_PATHS ...
EXIT=1

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 comment

These 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/src contains 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 lint clean; both overlays render and pass.
  • Rendered manifests byte-identical before/after (diff -r empty) — the _helpers.tpl change is comment-only, so no deployed behaviour changes.
  • Both guards' self-tests pass.

Not done, and why

The 5 unverified paths are not removed. Settling them requires a browser network capture against a live Authentik, which this session cannot run (no cluster access, and kubectl is 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-merge label — this repo is deployOnPush: true, so it needs a deploy window.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv


Generated by Claude Code

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
@github-actions
github-actions Bot requested a review from izzywdev as a code owner August 24, 2026 08:13
@github-actions github-actions Bot added the auto-merge Enable squash auto-merge once CI passes label Aug 24, 2026
@claude claude Bot changed the title security(authentik): enforce the public-path allowlist as a CLOSED SET security(authentik): enforce the public-path allowlist as a CLOSED SET, not a denylist Aug 24, 2026
@claude claude Bot removed the auto-merge Enable squash auto-merge once CI passes label Aug 24, 2026
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 izzywdev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@izzywdev
izzywdev merged commit 5bac2b9 into master Aug 26, 2026
59 checks passed
@izzywdev
izzywdev deleted the claude/authentik-public-path-closed-set branch August 26, 2026 05:05
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