# ============================================================================= # L0 STATIC — no two oauth2-proxy instances can share a browser cookie # Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster. # # THE DEFECT THIS CLOSES. Every oauth2-proxy on this platform is an INDEPENDENT instance with # its OWN `cookie-secret`: five per-app reverse-proxy sidecars (jackett, pihole, qbittorrent, # radarr, sonarr), crafty's, and the SHARED forward-auth proxy. Measured on prod 2026-09-30: # seven `*-oauth2-proxy` Secrets, SEVEN DISTINCT cookie-secret values — so no instance can # decrypt any other's cookie. # # They were all emitting oauth2-proxy's DEFAULT cookie names (`_oauth2_proxy` and # `_oauth2_proxy_csrf`), scoped to `.${BASE_DOMAIN}`. A cookie is keyed by (name, domain, path), # so the browser kept ONE pair for ALL of them. Whichever proxy authenticated last OVERWROTE # every other's session; the loser logged `No valid authentication in request`, bounced to # Keycloak, and — the SSO session still being live — was redirected straight back and overwrote # the cookie again. Six live instances, one cookie, mutual eviction. # # WHY IT IS THE WORST KIND OF BUG. The symptom is "I signed into Keycloak but I'm still not # signed in", which points the investigator at Keycloak, where nothing is wrong. It is # INTERMITTENT — it only appears once a browser has visited a SECOND gated app, so the obvious # single-app reproduction succeeds — and it is SILENT: every Deployment stays Ready, every # Kustomization stays Ready, the proxy's own log line names an authentication failure rather # than a collision. It went unnoticed across five services for the whole life of the pattern, # and was found only as a side effect of investigating something else (2026-09-29, crafty). # # WHY A COMMIT IS NOT ENOUGH, AND THIS IS A GATE STAGE. The collision is a property of the # PATTERN, not of any one app: the next forward-auth service onboarded inherits the defect by # doing nothing at all, because a missing flag IS the bug. Nothing about its absence is loud. # This is the same shape as the twelve inert ServiceMonitors in this tree — one omission, # copied, invisible for weeks — and the same remedy: make the omission fail the merge. # # GIVEN every oauth2-proxy instance this repo authors — a container in any manifest (the # rendered sidecars in apps/, crafty's hand-authored one in platform/) and the shared # proxy's HelmRelease values, # WHEN each instance's EFFECTIVE cookie name is resolved (its declared `--cookie-name` / # `OAUTH2_PROXY_COOKIE_NAME`, or oauth2-proxy's built-in default `_oauth2_proxy` when it # declares none — an omission is a NAME, not an absence, and that is the whole bug), # THEN (1) no two instances share an effective cookie name; # (2) no instance's effective name is another's name + `_csrf` — oauth2-proxy derives the # CSRF cookie that way, so `_oauth2_proxy` and `_oauth2_proxy_csrf` as two SESSION # names would collide on the second cookie while looking distinct on the first; # (3) the DEFAULT name has exactly ONE owner and it is the instance declared in # SHARED_DEFAULT_COOKIE_OWNER. This is the deliberate ASYMMETRY. The shared # forward-auth proxy MUST keep `_oauth2_proxy`: it is the single session every # OTHER forward-auth-gated surface on the platform rides on, so renaming it signs # every one of them out at once. The rule is therefore NOT "every instance must # declare a name" — it is "the default is a NAME LIKE ANY OTHER, held by exactly one # REGISTERED owner". That formulation needs no exemption from (1), and it closes the # hole an exemption would leave: if the shared proxy is ever removed, an app silently # inheriting the default would still FAIL here instead of quietly becoming its heir; # (4) every instance that is NOT that owner is HOST-SCOPED: its `--cookie-domain` equals # the host of its own `--redirect-url`. See the argument below; # (5) catalog/render.py DERIVES both flags from the ServiceDefinition (the service name # and the route host) and does not carry a per-service table. A list is how twelve # ServiceMonitors acquired one identical wrong port name in a single edit, and a list # leaves the NEXT app defaulting into the collision; # (6) the SHIPPED renderer is proven END-TO-END on synthetic ServiceDefinitions, run as a # subprocess: two forward-auth fixtures must yield two different, non-default cookie # names, each scoped to its own host. Without this, once the tree is compliant (1)-(4) # are satisfied by a tree with nothing left to catch, and a guard whose only evidence # is "nothing failed" cannot be told apart from one that stopped working; # AND (7) the check is LOAD-BEARING: this Test DECLARES `conformance.home.k3s/scope: # whole-repo`, so ci/l0.sh Stage 3b's `--include-scope whole-repo` unions it into # EVERY gate run, and ci/gate.sh blocks the merge when the l0 stage fails. It # deliberately adds NO stage to ci/l0.sh — tests/l0-static/whole-repo-invariants-wired # fails the gate if anyone adds one. # # WHY SCOPE, NOT REQUIREMENTS: ci/touched-requirements.sh maps `apps/*` to R-IDP-02/R-IDP-06 # today, but the instances live in THREE unrelated subtrees (apps/, platform/crafty/, # platform/oauth2-proxy/) and the file most likely to reintroduce the defect is # catalog/render.py, which maps to R-GITOPS-02 and to no identity requirement at all. A # requirement-scoped selection would filter this check out of exactly the change that breaks it. # # ON ASSERTION (4), HOST-SCOPED COOKIE-DOMAIN — the argument, and the decision. # AGAINST asserting it (overreach): distinct NAMES already close the eviction bug completely. # Two cookies with different names do not overwrite each other however wide their domains are, # so (4) fixes nothing that (1) has not already fixed. `--cookie-domain` is also a legitimate # knob — a future proxy fronting two hostnames of one app would need a wider value — and a # check that fails correct configurations is a check someone eventually disables. # FOR asserting it (and this wins): a zone-wide cookie is TRANSMITTED BY THE BROWSER TO EVERY # HOST IN THE ZONE. qBittorrent's session token is therefore sent to pihole, to radarr, to # sonarr, and to every other name under `.${BASE_DOMAIN}` — including hosts behind no proxy at # all. That is a session-credential disclosure across trust boundaries that no one needs and # nobody asked for; the sidecar's session is only ever meaningful to the one app it fronts. # And it is what makes a FUTURE name collision catastrophic rather than contained: with # host-scoped cookies, two instances that accidentally share a name cannot even see each # other's cookie, so the failure this whole test exists to prevent degrades from platform-wide # to nothing. Defence in depth on the exact axis that already failed once. # DECISION: assert it, for every instance except the registered shared proxy — whose whole # job IS cross-host forward-auth, and which is exempted by name and with a reason, not by # loosening the rule. `--whitelist-domain` is deliberately NOT touched: it governs permitted # post-login REDIRECT targets, not cookie scope, and narrowing it breaks the sign-in bounce. # # STATUS: EXECUTABLE. `check.sh` is an offline assertion script (PyYAML, as catalog/render.py # and the sibling l0-static checks use); conformance/run.sh --layer l0 and ci/l0.sh run it # directly, and this Chainsaw Test wraps the SAME script. # # NOT proven (honest gaps; this is STATIC, with no cluster and no browser): # - That anyone can actually sign in. No browser round-trip is performed, and none can be # from L0. The 2026-09-29 crafty entry recorded the same gap and it is still open: the # symptom has never been REPRODUCED in a browser, so this is a latent-bug fix reasoned from # measured configuration, not a fix watched to work. # - That the LIVE pods carry what the tree says. git is not the cluster (CLAUDE.md §2b). The # live flags were measured separately, once, by hand; nothing here re-measures them, and a # hand-applied drift would not be visible to this check. # - That the cookie-secrets really differ. That is a Secret comparison and belongs in-cluster; # it was measured out of band (seven distinct values, compared without printing any of # them). If they were ever made IDENTICAL the collision would become harmless — but sharing # one session-encryption key across trust zones is a worse idea than the bug it would mask, # so this check does not offer it as an alternative and does not look for it. # - Anything about the CONTENT of a session, group authorization, or the CSRF cookie's own # behaviour. Names and domains only. # - It does not read ../home-network-k3s. The spec is read-only (R-TEST-06). # ============================================================================= apiVersion: chainsaw.kyverno.io/v1alpha1 kind: Test metadata: name: oauth2-proxy-cookie-names-distinct labels: conformance.home.k3s/R-IDP-02: "true" conformance.home.k3s/R-IDP-06: "true" annotations: conformance.home.k3s/requirements: "R-IDP-02, R-IDP-06" conformance.home.k3s/layer: "L0" # WHOLE-REPO SCOPE — the instances span apps/wave-0, apps/wave-1, platform/crafty and # platform/oauth2-proxy, and the generator that emits five of them is catalog/render.py. # No single requirement path-maps to that set, and the renderer maps to no identity # requirement at all, so a requirement-scoped selection would skip this check on precisely # the change that can reintroduce the defect fleet-wide in one edit. conformance.home.k3s/scope: "whole-repo" conformance.home.k3s/safety-critical: "false" conformance.home.k3s/executable: "true" conformance.home.k3s/intent: >- No two oauth2-proxy instances can share a browser cookie: every instance's effective cookie name is unique, the built-in default belongs to exactly one registered owner (the shared forward-auth proxy, which cannot be renamed without signing every gated app out), every other instance is host-scoped, and catalog/render.py derives both flags from the ServiceDefinition rather than from a per-service list — proven end-to-end on synthetic fixtures so the guard stays falsifiable once the tree is clean. spec: steps: - name: assert-no-two-oauth2-proxy-instances-share-a-cookie description: >- Inventory every oauth2-proxy container and HelmRelease in the tree, resolve each one's effective cookie name (declared, or oauth2-proxy's default when omitted), assert pairwise distinctness including the session/CSRF derivation, ratchet the single registered owner of the default name, assert host-scoped cookie-domain on every other instance, assert catalog/render.py derives rather than lists, and prove the shipped renderer on two synthetic forward-auth ServiceDefinitions. try: - script: content: bash ./check.sh