# Brief: the Console checks that a signed-in request came through Traefik Vikunja #385, Gitea #1676. Owner Rocko or whichever seat is free, set at briefing. Gate owner Sage. Not on the window path (mos-0874). Built after the window rows 169, 175 and 176, as a partial fix with the limit recorded (mos-0878 Q2). Line numbers are at origin/refactor 835941b7c. Set at briefing (Sage, 2026-10-20): owner Rocko, reviewer Dewey. Base: the refactor head at briefing, 7f519242e or later. Rows 180 and 181 change `scripts/stack-release.sh`, and row 180 changes `scripts/webui-service.sh` too. Both are in this row's file list. Build on the current head. Before in-review, rebase onto every landing of 180 or 181 that came in after your base and rerun every touched test file (the row 68 drift rule). Sage lands 178 only after 180, and after 181 if 181 is already in review by then. ## The hole In preview mode, the Console treats a request as Jason's when the Host is the public name and the auth header (live: `X-authentik-username`) holds exactly the allowed user (`packages/webui/src/serve.mjs:453`, :466-469). Traefik and authentik set that header after sign-in and strip client copies. But the tunnel delivers every public request to 127.0.0.1:7332 (`R:0.0.0.0:7332:127.0.0.1:7332`), so the Console can't tell Traefik's requests from a local process's. Any process on dragon-lin that can open 127.0.0.1:7332 can send `Host: ` and `X-authentik-username: jason.woltje` and get Jason's sends, saves and settings writes. An agent can too, which gets around the human-shell guard (decisions 62 and 94). ## The design Traefik adds a proof header with a secret value to every request it forwards to the Console. The Console refuses a signed-in request without the right value. ### `packages/webui/src/serve.mjs` and `cli.mjs` - New serve flags `--proof-header NAME` and `--proof-file PATH`, given together and only with the preview flags. Either one alone refuses to start (exit 2). - The Console reads PATH once at start. It refuses to start (exit 2) unless the file is a regular file owned by the service's uid, has mode 0600 or 0400, and holds one line of at least 32 bytes from `[A-Za-z0-9_-]`. The value never appears on argv, in a log line, in an error message or in `/api/config`. - On a request with the public Host, the proof check runs before the auth header is read. It needs exactly one copy of NAME, and the value has to match by `crypto.timingSafeEqual` over equal-length buffers. Anything else answers 401 `proxy proof required`, with no bus call and no board call. A missing value and a wrong value get the same answer. - A request with NAME and any other Host refuses 403, as the auth header does at :456. - The tester listener from row 176 refuses NAME 403. - Without the flags, the startup line says `proxy proof off`, so the live unit's state is visible in its journal. ### `scripts/webui-service.sh` and `scripts/stack-release.sh` - Both flags join FLAGS, with the same checks. `render` refuses a PATH inside the repo or a copy, and a PATH it can't stat. It never reads the file. - `stack-release.sh` carries both flags through a re-pin and a rollback unchanged (`P_CONSOLE`). - Update `packages/webui/README.md`, `docs/TOOLS.md` and the unit template's header comment. ### What this stops and what it doesn't The proof stops any process that can't read the proof file: another uid on the host, and a hostNetwork pod on the k8s-woltje node (Cilium `AllowLocalhost=always`). It also stops a `run-task.sh` worker if its sandbox hides the host's `~/.config`. The build checks that and records it in the packet, without assuming it. It doesn't stop a process running as the Console's own uid. The Console runs as a `systemctl --user` unit under jwoltje, and every T3 seat runs as jwoltje too. A seat can read any file the Console can read, and `ptrace_scope` 1 doesn't stop that. Closing that part means the Console runs under a uid the seats don't have, which is a host and service change, so it's Jason's (Q173: infra). This row doesn't make that change. Agents acting as Jason only matter once the stack is in use, so uid separation is a question for Jason before he says "in use". It doesn't hold up the window. #385 stays open for his ruling (mos-0878 Q2). ### `console-allowed-uid` isn't a proof `~/.config/mosaic-dev/secrets/mosaic-stack/console-allowed-uid` (0600, 65 bytes, 2026-10-12, contents not read) is the Authentik uid for lead decision 101's switch from `--auth-header X-authentik-username --allow-user jason.woltje` to `--auth-header X-authentik-uid --allow-user UID`. No code in the repo reads it. It's an operator input for that reinstall (`scripts/webui-service.sh:145-146`, `docs/TOOLS.md:516`), and the live unit still signs in by username. It's an identity, not a secret the proxy proves it holds. Once installed it sits in ExecStart, which any local process can read. This row neither reuses it nor retires it, and the uid switch stays a separate operator step. ### Activation steps, not this row's 1. The secret. A slot file, `~/.config/mosaic-dev/secrets/mosaic-stack/console-proof.token`, mode 0600, 43 or more characters from `[A-Za-z0-9_-]` (for example 32 random bytes in base64url). Under Q173 an agent may create it, as long as the value is never printed, logged, put on argv or sent in a message (mos-0878 Q2). Create it with `umask 077` and write it in one step, then check only `stat -c '%a %U %s'`. 2. Traefik. A SealedSecret holding the same value, and a middleware on the Console's route in the mosaic-preview Argo app that sets the header (`headers.customRequestHeaders`, which replaces a client copy). That's ops-01's under R27. It narrows who gets in and opens nothing new from outside (mos-0878). Sealing the value means handing it to ops-01 without printing it. The activation plan names that path, and Mos checks it before anything runs. 3. The unit. After a store backup, add `--proof-header NAME --proof-file ` to the Console unit with `webui-service.sh install` and a restart, in a window Mos plan-checks. The order is middleware first, then the unit flags, then a signed-in check through the public name and a forged loopback request that has to get 401. Rollback drops the unit flags first, then the middleware. A STATE report follows. If the signed-in check through the public name fails, roll back at once, flags first and then the middleware, and record it in STATE. The turn-on doesn't wait for Jason (mos-0881). Outside traffic arrives only through Traefik with Authentik, and Traefik adds the proof, so whoever gets in from outside today still gets in. The callers it cuts off are local processes on dragon-lin and hostNetwork pods on k8s-woltje, which are inside callers. Step 2 is k3s under ops-01's R27 standing authority, as infrastructure bc310ee was. Mos confirms this again at the 178 plan check. #385's Traefik pod-label part is already live as infrastructure bc310ee (PR #352, Argo sync 2026-10-19T22:45:04Z, Traefik FORWARDED and falco DROPPED on Hubble, #385 comment 290). #385 stays open for this row and Jason's uid question. ## Tests 1. A named request with the right proof and auth header passes, and the reads and the send path still work as before. 2. A named request with no proof, a wrong proof, a proof one byte short, or a repeated proof header gets 401 with no bus call and no board call. Assert that the body never contains the value. 3. NAME with a loopback Host or any other Host refuses 403. NAME on the row 176 tester listener refuses 403. 4. Start-up refusals: one flag without the other; the flags without preview mode; a missing file, a directory, a symlink, mode 0644, another owner, a short value, a value with a newline in the middle or a bad character. Each one exits 2, and stderr never shows the value. 5. The value never shows in the request log, in `/api/config`, or in `ps` output for the serve process. The test reads `/proc//cmdline`. 6. `webui-service.sh render` puts both flags in ExecStart, refuses a PATH inside the repo, and doesn't open the file. `stack-release.sh` keeps both flags across a re-pin and a rollback. 7. A timing check isn't a gate test. The code uses `timingSafeEqual`, and a review grep confirms it. Mutants, each killed by some gate test (mos-0862): - the auth header is read before the proof check - a wrong proof with the right length passes - a missing proof passes when the flags are set - the proof is accepted with a loopback Host - mode 0644 accepted - the value is logged on refusal ## Gate The reviewer approves the candidate manifest. Webui and cli suites and every `scripts/test-*.sh` green on Sage's gate. Landing changes nothing live. The live unit gets the flags only through the activation steps above, after the middleware.