Skip to content

fix(api): verify hosted delegate identity to close owner-auth IDOR (#23)#30

Merged
harrymove-ctrl merged 1 commit into
mainfrom
fix/owner-auth-idor
Jun 20, 2026
Merged

fix(api): verify hosted delegate identity to close owner-auth IDOR (#23)#30
harrymove-ctrl merged 1 commit into
mainfrom
fix/owner-auth-idor

Conversation

@harrymove-ctrl

Copy link
Copy Markdown
Owner

Closes the client-reachable owner-authorization IDOR in the hosted Worker. Scoped to making authorization trustworthy — the larger #23 identity overhaul (verified sessions, per-plan quota, demo confinement) is called out as remaining work below.

The hole

Hosted owner identity was entirely self-asserted:

  • POST /api/namespaces/:ns/artifact-editupdateNamespaceArtifact has no dispatch auth gate; it authorized writes on a raw string match against a caller-supplied x-memwal-account-id / ?ownerId=. Anyone who knew a victim's owner id (derivable from a public Sui/MemWal account id) could overwrite their namespace artifacts.
  • listSchedules / listAlerts trusted ?ownerId= and defaulted to a shared "anonymous" bucket.

Root cause: the hosted Worker has no signup/session, so a caller could claim any owner id just by setting a header.

The fix (trust-on-first-use delegate binding)

  • resolveDelegateOwner() — binds each hosted owner to the first delegate secret seen (TOFU) in a new contextmem_hosted_delegates table, then verifies every later owner-scoped mutation against the salted sha256(ownerId:secret) hash (constant-time). A spoofed x-memwal-account-id without the bound secret is rejected. The raw secret is never stored.
  • resolveScopedOwner() — a trusted server-to-server caller (holds the import secret, i.e. the local Fastify proxy that already verified its own user) may delegate an explicit owner id; everyone else is scoped to their verified delegate. ?ownerId= is never trusted on its own.
  • updateNamespaceArtifact authorizes against the verified owner, not the self-asserted header.
  • listSchedules / listAlerts resolve via resolveScopedOwner and fail closed to an empty list instead of dumping the "anonymous" bucket.
  • migration 0008 adds the contextmem_hosted_delegates binding table.
  • .gitignore now ignores .dev.vars (Cloudflare Worker local secrets — HARBOR_*, MEMWAL_*, import token) so they can't be committed; this entry was missing on main.

Testing

  • New tests: cross-owner artifact-edit is denied (403) while the bound owner edits successfully; schedules scope to a delegated owner and fail closed to [] without one.
  • npx vitest run76/76 pass. tsc --noEmit clean for apps/api.

Out of scope (remaining #23, larger follow-up)

A verified ctx_-session resolveOwner over contextmem_sessions, associating delegate import with an account row, replacing the hardcoded quota.unlimited with a per-plan lookup, and confining demo owners — across all owner-scoped routes (/api/namespaces, /api/extractions, /api/usage). Those routes are currently import-token gated (trusted proxy), so they are not a client-reachable hole today.

TOFU caveat

Trust-on-first-use means if an attacker presents a victim's never-before-seen owner id before the victim ever does, the attacker's secret becomes the binding (a DoS / pre-registration hijack window). The full session-based identity above removes this; documented here as a known limitation of the scoped fix.

Hosted owner identity was fully self-asserted: the namespace artifact-edit
route (POST /api/namespaces/:ns/artifact-edit, no dispatch auth gate) granted
writes on a raw string match against a caller-supplied x-memwal-account-id /
?ownerId, and listSchedules/listAlerts defaulted to a shared "anonymous"
bucket. Anyone who knew a victim's owner id (derivable from a public
Sui/MemWal account id) could edit their namespace artifacts.

- Add resolveDelegateOwner(): the Worker has no signup/session, so bind each
  hosted owner to the first delegate secret seen (trust-on-first-use) and
  verify every later owner-scoped mutation against the salted hash. A spoofed
  account-id without the bound secret is rejected. Raw secret never stored.
- Add resolveScopedOwner(): a trusted server-to-server caller (holds the
  import secret — the local Fastify proxy that already verified its user) may
  delegate an explicit owner; everyone else is scoped to their verified
  delegate. ?ownerId is never trusted on its own.
- updateNamespaceArtifact: authorize against the verified owner, not the
  self-asserted header.
- listSchedules/listAlerts: resolve via resolveScopedOwner and fail closed to
  an empty list instead of dumping the "anonymous" bucket.
- migration 0008: contextmem_hosted_delegates binding table.
- .gitignore: ignore .dev.vars so Worker local secrets are never committed.
- tests: cross-owner artifact-edit is denied; the bound owner can edit;
  schedules scope to a delegated owner and fail closed without one.

Remaining #23 (separate, larger): a verified ctx_ session resolveOwner over
contextmem_sessions, delegate->account association, per-plan quota replacing
the hardcoded unlimited, and demo-owner confinement across all owner-scoped
routes.
@harrymove-ctrl harrymove-ctrl merged commit 00bc6bc into main Jun 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants