hub v0.153.0: no token sends, mints or reveals a household credential or link (operator ruling 13:20, 09 §3 decision 252)
gates / gates (push) Successful in 6m39s
gates / gates (push) Successful in 6m39s
- Session only: claim-resend, customer create, customer edit (changes the e-mail), create-config; pinned with selfbind-link, regen-password, appliance bind by TestToken_CredentialAndLinkRoutesAreSessionOnly (red-proved x2). - Customers write keeps block/unblock/geo. R-1055 closed; R-1059 filed (off-site re-issue, operator decision). Not deployed — waits for the operator's yes. Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
+43
-82
@@ -1,98 +1,59 @@
|
||||
# REPORT — hub access tokens (hub v0.152.0), 2026-10-15
|
||||
# REPORT — hub v0.153.0: no token sends, mints or reveals a household credential or link (2026-10-15)
|
||||
|
||||
The previous report (hub v0.152.0, access tokens, deploy and CC's switch to its token):
|
||||
`git show 5d78569b:REPORT-hub-tokens.md`.
|
||||
|
||||
## For the operator — plain words
|
||||
|
||||
**What a token can do.** A token lets a script use the hub without your password. You manage tokens on
|
||||
**Configuration → Access tokens**. You give a name, tick what it may do, and pick 30, 90, 180 or 365 days. The hub
|
||||
shows the token once. It keeps only a scrambled form. Every use is logged with the token's name.
|
||||
**What I did.** Your ruling (13:20, R-1055 = A) is built. No token can now:
|
||||
- resend a claim code,
|
||||
- create a customer,
|
||||
- edit a customer (the edit can change the household's e-mail),
|
||||
- create a config from a box's report.
|
||||
|
||||
**What no token can do.** Show a box's emergency root password. Show a household's passphrase or controller key.
|
||||
Delete or reset anything. Change the password. Create or revoke tokens. Reach the box API (`/api/v1`).
|
||||
These need you, signed in with the browser. Three were already password-only and stay so: the self-bind link, a new
|
||||
passphrase, binding an appliance. „Customers: write" keeps only block, unblock and geo disable.
|
||||
|
||||
**Is CC using its token now?** Yes. Hub 0.152.0 is live (your „yes", 2026-10-15). With the token „cc-releases":
|
||||
- a floor save with the same values (0.313.0, MinAgent 0.155.0) worked, and the hub database shows it;
|
||||
- the vouch was refused: „missing scope vouch:write";
|
||||
- the customer page and the emergency-password reveal were refused: „session only".
|
||||
All three boxes reported to the new hub after the restart.
|
||||
**Did it work?** Yes. A new test gives a token with every permission to all seven routes. Each answers 403. The
|
||||
household's e-mail and passphrase stay the same, and no customer is created. I broke the rule on purpose twice; the
|
||||
test failed both times. All hub tests pass.
|
||||
|
||||
**Is it live?** No. Hub 0.153.0 waits for your „yes".
|
||||
|
||||
**What you do now.**
|
||||
1. Change the hub password. CC no longer needs it.
|
||||
2. Delete the `HUB_PW` line in `~/.config/credentials` on DooPlex. CC does not delete it.
|
||||
3. Your token is wider than the plan: it can also change off-site windows, re-issue off-site keys, freeze off-site,
|
||||
and approve OS updates. CC uses only the floor. Re-create it narrower if you want; CC will not widen or narrow it.
|
||||
4. Decide R-1055: should „resend claim" need your password? My pick: yes.
|
||||
5. Proof-install clean-up (discarding an unclaimed appliance) is password-only now. CC will ask you to press Discard.
|
||||
1. Say „yes" to deploy hub 0.153.0.
|
||||
2. Decide R-1059: a token with „Off-site: write" (yours has it) can re-issue a household's off-site password. It never
|
||||
sees the password, but the backup can stop until the box picks the new one up. Keep it, or password-only?
|
||||
My pick: password-only.
|
||||
|
||||
**Side effect.** Each floor save (two today, both with the same value) mailed you „demo-hp's own floor 0.311.0
|
||||
blocks the global floor". That is the old item: clear demo-hp's own floor.
|
||||
## The Customers-write routes, checked one by one (`hub/internal/web/routes.go`)
|
||||
|
||||
**Was the hub ever open without a password?** The code allowed it. The live hub was never open (it asked for a login,
|
||||
and the manifest carries a password hash). Fixed: no password now means every operator page refuses.
|
||||
|
||||
## What was built
|
||||
|
||||
- Token store `api_tokens` (SHA-256 only), the guard in `RequireAuth`, the route table (`hub/internal/web/routes.go`),
|
||||
the Configuration card, the box API's refusal of tokens, the box API's route table (`hub/internal/api/routes.go`).
|
||||
- Design: `documentation/architecture/05-hub-architecture.md` §17 (read first, with §16). Also `04` §6.2, `13` §4/§5,
|
||||
`09` §3 decision 251.
|
||||
- Go gate in `hub/`: `go build ./... && go vet ./... && go test ./...` → rc 0.
|
||||
|
||||
## Red-proofs — each guard broken on purpose, its test seen failing, then restored
|
||||
|
||||
Evidence: `documentation/audits/hub-tokens-2026-10-15/red-proof/` (one file per case; each shows `=== RUN` for the test).
|
||||
|
||||
| # | guard broken | test | failed with |
|
||||
|---|---|---|---|
|
||||
| 1 | operator-action entry removed from the route table | `TestRouteTable_MatchesServeHTTPSwitch` | route 21: switch has `/hosts/…/operator-action`, table has `/request-logs` |
|
||||
| 2 | session-only allowed for tokens | `TestToken_SessionOnlyRoutesRefuseEveryToken` | `POST /` with an all-scope token: 200 |
|
||||
| 3 | expiry not checked | `TestToken_NoTokenUnknownExpiredRevoked401` | expired token: 200, want 401 |
|
||||
| 4 | group:read accepted on a write route | `TestToken_ScopeDecides` | floor:read token on floor save: 303; the floor changed |
|
||||
| 5 | plain token stored | `TestAPIToken_StoredRowHasNoPlainToken` | t.db holds the plain token |
|
||||
| 6 | old empty-password skip restored | `TestRequireAuth_NoPasswordRefusesEveryOperatorRoute` | no password: GET / → 200 |
|
||||
| 7 | Basic passes without the header | `TestToken_CSRFRuleUnchangedForBasic` | Basic without X-Felhom-Operator: 303 |
|
||||
| 8 | handler's own token check removed | `TestToken_CannotManageTokens` | handleTokenCreate under a token: 200; 2 rows |
|
||||
| 9 | `GET /customers` removed from the API table | `TestAPIRouteTable_MatchesSwitch` | route 36 mismatch |
|
||||
| 10 | `requireGlobalKey` returns true | `TestAPIRoutes_NoCredential` | GET /customers with no credential: 200 |
|
||||
| 11 | `fhp_` check removed from the API | `TestAPI_AccessTokenIsRefusedEverywhere` | POST /report with an access token: 401 (not the 403 refusal) |
|
||||
| 12 | main mounts `ServeHTTP` bare | `TestWebMountAlwaysAuthenticated` | 0 via Handler(), 1 other |
|
||||
|
||||
## Claims in the task that turned out wrong
|
||||
|
||||
- „Hungarian UI, same style as the page": the Configuration page and the whole hub UI are English (the design system:
|
||||
operator surfaces are English). The card is English, like the page.
|
||||
- „Mail hold — read: the held mails": the hub keeps no held mail; a hold drops it (`internal/mailhold`). Mail hold has
|
||||
only a write tick (release). Releases: vouch has only a write tick too — the Day-0 manifest has no page of its own.
|
||||
- „`dr-recipe.json` if it carries a secret": it carries none (`handleDRRecipeDownload`: „secret-free"); it is
|
||||
`customers:read`.
|
||||
- Not in the task, found: the customer page embeds the owner passphrase and the controller API key, and the config
|
||||
preview carries the controller key — both session only.
|
||||
|
||||
## Recommendations not followed (one line each)
|
||||
|
||||
- „Customers write: resend claim" — followed as written, but a stolen `customers:write` token can then redirect a
|
||||
household's claim code (edit e-mail, resend). Filed R-1055 for your decision; my pick is password-only.
|
||||
- „Off-site read: key audit" — followed; it is a POST that runs the check now and changes no key.
|
||||
|
||||
## Scripts and runbooks moved from the password to the token (Part C4 — done)
|
||||
|
||||
| where | what | scope it needs |
|
||||
| route | now | reason |
|
||||
|---|---|---|
|
||||
| `skills/felhom-build-deploy/SKILL.md` release step 5 | global floor save | `floor:write` — the CC token has it |
|
||||
| `skills/felhom-build-deploy/SKILL.md` ISO section | discard a proof install's appliance | **session only** — no token can; stays yours or stays the password until you place it |
|
||||
| `~/git/misc-scripts/gitea-image-prune.sh` | reads floor, golden, agent from the Configuration page | `floor:read` — the CC token has it |
|
||||
| `documentation/runbooks/RUNBOOK-manual-build.md` §prune | describes the script above | — |
|
||||
| `POST /configs/{id}/claim-resend` | session only | sends a fresh claim code to the household's e-mail |
|
||||
| `POST /configs/{id}/edit` | session only | can change the household's e-mail; a new e-mail auto-mails the self-bind link |
|
||||
| `POST /configs/new` | session only | mints the owner passphrase and controller API key; takes the e-mail; mails the self-bind link |
|
||||
| `POST /customers/{id}/create-config` | session only | mints the owner passphrase and controller API key |
|
||||
| `POST /customers/{id}/selfbind-link` | session only (since v0.152.0) | mints and mails a self-bind capability link |
|
||||
| `POST /configs/{id}/regen-password` | session only (since v0.152.0) | mints a new owner passphrase |
|
||||
| `POST /appliances/{id}/bind` | session only (since v0.152.0) | delivers the household's passphrase to the box that asks |
|
||||
| `POST /customers/{id}/block`, `/unblock`, `/geo/disable` | `customers:write` | no credential, link or e-mail |
|
||||
|
||||
Moved: the skill's floor step (the recipe was run verbatim: 303 `flash=floor_set`), `gitea-image-prune.sh` (misc-scripts;
|
||||
its test passes, a live dry-run read 6 in-use versions through the token, deleted 0), the runbook line. The appliance
|
||||
discard is password-only: the skill now says to ask you. Nothing CC runs reads `HUB_PW` any more.
|
||||
The GET forms (`/configs/new`, `/configs/{id}/edit`) stay `customers:read`: they show no passphrase or key (checked in
|
||||
the templates).
|
||||
|
||||
## Register
|
||||
## Proof
|
||||
|
||||
Before 155 · after 156 · opened 3 (R-1054 global API key's future, R-1055 claim-resend chain, R-1056 five routes
|
||||
with no group) · closed 2 (R-952, R-1051).
|
||||
- Test `TestToken_CredentialAndLinkRoutesAreSessionOnly` (`hub/internal/web/tokens_test.go`).
|
||||
- Red-proofs: claim-resend put back in the group → the handler was reached (502, no claim engine in the test);
|
||||
edit put back → 200. Files: `documentation/audits/hub-tokens-2026-10-15/red-proof/13-claim-resend.txt`, `14-config-edit.txt`.
|
||||
- `go build ./... && go vet ./... && go test ./...` in `hub/` → rc 0.
|
||||
|
||||
## Records
|
||||
|
||||
`09` §3 decision 252; `05` §17.2, §17.3, §17.5; hub CHANGELOG v0.153.0; STATUS.
|
||||
Register: before 157 · after 157 · opened 1 (R-1059, off-site re-issue) · closed 1 (R-1055).
|
||||
|
||||
## Teardown
|
||||
|
||||
Machine: nothing provisioned. Host: nothing. Hub: v0.152.0 deployed (manifest `2184897`); two floor saves with the
|
||||
unchanged value; no test token created by CC (the only token is the operator's). Evidence:
|
||||
`documentation/audits/hub-tokens-2026-10-15/` (`deploy.txt`, `C3-token-live.txt`, `red-proof/`).
|
||||
Nothing provisioned. No live change: the hub still runs 0.152.0.
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
## Waiting on you — do
|
||||
- **Change the hub password now — CC no longer needs it.** CC uses its token „cc-releases" (tested live: the floor works; the vouch and the password-only pages are refused). Then delete the `HUB_PW` line in `~/.config/credentials` on DooPlex (CC does not delete it). Your token is wider than planned (it can also change off-site windows and approve OS updates) — revoke and re-create it narrower if you want. If you do nothing: CC still holds a password that can do everything.
|
||||
- **Decide (R-1055):** a token with „Customers: write" can change a household's e-mail and resend its claim code to it. Keep that, or make „resend claim" password-only? **My pick: password-only** (one line). If you do nothing: it stays as you wrote it; nobody holds such a token now.
|
||||
- **Hub 0.153.0 is ready (your ruling 13:20): no token can resend a claim code, create or edit a customer, or make a passphrase or a link.** May I deploy it? Say „yes". If not: on the live hub a token with „Customers: write" can still do those four. **Decide (R-1059):** a token with „Off-site: write" (yours has it) can re-issue a household's off-site password — it never sees it, but the backup can stop until the box picks the new one up. Keep, or password-only? **My pick: password-only.** If you do nothing: it stays.
|
||||
- **Remove the test server „plex-k6” from your Plex account** (plex.tv → Settings → Authorized Devices). The claim test passed and the server is gone from Tester 1. If not: a dead server stays listed in your Plex apps.
|
||||
- System poster needs a refresh: every public name answers only over https (http → 301).
|
||||
- **Clear demo-hp's own floor** (hub → Demo HP → floor: empty → Save floor). It still reads 0.311.0. If not: the next global raise passes demo-hp by.
|
||||
|
||||
@@ -551,7 +551,7 @@ Configuration, which `floor:read` opens; the mail hold is a banner, and held mai
|
||||
| `overview` | dashboard, customer list (`/configs`), hosts list, apps, off-site page, system page | — |
|
||||
| `floor` | Configuration page, global-floor impact | global floor, per-customer floor |
|
||||
| `vouch` | — | `POST /configuration/artifacts` (golden, agent, MinAgent) |
|
||||
| `customers` | new/edit forms, DR recipe (secret-free) | create, edit, block/unblock, geo disable, create-config, claim resend |
|
||||
| `customers` | new/edit forms, DR recipe (secret-free) | block/unblock, geo disable (create, edit, create-config, claim resend: session only since v0.153.0, decision 252) |
|
||||
| `hosts` | host page, log bundles, log tails | operator actions, log-bundle and log-tail requests |
|
||||
| `offsite` | run the key check now (it reads, changes no key) | window grant, weekly windows on/off, off-site and PBS-DR re-issue, freeze |
|
||||
| `os` | the fleet queue (`/os/fleet`) | approve (guest, Docker, Proxmox, kernel), ring, on/off |
|
||||
@@ -602,13 +602,13 @@ method (405), so a token gets 403.
|
||||
| `/customers/{id}/unblock` | — | `customers:write` | |
|
||||
| `/customers/{id}/geo/disable` | — | `customers:write` | |
|
||||
| `/customers/{id}/floor` | — | `floor:write` | |
|
||||
| `/customers/{id}/create-config` | — | `customers:write` | |
|
||||
| `/customers/{id}/create-config` | — | **session only** | Mints the household's owner passphrase and controller API key (operator ruling 2026-10-15 13:20, `09` §3 252). |
|
||||
| `/customers/{id}/dr-recipe.json` | `customers:read` | — | |
|
||||
| `/customers/{id}/request-log-tail` | — | `hosts:write` | |
|
||||
| `/customers/{id}/log-tail/{x}` | `hosts:read` | — | |
|
||||
| `/customers/{id}` | **session only** | **session only** | The customer page embeds the owner passphrase and the controller API key (customer_unified.html). |
|
||||
| `/configs` | `overview:read` | — | |
|
||||
| `/configs/new` | `customers:read` | `customers:write` | |
|
||||
| `/configs/new` | `customers:read` | **session only** | Create mints the passphrase + API key, takes the household's e-mail and mails the self-bind link (ruling 13:20). |
|
||||
| `/configuration/global-floor/impact` | `floor:read` | — | |
|
||||
| `/configuration/global-floor` | — | `floor:write` | |
|
||||
| `/configuration/artifacts` | — | `vouch:write` | |
|
||||
@@ -617,7 +617,7 @@ method (405), so a token gets 403.
|
||||
| `/configuration/tokens` | — | **session only** | |
|
||||
| `/configuration/tokens/{id}/revoke` | — | **session only** | |
|
||||
| `/configs/{id}/delete` | **session only** | **session only** | |
|
||||
| `/configs/{id}/edit` | `customers:read` | `customers:write` | |
|
||||
| `/configs/{id}/edit` | `customers:read` | **session only** | Edit can change the household's e-mail — and a new e-mail auto-mails the self-bind link (ruling 13:20). |
|
||||
| `/offsite/remove-unpinned/{id}` | — | **session only** | |
|
||||
| `/offsite/abandon-cancel/{id}` | — | **session only** | Cancelling a pending set-aside deletion: no group names it; until the operator places it, session only. |
|
||||
| `/offsite/window-grant/{id}` | — | `offsite:write` | |
|
||||
@@ -625,7 +625,7 @@ method (405), so a token gets 403.
|
||||
| `/os/{id}` | `os:read` | `os:write` | |
|
||||
| `/offsite/key-audit` | — | `offsite:read` | Runs the daily key check now: it reads keys and reports, it changes none. |
|
||||
| `/configs/{id}/offsite-reissue` | — | `offsite:write` | |
|
||||
| `/configs/{id}/claim-resend` | — | `customers:write` | |
|
||||
| `/configs/{id}/claim-resend` | — | **session only** | Sends a fresh claim code to the household's e-mail (R-1055, operator ruling 2026-10-15 13:20). |
|
||||
| `/configs/{id}/pbsdr-reissue` | — | `offsite:write` | |
|
||||
| `/configs/{id}/offsite-freeze` | — | `offsite:write` | |
|
||||
| `/configs/{id}/preview` | **session only** | — | The preview is the generated controller config — it carries the controller API key. |
|
||||
@@ -633,7 +633,9 @@ method (405), so a token gets 403.
|
||||
| `/configs/{id}/reset` | **session only** | **session only** | |
|
||||
| `/configs/{id}` | `overview:read` | `overview:read` | |
|
||||
|
||||
**Session only, by reason:** a secret is shown or minted (reveal recovery credential; the customer page — it embeds the
|
||||
**Session only, by reason:** a household credential or link is sent, minted or its e-mail changed (claim resend,
|
||||
customer create and edit, create-config — `09` §3 decision 252, `TestToken_CredentialAndLinkRoutesAreSessionOnly`); a
|
||||
secret is shown or minted (reveal recovery credential; the customer page — it embeds the
|
||||
owner passphrase and the controller API key; the config preview — the generated controller config carries that key;
|
||||
regen-password; the self-bind link; an appliance bind — it delivers the passphrase to the box that asks); something is
|
||||
deleted or reset (customer delete and its preview, reset and its preview, host delete and its impact, off-site
|
||||
@@ -659,9 +661,13 @@ replace the global key yet (R-1054).
|
||||
vouched golden needs a declared MinAgent, R-472) — and read the overview pages. It cannot see any secret, delete or
|
||||
reset anything, vouch a golden or agent, release the mail hold, or touch a box's API. Revoke on the card; it stops at
|
||||
once (no cache).
|
||||
- **A `customers:write` token** can change a household's e-mail and then resend its claim code — the code goes to the
|
||||
new address. That is a takeover path for the household's dashboard password; it is in the group because the operator
|
||||
table put „resend claim" there. Decision open (R-1055).
|
||||
- **A `customers:write` token** could, in v0.152.0, change a household's e-mail and then resend its claim code there — a
|
||||
takeover path. Closed in v0.153.0 (operator ruling 2026-10-15 13:20, `09` §3 decision 252, R-1055): every route that
|
||||
sends, mints or reveals a household credential or link, or changes the e-mail, is session only. The group keeps block,
|
||||
unblock and geo disable.
|
||||
- **An `offsite:write` token** can re-issue a household's off-site or PBS-DR credential (a fresh one-time secret staged
|
||||
for the box). It never sees the secret; it can break a household's backup until the box picks the new one up. Open
|
||||
for the operator (R-1059).
|
||||
- **No token** can reach the break-glass password, the escrow, the off-site keys, or any destructive verb.
|
||||
|
||||
### 17.6 No operator password configured = closed
|
||||
|
||||
@@ -939,6 +939,12 @@ its length, and both fixes cost something the household would notice — operato
|
||||
reset, the password, the tokens. CC gets one token, `overview:read` + `floor:write` — not the vouch (decision 250
|
||||
item 3). **Until that token works live, CC may use the operator password ONLY to set the floor.** After it works,
|
||||
the operator changes the password so CC no longer holds it. Amends decision 250 item 2's *Route* sentence (R-1051).
|
||||
252. **No token sends, mints or reveals a household credential or link, or changes a household's e-mail.** *Operator
|
||||
ruling 2026-10-15 13:20 (R-1055 = A).* Session only (the operator signed in with the browser), never by any token:
|
||||
claim resend, customer create (`/configs/new`), customer edit (it changes the e-mail, and a new e-mail mails the
|
||||
self-bind link), create-config from a report (mints passphrase + API key) — plus the three already session only:
|
||||
the self-bind link, regen-password, appliance bind. „Customers: write" keeps block, unblock and geo disable. Pinned by
|
||||
`TestToken_CredentialAndLinkRoutesAreSessionOnly` (hub v0.153.0, `05` §17.3). Amends decision 251's groups.
|
||||
|
||||
### 2026-10-15 (07:48–08:20) — bake first; Plex through its own key; HSTS one day (recorded before the media build)
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ OS packages (exact versions, apt-signed), the installer script (by tag, R-980).
|
||||
| **added (R-1007):** controller → an app's own job queue | holds — nothing new | the `after_unclean_boot` commands run inside the app's container through the docker socket the controller already holds, and read the app's database with a read-only session: no key into the household's account, nothing a compromised controller could not already do (exec in any app, read its database). The household's lost-file names stay on the box (`app.yaml`, the app page); the operator event carries counts only, and a failing command's output is never logged (security review of controller `8d862e4`, fixed in `3b85a0c`) |
|
||||
| **added:** agent ↔ root on the host | holds by the sudoers | every root verb is one regex-anchored line or a root wrapper that re-checks its input (`03` §3); agent v0.156.1 adds one delete: `felhom-vzdump-partial-clean` removes only a DEAD half-written vzdump of the named guest on a `dir` storage (no vzdump on the host, nothing holding it open) — a compromised agent gains the deletion of a leftover that is not a backup |
|
||||
| agent ↔ hub | holds for destructive ops | signed jobs verified in the documented order; unsigned spec has no size bounds (R-984) |
|
||||
| **added 2026-10-15:** operator script ↔ hub UI | holds from hub v0.152.0 | scripts used the operator password, which can do everything; now a scoped, expiring, hashed token (`05` §17); a token never reaches a secret, a delete or `/api/v1`. Open: a `customers:write` token can redirect a claim code (R-1055); the global API key is still one all-powerful bearer (R-1054) |
|
||||
| **added 2026-10-15:** operator script ↔ hub UI | holds from hub v0.152.0 | scripts used the operator password, which can do everything; now a scoped, expiring, hashed token (`05` §17); a token never reaches a secret, a delete or `/api/v1`. No token sends, mints or reveals a household credential or link since v0.153.0 (`09` §3 decision 252, R-1055 closed); open: an `offsite:write` token can re-issue a household's off-site credential (R-1059); the global API key is still one all-powerful bearer (R-1054) |
|
||||
| controller ↔ hub | **did not hold until 2026-10-12** | the global key was public (R-950, rotated) and three routes needed no key (R-951, fixed); per-box keys are bound to their box (R-955) |
|
||||
| box ↔ PBS | holds as written | ciphertext only; the zero-knowledge wording is stronger than a live box allows (R-985) |
|
||||
| guest ↔ Proxmox host | holds | the guest has no Proxmox credentials (measured) |
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
=== RUN TestToken_CredentialAndLinkRoutesAreSessionOnly
|
||||
tokens_test.go:579: POST /configs/c1/claim-resend (sends a claim code to the household's e-mail) with an all-scope token: 502 "Claim engine is not configured on this hub\n", want 403 session only
|
||||
--- FAIL: TestToken_CredentialAndLinkRoutesAreSessionOnly (0.05s)
|
||||
FAIL
|
||||
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.072s
|
||||
FAIL
|
||||
@@ -0,0 +1,6 @@
|
||||
=== RUN TestToken_CredentialAndLinkRoutesAreSessionOnly
|
||||
tokens_test.go:579: POST /configs/c1/edit (changes the household's e-mail (a new e-mail mails the self-bind link)) with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Felhom Hub — Edit c1</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\"
|
||||
--- FAIL: TestToken_CredentialAndLinkRoutesAreSessionOnly (0.05s)
|
||||
FAIL
|
||||
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.078s
|
||||
FAIL
|
||||
@@ -36,6 +36,7 @@
|
||||
|
||||
| Row | What | Closed | Evidence |
|
||||
|---|---|---|---|
|
||||
| **R-1055** | **A stolen „Customers: write" token could take over a household's dashboard: change the e-mail, then resend the claim code.** (P3) | CLOSED 2026-10-15 — operator ruling 13:20 (A, `09` §3 decision 252); **built in hub v0.153.0 (not deployed — the operator's „yes" first)**: claim resend, customer create, customer edit and create-config are session only, with the self-bind link, regen-password and appliance bind already so. Full text: `git show 5d78569b:documentation/backlog/OPEN-ITEMS.md` | `TestToken_CredentialAndLinkRoutesAreSessionOnly` (red-proved twice: `audits/hub-tokens-2026-10-15/red-proof/13-claim-resend.txt`, `14-config-edit.txt`); `05` §17.3, §17.5 |
|
||||
| **R-1051** | **CC could not raise the global floor without the operator's own password.** (P3) | CLOSED 2026-10-15 — hub v0.152.0 deployed (operator „yes"); the operator created the token „cc-releases" (2026-10-15 13:06, wider than the task's two scopes: also customers/hosts read, offsite and os read+write — the operator's choice). Live with the token: a no-op floor save 0.313.0/MinAgent 0.155.0 → 303 `flash=floor_set`, read back from the hub DB with its WAL; the vouch → 403 `missing scope vouch:write`; the customer page and a recovery-credential reveal → 403 `session only`. The `felhom-build-deploy` skill's floor step and `gitea-image-prune.sh` now use the token. Full text: `git show e92501f5:documentation/backlog/OPEN-ITEMS.md` | `audits/hub-tokens-2026-10-15/C3-token-live.txt`, `deploy.txt`; `05` §17; `09` §3 decision 251 |
|
||||
| **R-952** | **Nothing failed when a new hub route was added without deciding who may call it.** (P3) | CLOSED 2026-10-15 — **built in hub v0.152.0**, both surfaces: `/api/v1` — `internal/api/routes.go` names each route's caller (public with a reason, customer, host, global, passphrase, appliance, recovery), pinned to the switch from source; every non-public route with no credential, for a household and host that exist, answers 401/403. Web — `internal/web/routes.go` classifies every route for access tokens, pinned the same way. Full text: `git show e92501f5:documentation/backlog/OPEN-ITEMS.md` | `TestAPIRouteTable_MatchesSwitch`, `TestAPIRoutes_NoCredential`, `TestRouteTable_MatchesServeHTTPSwitch` (each red-proved, `REPORT-hub-tokens.md`); `05` §17.3–17.4 |
|
||||
|
||||
|
||||
@@ -222,8 +222,8 @@ stopping line that lies.
|
||||
| **R-1040** | Security & access | P4 | **Until the household makes Sonarr's or Radarr's first admin, any container on the box can read the app's API key.** Measured on the bench 2026-10-14 with the R-1030 seed in place: `AuthenticationRequired=Enabled`, `AuthenticationMethod=None` — `/initialize.json` from another container answers 200 with `apiKey`. Outsiders are blocked by the box's setup gate in that window; a compromised other app is not. | **OPEN** — filed 2026-10-14 | — | Seed the API key at install (`SONARR__AUTH__APIKEY`, measured to work) so the box knows it, and close the window by telling the household in the first steps; or seed a Forms user | CC |
|
||||
| **R-1050** | Security & access | P4 | **Raise HSTS to one year on or after 2026-11-15, if no https trouble was reported.** `09` §3 decision 249 (D2 = A, operator 2026-10-15): HSTS stays `max-age=86400` now; after a month with no https trouble it becomes one year (`31536000`); never preload, never `includeSubDomains`. The value is `HSTSMaxAgeSeconds` in `felhom-controller/controller/internal/infra/infra.go`, pinned by `TestRenderForwardedHeaders_HTTPSOnly`. „Trouble” = any report of a name that will not open, or a box answering http after the floor. | **WATCHING** — filed 2026-10-15 | the date | On or after 2026-11-15: read the register and the operator's mail for https trouble since 2026-10-15; none → a controller release with one year (test updated), bake → vouch → floor | CC |
|
||||
| **R-1054** | Security & access | P3 | **The hub's operator/global API key is still one bearer that can do everything on `/api/v1`, including reading every box's break-glass root password (`GET /admin/hosts/{id}/recovery-credential`).** Hub v0.152.0 gave the web surface scoped tokens (`05` §17) but tokens are refused on `/api/v1` by design. Who uses the global key today is not listed in one place. Fix: list its consumers; move each operator use to a scoped token on the web side (or a token class on the API), and keep the global key only where a box path needs it — or retire it. | OPEN — filed 2026-10-15 by the token build | — | List the global key's consumers (pod env, scripts, runbooks) | CC |
|
||||
| **R-1055** | Security & access | P3 | **A stolen `customers:write` token can take over a household's dashboard: edit the household's e-mail, then „resend claim" — the claim code goes to the new address.** The operator table (`09` §3 decision 251) put „resend claim" in Customers write; the self-bind link, which mints a capability, was made session only. Nobody holds a `customers:write` token today. Fix (operator decision): keep it, or make `/configs/{id}/claim-resend` session only (one line in `hub/internal/web/routes.go`). | WAITING-ON-OPERATOR — filed 2026-10-15 by the token build | operator decision | Decide: claim resend stays in Customers write, or session only | operator |
|
||||
| **R-1057** | Security & access | P3 | **A restore of a REMOVED gated app comes back without its setup gate, even when the backup was taken before the household's setup — so the app returns open, with no account.** Measured 2026-10-15 on 9202 with Prowlarr (`audits/prowlarr-2026-10-15/box/run3/restore.txt`): installed (gated, no account made), the guarded Update's per-app backup, remove keeping the backups, the household's restore button — afterwards a stranger's `/ping` through traefik answered 200, where before the remove it answered 401 (the gate). This follows the documented rule (`felhom-controller` `internal/stacks/life_records.go` `carryLifeRecords`: no prior record = no gate, „the data comes back with its admin"); the rule's assumption fails for a backup made before the setup. For a first-visitor app (Prowlarr, n8n, …) the first stranger sets the sign-in; for Prowlarr `/initialize.json` also hands out the API key (seen before the setup in the install window — NOT measured after this restore). Needs: the household to install, let a backup run before finishing the setup, remove, restore. | **OPEN** — filed 2026-10-15 by the Prowlarr walk | — | Record the gate's state in the per-app backup and re-close it on restore when it was closed then; red-proof with this walk (`tools/walk_restore.py`) | CC |
|
||||
| **R-1059** | Security & access | P3 | **A token with „Off-site: write" can re-issue a household's off-site or PBS-DR credential** (`/configs/{id}/offsite-reissue`, `/pbsdr-reissue`: the box password is reset, a fresh one-time secret is staged for the box). The token holder never sees the secret, but can break the household's backup until the box picks the new one up. Decision 252 covered „Customers: write" only; the operator's own token „cc-releases" holds `offsite:write`. | WAITING-ON-OPERATOR — filed 2026-10-15 by the decision-252 build | operator decision | Decide: keep the two re-issues in Off-site write, or session only (two lines in `hub/internal/web/routes.go`) | operator |
|
||||
|
||||
## Box system & updates — 13 rows (P2 1, P3 9, P4 3)
|
||||
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
## v0.153.0 — no token sends, mints or reveals a household credential or link (R-1055) (2026-10-15)
|
||||
|
||||
Operator ruling 2026-10-15 13:20 (`09` §3 decision 252, R-1055 = A). `documentation/architecture/05-hub-architecture.md` §17.
|
||||
|
||||
- **Session only now** (were `customers:write` in v0.152.0), one reason each:
|
||||
- `POST /configs/{id}/claim-resend` — sends a fresh claim code to the household's e-mail.
|
||||
- `POST /configs/{id}/edit` — can change the household's e-mail, and a new e-mail auto-mails the self-bind link.
|
||||
- `POST /configs/new` — mints the owner passphrase and controller API key, takes the e-mail, mails the self-bind link.
|
||||
- `POST /customers/{id}/create-config` — mints the owner passphrase and controller API key.
|
||||
- Already session only since v0.152.0, now pinned by the same test: the self-bind link (mints and mails a capability),
|
||||
regen-password (mints a new owner passphrase), appliance bind (delivers the passphrase to the box that asks).
|
||||
- „Customers: write" keeps block, unblock and geo disable (none touches a credential, a link or the e-mail).
|
||||
- Test `TestToken_CredentialAndLinkRoutesAreSessionOnly`: a token with every scope gets 403 „session only" on all seven;
|
||||
the e-mail and the passphrase are unchanged and no customer is created; block still opens (positive control).
|
||||
Red-proved twice: claim-resend back in the group → 502 from the handler (reached); edit back → 200.
|
||||
- Off-site re-issue also mints a credential (for the box, never shown) — outside this ruling, filed R-1059.
|
||||
- **Not deployed** — the operator's „yes" first.
|
||||
|
||||
## v0.152.0 — operator access tokens with scopes; an empty password no longer opens the UI; R-952 (2026-10-15)
|
||||
|
||||
Operator ruling 2026-10-15 11:38 (`09` §3 decision 251). Design: `documentation/architecture/05-hub-architecture.md` §17.
|
||||
|
||||
@@ -41,7 +41,7 @@ var scopeGroups = []scopeGroup{
|
||||
{grpOverview, "Overview", "dashboard, customer list, hosts list, apps, off-site, system (no secrets)", "", true, false},
|
||||
{grpFloor, "Releases: floor", "the floors (Configuration page, floor impact)", "the global and the per-customer floor", true, true},
|
||||
{grpVouch, "Releases: vouch", "", "golden, agent, MinAgent (the vouch)", false, true},
|
||||
{grpCustomers, "Customers", "config forms, DR recipe", "create, edit, block/unblock, geo, resend claim", true, true},
|
||||
{grpCustomers, "Customers", "config forms, DR recipe", "block/unblock, geo disable", true, true},
|
||||
{grpHosts, "Hosts & logs", "host pages, log bundles, log tails", "operator actions, log requests", true, true},
|
||||
{grpOffsite, "Off-site", "run the key check", "windows, re-issue, freeze", true, true},
|
||||
{grpOS, "OS updates", "the fleet queue", "approve, ring, on/off", true, true},
|
||||
@@ -140,14 +140,16 @@ var operatorRoutes = []routeEntry{
|
||||
{Prefix: "/customers/", Suffix: "/unblock", Post: wr(grpCustomers)},
|
||||
{Prefix: "/customers/", Suffix: "/geo/disable", Post: wr(grpCustomers)},
|
||||
{Prefix: "/customers/", Suffix: "/floor", Post: wr(grpFloor)},
|
||||
{Prefix: "/customers/", Suffix: "/create-config", Post: wr(grpCustomers)},
|
||||
// Mints the household's owner passphrase and controller API key (operator ruling 2026-10-15 13:20, `09` §3 252).
|
||||
{Prefix: "/customers/", Suffix: "/create-config", Post: sessionOnly},
|
||||
{Prefix: "/customers/", Suffix: "/dr-recipe.json", Get: rd(grpCustomers)},
|
||||
{Prefix: "/customers/", Suffix: "/request-log-tail", Post: wr(grpHosts)},
|
||||
{Prefix: "/customers/", Contains: "/log-tail/", Get: rd(grpHosts)},
|
||||
// The customer page embeds the owner passphrase and the controller API key (customer_unified.html).
|
||||
{Prefix: "/customers/", Get: sessionOnly, Post: sessionOnly},
|
||||
{Exact: "/configs", Get: rd(grpOverview)},
|
||||
{Exact: "/configs/new", Get: rd(grpCustomers), Post: wr(grpCustomers)},
|
||||
// Create mints the passphrase + API key, takes the household's e-mail and mails the self-bind link (ruling 13:20).
|
||||
{Exact: "/configs/new", Get: rd(grpCustomers), Post: sessionOnly},
|
||||
{Exact: "/configuration/global-floor/impact", Get: rd(grpFloor)},
|
||||
{Exact: "/configuration/global-floor", Post: wr(grpFloor)},
|
||||
{Exact: "/configuration/artifacts", Post: wr(grpVouch)},
|
||||
@@ -156,7 +158,8 @@ var operatorRoutes = []routeEntry{
|
||||
{Exact: "/configuration/tokens", Post: sessionOnly},
|
||||
{Prefix: "/configuration/tokens/", Suffix: "/revoke", Post: sessionOnly},
|
||||
{Prefix: "/configs/", Suffix: "/delete", Get: sessionOnly, Post: sessionOnly},
|
||||
{Prefix: "/configs/", Suffix: "/edit", Get: rd(grpCustomers), Post: wr(grpCustomers)},
|
||||
// Edit can change the household's e-mail — and a new e-mail auto-mails the self-bind link (ruling 13:20).
|
||||
{Prefix: "/configs/", Suffix: "/edit", Get: rd(grpCustomers), Post: sessionOnly},
|
||||
{Prefix: "/offsite/remove-unpinned/", Post: sessionOnly},
|
||||
// Cancelling a pending set-aside deletion: no group names it; until the operator places it, session only.
|
||||
{Prefix: "/offsite/abandon-cancel/", Post: sessionOnly},
|
||||
@@ -166,7 +169,8 @@ var operatorRoutes = []routeEntry{
|
||||
// Runs the daily key check now: it reads keys and reports, it changes none.
|
||||
{Exact: "/offsite/key-audit", Post: rd(grpOffsite)},
|
||||
{Prefix: "/configs/", Suffix: "/offsite-reissue", Post: wr(grpOffsite)},
|
||||
{Prefix: "/configs/", Suffix: "/claim-resend", Post: wr(grpCustomers)},
|
||||
// Sends a fresh claim code to the household's e-mail (R-1055, operator ruling 2026-10-15 13:20).
|
||||
{Prefix: "/configs/", Suffix: "/claim-resend", Post: sessionOnly},
|
||||
{Prefix: "/configs/", Suffix: "/pbsdr-reissue", Post: wr(grpOffsite)},
|
||||
{Prefix: "/configs/", Suffix: "/offsite-freeze", Post: wr(grpOffsite)},
|
||||
// The preview is the generated controller config — it carries the controller API key.
|
||||
|
||||
@@ -550,3 +550,46 @@ func TestRequireAuth_NoPasswordRefusesEveryOperatorRoute(t *testing.T) {
|
||||
t.Error("the public /bind/ surface must not depend on the operator password")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToken_CredentialAndLinkRoutesAreSessionOnly — operator ruling 2026-10-15 13:20 (`09` §3 decision 252, R-1055):
|
||||
// every route that sends, mints or reveals a household credential or link, or changes a household's e-mail, is session
|
||||
// only. A token holding EVERY scope gets 403 on each, and nothing changes (the e-mail, the passphrase, no new config).
|
||||
// The positive control: block — still customers:write — is NOT refused.
|
||||
// RED-PROOF: put claim-resend back to wr(grpCustomers) → FAIL on /configs/c1/claim-resend.
|
||||
func TestToken_CredentialAndLinkRoutesAreSessionOnly(t *testing.T) {
|
||||
s, st, _, _ := tokenServer(t)
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", Email: "old@example.com",
|
||||
RetrievalPassword: "old-pass-phrase", APIKey: "ckey", ConfigJSON: "{}"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain := mintToken(t, st, "all", time.Now().Add(time.Hour), allScopes()...)
|
||||
routes := []struct{ path, why string }{
|
||||
{"/configs/c1/claim-resend", "sends a claim code to the household's e-mail"},
|
||||
{"/configs/c1/edit", "changes the household's e-mail (a new e-mail mails the self-bind link)"},
|
||||
{"/configs/new", "mints passphrase + API key, mails the self-bind link"},
|
||||
{"/customers/c3/create-config", "mints passphrase + API key"},
|
||||
{"/customers/c1/selfbind-link", "mints and mails the self-bind link"},
|
||||
{"/configs/c1/regen-password", "mints a new owner passphrase"},
|
||||
{"/appliances/1/bind", "delivers the passphrase to the box that asks"},
|
||||
}
|
||||
form := url.Values{"email": {"attacker@example.com"}, "customer_id": {"c2"}, "customer_name": {"x"}}
|
||||
for _, rt := range routes {
|
||||
w := serve(s, tokenReq(http.MethodPost, rt.path, plain, form))
|
||||
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "session only") {
|
||||
t.Errorf("POST %s (%s) with an all-scope token: %d %q, want 403 session only", rt.path, rt.why, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
cfg, _ := st.GetCustomerConfig("c1")
|
||||
if cfg == nil || cfg.Email != "old@example.com" || cfg.RetrievalPassword != "old-pass-phrase" {
|
||||
t.Errorf("a refused token changed c1: %+v", cfg)
|
||||
}
|
||||
for _, id := range []string{"c2", "c3"} {
|
||||
if c, _ := st.GetCustomerConfig(id); c != nil {
|
||||
t.Errorf("a refused token created customer %s", id)
|
||||
}
|
||||
}
|
||||
// Positive control: the group still opens block.
|
||||
if w := serve(s, tokenReq(http.MethodPost, "/customers/c1/block", plain, url.Values{})); w.Code == http.StatusForbidden {
|
||||
t.Errorf("customers:write must still open block: %d %q", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user