Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .github/workflows/helm-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,21 @@ jobs:
# live, unnoticed, for an unknown period. helm lint and kubeconform both
# stayed green throughout.
#
# TWO layers, both from deploy/scripts/authentik-path-policy.sh (the
# single source of truth this and the post-deploy live probe share):
# 1. denylist — no routed path may string-prefix-match a forbidden
# Authentik surface under Traefik's matcher.
# 2. CLOSED SET — the routed set must equal the approved set EXACTLY.
# A denylist can only object to surfaces someone remembered to
# enumerate, which is precisely why /applications shipped green.
# Any new Authentik path now fails here until it is approved in
# the policy file with a justification.
#
# Self-test FIRST: prove the probe still fails on the exact regression
# (a bare "/application" that matches "/applications") before trusting it
# to pass on this chart's actual rendered output. A check only ever
# observed passing is not evidence of anything.
# (a bare "/application" that matches "/applications") and on an
# unapproved extra path, before trusting it to pass on this chart's
# actual rendered output. A check only ever observed passing is not
# evidence of anything.
set -euo pipefail
./deploy/scripts/check-authentik-public-paths.sh --self-test
./deploy/scripts/check-authentik-public-paths.sh rendered/fuzefront/templates
44 changes: 37 additions & 7 deletions deploy/helm/fuzefront/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,43 @@ authentik_providers_oauth2; there is no SAML provider configured, so
`/application/saml/` is not needed). If a SAML provider is ever added, add
`/application/saml/` explicitly then — do not widen back to a bare prefix.

`gate-authentik-public-paths` (helm-validate.yml, backed by
deploy/scripts/check-authentik-public-paths.sh) renders both overlays and
fails the build if any Authentik-backed path here is a STRING PREFIX (i.e.
would traefik-match) of a known-forbidden path — /applications, /if/admin,
/if/user, /api/v3/core, /api/v3/providers, /api/v3/policies, /sources. Keep
that forbidden list current if a new sensitive Authentik surface is ever
identified.
THIS LIST IS A CLOSED SET, AND IT IS ENFORCED. Every entry below must also
appear in APPROVED_PUBLIC_PATHS in deploy/scripts/authentik-path-policy.sh
with a written justification. Adding a path here without adding it there
FAILS THE BUILD, and vice versa. That is deliberate: this list used to be
guarded only by a denylist, which by construction can object only to
surfaces somebody remembered to enumerate — which is exactly how
/applications reached the public internet unlisted, unnoticed, and green.
Do not "fix" a closed-set failure by pasting the path into the policy file;
add the browser network capture that proves the OIDC flow needs it, or
remove it from here.

The policy file is the single source of truth for BOTH guards, which check
it from opposite ends:

- `gate-authentik-public-paths` (helm-validate.yml, backed by
deploy/scripts/check-authentik-public-paths.sh) renders both overlays
and fails if (1) any Authentik-backed path here is a STRING PREFIX (i.e.
would traefik-match) of a known-forbidden path, or (2) the rendered set
is not EXACTLY the approved set.
- `check-authentik-live-boundary.sh` (prod-post-deploy.yml) black-box
probes the real public edge, catching what a static check cannot: a
Traefik upgrade that changes matcher semantics, or an Ingress applied
outside this chart.

Both carry a --self-test that must go red on the known-broken input before
the real check is trusted.

NOTE on the entries below: 5 of the 12 — /if/session-end/, /flows/, /ws/,
/-/ and /outpost.goauthentik.io/ — have NO evidence in this repo
that a browser needs them — they were inherited from the pre-incident list.
They are marked `unverified` in the policy file, which prints a warning for
each on every CI run. /-/ in particular serves only Authentik's health
endpoints, whose sole in-repo consumer is the kubelet probe hitting the pod
directly on port 9000 (authentik.yaml) — which never transits this Ingress.
Settle each with a network capture of a live login and delete the ones that
turn out to be unnecessary; do not delete them blind, since a path a real
login needs is an outage.
*/}}
{{- define "fuzefront.authentikPublicPaths" -}}
- /application/o/
Expand Down
101 changes: 101 additions & 0 deletions deploy/scripts/authentik-path-policy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# authentik-path-policy.sh — THE single source of truth for which Authentik
# surfaces may face the public internet, and which must never.
#
# Sourced by BOTH guards, which check the same policy from opposite ends:
# - check-authentik-public-paths.sh (static, pre-merge: rendered chart)
# - check-authentik-live-boundary.sh (black-box, post-deploy: live edge)
#
# It exists because those two scripts previously carried their own copies of
# FORBIDDEN_PATHS under a "keep the two lists in sync" comment. A comment is
# not a mechanism: nothing failed if they drifted, and a surface added to one
# list but not the other would be guarded pre-merge and unguarded in prod (or
# the reverse) with both jobs green. Deduplicating the list removes the drift
# by construction instead of asking a reviewer to notice it.
#
# ── WHY ANY AUTHENTIK PATH IS PUBLIC AT ALL ───────────────────────────────
# FuzeFront abstracts the IdP vendor at the API layer: products and the SPA
# call the security service (/v1/security/*, @fuzefront/security-client) and
# never name Authentik. That abstraction is real and holds — there is no
# Authentik path anywhere in frontend/src.
#
# It cannot, however, extend to the browser-redirect leg of OIDC. OIDC is a
# front-channel protocol: the user agent itself must transit the authorization
# endpoint and render the login/consent UI. A backend cannot proxy that away
# without becoming a credential interceptor. So a SMALL, CLOSED set of
# Authentik-native paths is reverse-proxied under app.fuzefront.com (which is
# why the IdP HOST stays invisible — the vendor is hidden at the hostname
# layer even though the path layer necessarily leaks it).
#
# "Small and closed" is the whole security property, and it is what
# APPROVED_PUBLIC_PATHS below makes machine-checkable.

# ── Layer 1: the CLOSED SET ────────────────────────────────────────────────
# The complete set of paths that fuzefront.authentikPublicPaths (_helpers.tpl)
# is permitted to route to authentik-server. The static guard asserts the
# rendered chart matches this EXACTLY — no extras, and nothing missing.
#
# This is an ALLOWLIST, and that is deliberate. The guard used to be a
# denylist alone, which can only ever forbid a surface somebody remembered to
# enumerate. That is precisely how `/applications` reached the internet: no
# one had listed it, so no check could object. Under a closed set, ANY new
# Authentik path — enumerated or not, thought of or not — fails the build
# until a human adds it here with a justification. The denylist below is kept
# as a second layer, not the only one.
#
# Format: "<path>|<verified>|<justification>"
# verified = "capture" — confirmed required by an observed browser network
# capture or by code in this repo
# verified = "unverified" — inherited from the pre-incident list; no evidence
# in this repo demonstrates a browser needs it. NOT
# removed blind (removing a path a live login turns
# out to need is an outage), but reported by the
# guard so the debt is visible instead of silent.
# shellcheck disable=SC2034 # consumed by the scripts that source this file
APPROVED_PUBLIC_PATHS=(
"/application/o/|capture|OIDC authorize/token/userinfo/jwks/end-session. The browser MUST reach authorize: backend/security/src/services/oidc.ts pins token/userinfo/jwks to the in-cluster base but leaves authorization_endpoint EXTERNAL, 'it is browser-facing'. Narrowed from a bare /application, which string-prefix-matched /applications."
"/if/flow/|capture|Flow-executor UI. The authorize endpoint 302s the user agent here to render login/consent. frontend/vite.config.ts excludes /if/ from the service-worker navigation fallback for exactly this reason."
"/if/session-end/|unverified|RP-initiated-logout landing page. No code in this repo constructs or navigates to it; grep for session-end/end_session across backend/security/src, frontend/src and deploy/helm returns nothing."
"/source/|capture|Social (Google) sign-in. frontend/vite.config.ts: 'Social sign-in navigates the browser to /source/oauth/login/<provider>/; without these entries the SW served the cached SPA shell instead of letting the redirect reach Authentik'. Note the SINGULAR /source/ — /sources is the admin source-list API and is forbidden below."
"/api/v3/flows/executor/|capture|The flow-executor SPA calls this from the browser to advance stages. (The security service ALSO drives it, but server-side over the in-cluster base — see authentikBaseUrl() — so that consumer needs no public route.)"
"/api/v3/root/config/|capture|Anonymous-safe bootstrap config. Not observed in the 2026.5.5 capture (config arrives embedded in the initial HTML) but retained so a flow variant that bootstraps from it still renders a login page."
"/static/dist/|capture|Static JS/CSS assets for the flow-executor UI above."
"/static/authentik/|capture|Static branding/theme assets for the flow-executor UI above."
"/outpost.goauthentik.io/|unverified|Embedded-outpost / forward-auth endpoints. No forward-auth consumer exists in this chart; inherited from the pre-incident list."
"/flows/|unverified|No Authentik surface at this bare prefix is referenced anywhere in this repo. Inherited from the pre-incident list. Distinct from /api/v3/flows/executor/ above, which is the one the browser demonstrably uses."
"/ws/|unverified|Authentik websocket endpoint. No browser consumer in this repo; the flow executor is plain HTTP."
"/-/|unverified|Serves Authentik's health endpoints. Its ONLY demonstrated consumer in this repo is the kubelet probe — deploy/helm/fuzefront/templates/authentik.yaml uses httpGet /-/health/live/ and /-/health/ready/ on port 9000 DIRECTLY against the pod, which does not transit the Ingress at all. Nothing in this repo shows a browser needing it."
)

# ── Layer 2: the DENYLIST ──────────────────────────────────────────────────
# Surfaces that must never be reachable, checked independently of the closed
# set. Under Traefik's PathPrefix (a plain STRING prefix, NOT the Kubernetes
# spec's element-wise segment match) an approved path that is a string prefix
# of any entry here routes that entry to the internet.
#
# Kept even though the closed set now subsumes it: the closed set says "only
# these", this says "and specifically never those", and the live probe can
# only use this half (it cannot enumerate what an edge does NOT route).
# shellcheck disable=SC2034 # consumed by the scripts that source this file
AUTHENTIK_FORBIDDEN_PATHS=(
# The original incident: Authentik's application-list API.
"/applications"
# Admin + user-account UI.
"/if/admin"
"/if/user"
# Admin REST API namespaces.
"/api/v3/core"
"/api/v3/providers"
"/api/v3/policies"
"/api/v3/admin"
"/api/v3/rbac"
"/api/v3/crypto"
"/api/v3/events"
"/api/v3/outposts"
"/api/v3/stages"
"/api/v3/propertymappings"
"/api/v3/managed"
# Admin source-list API. Note this is the PLURAL form; the singular
# /source/ is approved above for the social-login redirect.
"/sources"
)
38 changes: 25 additions & 13 deletions deploy/scripts/check-authentik-live-boundary.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,31 @@ set -euo pipefail
APP_ORIGIN_DEFAULT="https://app.fuzefront.com"
AUTH_ORIGIN_DEFAULT="https://auth.fuzefront.com"

# Every path that must respond IDENTICALLY (status + content-type) to a
# guaranteed-unmatched, SAME-TIER control path — i.e. must NOT be specially
# routed to authentik-server. Mirrors FORBIDDEN_PATHS in
# check-authentik-public-paths.sh; keep the two lists in sync.
FORBIDDEN_PATHS=(
"/applications"
"/if/admin/"
"/if/user/"
"/api/v3/core/"
"/api/v3/providers/"
"/api/v3/policies/"
"/sources"
)
# The forbidden list comes from the SHARED policy file that the static
# pre-merge guard (check-authentik-public-paths.sh) also sources. The two
# scripts used to carry separate copies under a "keep the two lists in sync"
# comment — a comment is not a mechanism, and a surface added to one list but
# not the other would be guarded in exactly one of the two places with both
# jobs green. Deduplicating removes that drift by construction.
#
# The static guard also enforces a CLOSED SET (an allowlist) which this probe
# structurally cannot: a black-box prober can confirm a named path is not
# routed, but it cannot enumerate every path an edge does NOT route. So the
# two layers are complementary — the closed set catches unanticipated
# surfaces pre-merge, this catches controller/edge behaviour post-deploy.
# shellcheck source=deploy/scripts/authentik-path-policy.sh
. "$(dirname "${BASH_SOURCE[0]}")/authentik-path-policy.sh"

# Probe each forbidden surface in BOTH its bare and trailing-slash form. The
# canonical policy list is slash-free, but Authentik/Traefik can treat
# `/if/admin` and `/if/admin/` differently, and the original incident path was
# the bare `/applications`. A correctly-unrouted path falls through to the same
# tier control either way, so probing both only widens coverage.
FORBIDDEN_PATHS=()
for _p in "${AUTHENTIK_FORBIDDEN_PATHS[@]}"; do
FORBIDDEN_PATHS+=("$_p" "${_p}/")
done
unset _p

# fetch STATUS and CONTENT-TYPE for a URL. Loud on network failure (curl exit
# != 0) rather than treating it as a silent pass — a probe that can't reach
Expand Down
Loading
Loading