Skip to content

fix(claim)!: derive resource ownership from capability, not network fingerprint - #295

Open
mastermanas805 wants to merge 1 commit into
masterfrom
fix/claim-ownership-capability-not-fingerprint
Open

fix(claim)!: derive resource ownership from capability, not network fingerprint#295
mastermanas805 wants to merge 1 commit into
masterfrom
fix/claim-ownership-capability-not-fingerprint

Conversation

@mastermanas805

Copy link
Copy Markdown
Member

The vulnerability

POST /claim transferred ownership of anonymous resources based on network fingerprint
SHA256(/24 subnet + ASN) — a bucket shared by everyone behind one NAT or CGNAT range. Two
strangers provisioning inside the same 24h TTL window landed in the same bucket, and whoever
claimed first inherited the other's live database credential.

Confirmed in production, not theorised: a test team ended up owning three Postgres databases it
never created, one of them created before its first API call existed.

Two layers, and fixing only one leaves the hole open:

  1. issueOnboardingJWT built the signed JWT's tok array from
    GetAllActiveResourcesByFingerprint, so the token handed to caller A already enumerated
    caller B's resources. The signature was no defence — the contents were assembled from the
    network.
  2. /claim then swept the fingerprint again and attached any unclaimed match not in the JWT.

The fix

Ownership derives only from a capability the caller holds: the signed onboarding token it was
handed at provision time. Both sweeps are gone.

Multi-service bundling is preserved by chaining. A caller re-presents its previous
upgrade_jwt on X-Instant-Upgrade-Token; the server verifies the signature and carries that
token list forward. Presenting the prior signed token proves the caller received it — a stranger
on the same /24 cannot produce one. Absent, malformed, expired or wrong-key degrades to this
request's own token and never fails the provision. There is no fingerprint fallback.
Chains capped at 25.

/claim/preview now lists exactly what /claim binds, pinned by a test comparing the previewed
set against the set actually written — a preview that over-promises is the same bug with a
friendlier face.

The fingerprint stays where it is legitimate: quota, dedup, rate limiting, the recycle gate
(provision_helper.go:312, unchanged). It just no longer confers ownership.
attachClaimedResourceToTeam is now the platform's only ownership-transfer write, with exactly
one caller.

Tests

claim_fingerprint_isolation_test.go asserts both layers — that caller B's signed JWT must not
even enumerate caller A's token, not merely that claim declines to bind it.

Two pre-existing tests asserted the removed behaviour and were inverted rather than deleted:
TestResidualClaimPreview_FingerprintResources..._IgnoresFingerprintResources, and
TestResidualClaim_HappyPath_ClaimsResources. Those tests had encoded the vulnerability as
intended behaviour.

Observability: instant_upgrade_token_chain_total{result=accepted|rejected|truncated} plus
provision.upgrade_chain.{rejected,truncated} log events. Prom rule + NR alert + dashboard tile
are infra follow-ups (rule 25).

On the local gate

Two local make gate runs went red and both were environment, verified individually:

  • TestDBNew_* / TestBulkTwin_* 503'd on a missing TEST_POSTGRES_CUSTOMERS_URL — the exact
    failure ci.yml:94-101 documents by name. All seven pass once it is set.
  • TestGitHubApp* / TestAdminList_AdminUserSees200 failed on a dirty instant_dev_test
    (duplicate key users_github_id_key), polluted by the previous run. All pass after
    make test-db-reset.

Every other package was ok throughout. CI is the authoritative gate and runs a clean
environment, so it is the judge here rather than a third local run on a machine already shown to
be flaky.

Found, not fixed — same mistake, different surface

The idempotency middleware scopes its cache by the same network fingerprint for anonymous callers
(internal/middleware/idempotency.go), so two identical anonymous POSTs from one /24 replay a
single response. Same trust-the-network error; deliberately out of scope for an already-large
security change.

Docs

content/llms.txt still describes a session as owning "every resource attached to your network
fingerprint"
— accurate today, and precisely this bug. It is corrected in
InstaNode-dev/content#38 once this merges, so behaviour and documentation change together.

🤖 Generated with Claude Code

…ingerprint

POST /claim transferred ownership of anonymous resources based on the caller's
NETWORK FINGERPRINT — SHA256(/24 subnet + ASN) — which is a bucket shared by
everyone behind one NAT or CGNAT range. Two strangers provisioning inside the
same 24h TTL window landed in the same bucket and whoever claimed first
inherited the other's live database credential. Confirmed in production: a
team ended up owning three Postgres databases it never created, one of them
created before its first API call existed.

It was two layers, and fixing only one leaves the hole open:

1. issueOnboardingJWT built the signed JWT's `tok` array from
   models.GetAllActiveResourcesByFingerprint, so the JWT handed to caller A
   already ENUMERATED caller B's resource tokens. The signature was no
   defence — the contents were assembled from the network.
2. Claim, after binding everything the JWT listed, swept the fingerprint AGAIN
   and attached any unclaimed match.

Both sweeps are gone. Ownership now derives only from a capability the caller
holds: the signed onboarding token it was handed at provision time.

The multi-service bundle is preserved by CHAINING. A caller re-presents its
previous `upgrade_jwt` on the new X-Instant-Upgrade-Token request header; the
server verifies the signature and carries that token list forward. Presenting
the prior signed token proves the caller received it; a stranger on the same
/24 cannot produce one. Absent/malformed/expired/wrong-key header degrades to
this request's own token and NEVER fails the provision — there is no
fingerprint fallback. Chains are capped at 25 tokens.

/claim/preview now lists exactly what /claim binds (same skips: unparseable
token, missing row, already-owned resource), pinned by a test that compares
the previewed set against the set the claim actually wrote.

The fingerprint stays where it is legitimate — quota, dedup, rate limiting,
the recycle gate (provision_helper.go:312, unchanged). It just no longer
confers ownership. attachClaimedResourceToTeam is now the platform's only
ownership-transfer write and has exactly one caller.

Tests: claim_fingerprint_isolation_test.go is written against the public
surface only and FAILS against the pre-fix tree on all five cases (verified by
reverting the production files and re-running) — Bob's JWT contained Alice's
token, Alice's claim bound Bob's resource into Alice's team, and the preview
promised both. upgrade_token_chain_test.go covers the chain end-to-end
including the five degradation modes; upgrade_chain_whitebox_test.go covers
the cap and the merge filters.

Two pre-existing tests asserted the removed behaviour and were inverted:
TestResidualClaimPreview_FingerprintResources (renamed
..._IgnoresFingerprintResources) and TestResidualClaim_HappyPath_ClaimsResources.

Observability: instant_upgrade_token_chain_total{result=accepted|rejected|
truncated} plus provision.upgrade_chain.{rejected,truncated} log events. The
Prom rule + NR alert + dashboard tile are infra-repo follow-ups (rule 25).

Not fixed here, reported separately: the idempotency middleware scopes its
cache by the same network fingerprint for anonymous callers
(internal/middleware/idempotency.go), so two identical anonymous POSTs from
one /24 replay one response — the same trust-the-network mistake on a
different surface.
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.

1 participant