fix(claim)!: derive resource ownership from capability, not network fingerprint - #295
Open
mastermanas805 wants to merge 1 commit into
Open
fix(claim)!: derive resource ownership from capability, not network fingerprint#295mastermanas805 wants to merge 1 commit into
mastermanas805 wants to merge 1 commit into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The vulnerability
POST /claimtransferred ownership of anonymous resources based on network fingerprint —SHA256(/24 subnet + ASN)— a bucket shared by everyone behind one NAT or CGNAT range. Twostrangers 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:
issueOnboardingJWTbuilt the signed JWT'stokarray fromGetAllActiveResourcesByFingerprint, so the token handed to caller A already enumeratedcaller B's resources. The signature was no defence — the contents were assembled from the
network.
/claimthen 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_jwtonX-Instant-Upgrade-Token; the server verifies the signature and carries thattoken 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/previewnow lists exactly what/claimbinds, pinned by a test comparing the previewedset 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.attachClaimedResourceToTeamis now the platform's only ownership-transfer write, with exactlyone caller.
Tests
claim_fingerprint_isolation_test.goasserts both layers — that caller B's signed JWT must noteven 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, andTestResidualClaim_HappyPath_ClaimsResources. Those tests had encoded the vulnerability asintended behaviour.
Observability:
instant_upgrade_token_chain_total{result=accepted|rejected|truncated}plusprovision.upgrade_chain.{rejected,truncated}log events. Prom rule + NR alert + dashboard tileare infra follow-ups (rule 25).
On the local gate
Two local
make gateruns went red and both were environment, verified individually:TestDBNew_*/TestBulkTwin_*503'd on a missingTEST_POSTGRES_CUSTOMERS_URL— the exactfailure
ci.yml:94-101documents by name. All seven pass once it is set.TestGitHubApp*/TestAdminList_AdminUserSees200failed on a dirtyinstant_dev_test(
duplicate key users_github_id_key), polluted by the previous run. All pass aftermake test-db-reset.Every other package was
okthroughout. CI is the authoritative gate and runs a cleanenvironment, 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 asingle response. Same trust-the-network error; deliberately out of scope for an already-large
security change.
Docs
content/llms.txtstill describes a session as owning "every resource attached to your networkfingerprint" — accurate today, and precisely this bug. It is corrected in
InstaNode-dev/content#38once this merges, so behaviour and documentation change together.🤖 Generated with Claude Code