fix(daemon): fail closed when the auth hook is silent about tenant - #2104
Conversation
Size Report
npm unpacked components
Startup median (7 runs, lower is better):
Top changed chunks:
Top changed packed files
|
|
Not ready. The fail-closed posture is correct, but the claimed trust seam returns only a boolean, leaving both HTTP callers to promote |
6f4ea29 to
a51e934
Compare
|
Addressed:
|
|
a51e934 to
3b468c0
Compare
|
Comments removed:
No other new comments in the diff (double-checked with a diff-only scan of every touched file). Digests in `test/wire-compat/ledger.json` are unaffected since comment-only edits don't move them. Pushed at 3b468c0. Watching CI now. |
|
The trust seam and regressions are now sound: normalized trusted identity is returned centrally and raw client tenant metadata is overwritten/removed before handling. Still not ready. |
|
The comment-policy cleanup is complete at |
|
All checks now pass (the iOS smoke-test failure was the dropped-keystroke XCTest flake — confirmed by rerun with no code changes; |
3b468c0 to
d49e7e0
Compare
|
Rebased onto latest `main` (was 4454aef, now on 2e87347 — clean rebase, no conflicts, same 8-file diff). Also fixed the operator-facing contract I'd missed: `security-trust.md` and the CHANGELOG entry both said "a configured silent hook refuses the request," which overstates it. The actual posture is narrower — a request that also declares a client tenant is refused (401); a request that declares none proceeds unscoped, same as before. Reworded both to say that precisely:
Pushed at `d49e7e0609`. Watching CI now. |
|
Not ready at Derive the untrusted tenant claim from both RPC meta and |
d49e7e0 to
e4d2321
Compare
|
Confirmed and fixed — this was a real bypass. Root cause: the trust seam only ever inspected Fix, at
Added a planted red/green regression ( Aux routes (upload/download/diagnostics) don't carry a Pushed at |
|
Re-reviewed e4d2321. The confirmed |
e4d2321 to
e4d9a2f
Compare
|
Fixed — both docs now name `flags.tenant` alongside `meta.tenantId`:
Pushed at `e4d9a2fe5a`. Watching CI now. |
|
Re-reviewed |
|
Android Smoke rerun is green (98797116829, 6m51s). All checks pass at |
|
[P1] A configured-but-tenant-silent auth hook is still bypassable by omitting the tenant claim. resolveTrustedTenant returns trusted/unscoped for that shape (tenant-trust.ts:14-17). Auxiliary diagnostics then receives tenantId: undefined, and request-diagnostics-http.ts:119-129 skips ownership enforcement because it only checks a truthy tenant; a caller with the shared token can omit x-agent-device-tenant and request a known victim-owned session/request path. RPC has the analogous gap: an omitted request tenant leaves session access unscoped, and request-admission.ts:129-155 treats missing request lease fields as matching an owned session. The new tests prove declared-tenant rejection but do not plant omitted-tenant access to owned diagnostics/session red. Fail closed when a configured hook does not attest a tenant, or prove/enforce a public/unowned scope at every route, and add both end-to-end regressions. All checks being green does not make this head ready. |
An auth hook that ran but returned no tenantId opted the deployment into tenant attestation; falling back to the client's own claim (RPC body meta.tenantId, aux-route x-agent-device-tenant header) let a holder of one valid shared token impersonate any tenant on /rpc and on the diagnostics/ upload/download routes. resolveTrustedTenant() in the new src/daemon/server/tenant-trust.ts is now the single seam both surfaces go through and the only place that computes the resulting identity: hook attests -> use it; no hook configured -> keep today's client-declared behavior (loopback/dev unchanged); hook configured but silent with a client-declared tenant -> refuse (401) instead of trusting the claim, and no raw client-declared metadata survives into the dispatched request in that case either. Fixes #2095
e4d9a2f to
423cd18
Compare
|
Confirmed and fixed — you're right that "declares none proceeds unscoped" was itself the hole, exactly as described:
I took the "fail closed when a configured hook does not attest a tenant" option rather than auditing/hardening every downstream ownership check individually — it's the smaller, single-point fix that closes both named paths at the actual trust seam instead of chasing every place that conditions enforcement on a truthy tenant: `resolveTrustedTenant()` no longer has an "admit as unscoped" branch. The posture collapses to three cases: hook attests → use it; no hook → keep today's client-declared behavior (unchanged); hook configured and doesn't attest → refuse (401), full stop, independent of whatever the client does or doesn't declare. `clientDeclaredTenant` is now only consulted in the no-hook branch. Added two planted red/green regressions matching your exact examples:
One existing test (`RPC: a whitespace-only meta.tenantId is admitted untenanted...`) asserted the old "admit unscoped" outcome for a blank claim; updated to expect 401, since that outcome no longer exists under a silent hook. Full suite (14/14 in the tenant-trust file, all other daemon/wire-compat/provider-integration suites) green; `tsc`/`oxlint` clean. Updated CHANGELOG and security-trust.md to state the posture is now unconditional (hook must attest `tenantId` on every request it wants admitted), and updated the PR description to walk through the full review-driven design history for anyone picking this up cold. Pushed at `423cd18232`. Watching CI now. |
Summary
An auth hook that ran but returned no
tenantIdopted the deployment into tenant attestation. Both HTTP surfaces the daemon exposes tenant scoping on —/rpc(via bodymeta.tenantId/flags.tenant) and the auxiliary routes (/upload,/artifacts/*, request diagnostics, via thex-agent-device-tenantheader) — silently fell back to trusting the client's own claim, or admitted the request unscoped when the client declared nothing at all. A holder of one valid shared token could declare any tenant id and be treated as that tenant, or omit one entirely and read a tenant-owned session/artifact by naming it directly.resolveTrustedTenant()(src/daemon/server/tenant-trust.ts) is the single seam both surfaces go through before a request is admitted:meta.tenantId/flags.tenant, so neither can survive as a second route to identity.-32001), regardless of what the client does or doesn't declare. A hook that wants any request admitted must attesttenantIdon it.Per the issue's triage comment, no
trustClientDeclaredTenantescape hatch was added.Review history
This went through several rounds of maintainer review that materially changed the design from its first version:
meta.tenantId; a real bypass viaflags.tenant(independently read byscopeRequestSession/leaseScopeFromRequest) was found and closed — the flag is now derived and overwritten alongsidemeta.tenantId.request-diagnostics-http.ts's ownership check andrequest-admission.ts's lease-field matching only enforce tenant ownership when a tenant is present, so an unscoped caller could read a tenant-owned session/request by naming it directly and omitting any claim. The posture is now unconditional: a hook that doesn't attest means the request is refused, period — not just when the client also makes a claim.Test plan
src/daemon/__tests__/http-server-tenant-trust.test.ts: both surfaces (RPC command, RPC lease, aux request-diagnostics + upload routes) across every hook posture — attested, silent-with-a-claim (meta, flags, header), and silent-with-no-claim-at-all — plus regressions proving the no-hook path is unchanged.flags.tenantbypass and the omitted-tenant/unscoped-access bypass found in later review rounds.unit-core+fuzz-worker+provider-integrationsuites pass;tscandoxlintclean;test/wire-compatledger updated with acompatibleChangesentry (the changed function reuses the same 401{ok:false,error,code}shape already sent by this route's other rejection paths, so this is additive, not a protocol bump).Fixes #2095