From 326b206838c2d401e044b21411acce68a53b75b3 Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 13 Aug 2026 11:55:20 +0530 Subject: [PATCH] fix(claim)!: derive resource ownership from capability, not network fingerprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- e2e/loadtest_harness_test.go | 33 +- internal/handlers/cache.go | 4 +- .../claim_fingerprint_isolation_test.go | 464 ++++++++++++++++++ internal/handlers/db.go | 4 +- internal/handlers/nosql.go | 4 +- internal/handlers/onboarding.go | 95 ++-- internal/handlers/onboarding_residual_test.go | 47 +- internal/handlers/provision_helper.go | 150 ++++-- internal/handlers/queue.go | 4 +- internal/handlers/recycle_gate_test.go | 16 +- internal/handlers/storage.go | 4 +- .../handlers/upgrade_chain_whitebox_test.go | 209 ++++++++ internal/handlers/upgrade_token_chain_test.go | 277 +++++++++++ internal/handlers/vector.go | 4 +- internal/handlers/webhook.go | 4 +- internal/metrics/metrics.go | 21 + internal/metrics/metrics_test.go | 3 + internal/testhelpers/testhelpers.go | 6 +- 18 files changed, 1221 insertions(+), 128 deletions(-) create mode 100644 internal/handlers/claim_fingerprint_isolation_test.go create mode 100644 internal/handlers/upgrade_chain_whitebox_test.go create mode 100644 internal/handlers/upgrade_token_chain_test.go diff --git a/e2e/loadtest_harness_test.go b/e2e/loadtest_harness_test.go index 1f718961..ec58c0e3 100644 --- a/e2e/loadtest_harness_test.go +++ b/e2e/loadtest_harness_test.go @@ -581,16 +581,24 @@ func extractJWTLoose(note string) string { // teardown mechanism and the ultimate backstop. // // To NOT rely solely on TTL, the harness actively tears Lane-B resources -// down: every anonymous provision response carries a fingerprint-scoped -// onboarding JWT in `note`. POST /claim with that JWT moves EVERY active -// resource for that fingerprint into a fresh throwaway team in one call; -// each is then deletable via the authenticated DELETE. Because a Lane-B -// burst uses a single fingerprint, one claim + a delete-sweep reclaims the -// whole burst. If E2E_JWT_SECRET is unset (cannot mint the session JWT to -// authorize the DELETEs) the harness falls back to the 24h TTL and says so. +// down: every anonymous provision response carries an onboarding JWT in +// `note`. POST /claim with that JWT moves the resources THAT JWT NAMES into a +// fresh throwaway team; each is then deletable via the authenticated DELETE. +// If E2E_JWT_SECRET is unset (cannot mint the session JWT to authorize the +// DELETEs) the harness falls back to the 24h TTL and says so. // -// teardownAnonymousFingerprint claims every resource on `fpJWT`'s fingerprint -// and deletes them. Returns (claimed, deleted, ok). +// PARTIAL SINCE 2026-08-13: this used to reclaim the WHOLE burst from one JWT, +// because /claim swept every resource sharing the caller's fingerprint. That +// sweep was a cross-tenant ownership hole (SHA256(/24 + ASN) buckets strangers +// behind one NAT together) and was removed — see +// api/internal/handlers/onboarding.go. A single captured JWT now reclaims only +// its own token (plus anything chained onto it via X-Instant-Upgrade-Token, +// which this concurrent burst does not thread). The remainder of the burst +// falls back to the 24h TTL, which the caller already logs. Reclaiming the +// full burst again would mean chaining the header through the goroutines. +// +// teardownAnonymousFingerprint claims the resources named by `fpJWT` and +// deletes them. Returns (claimed, deleted, ok). func teardownAnonymousFingerprint(t *testing.T, fpJWT string) (claimed, deleted int, ok bool) { t.Helper() if fpJWT == "" { @@ -797,12 +805,13 @@ func TestLoad_FingerprintDedup_UnderBurst(t *testing.T) { stats := newLoadStats() // fpJWT captures one onboarding JWT from a 201 response so the test can - // claim+delete every anonymous resource it created on this fingerprint - // (rather than relying purely on the 24h TTL). + // claim+delete the resource that JWT names (rather than relying purely on + // the 24h TTL). See teardownAnonymousFingerprint for why this is partial + // coverage of the burst since the claim-by-fingerprint fix. var fpJWT string var fpJWTMu sync.Mutex - // Cleanup: claim this fingerprint's burst into a throwaway team & delete. + // Cleanup: claim what the captured JWT names into a throwaway team & delete. t.Cleanup(func() { fpJWTMu.Lock() jwtTok := fpJWT diff --git a/internal/handlers/cache.go b/internal/handlers/cache.go index 3a403763..553327ae 100644 --- a/internal/handlers/cache.go +++ b/internal/handlers/cache.go @@ -139,7 +139,7 @@ func (h *CacheHandler) NewCache(c *fiber.Ctx) error { return h.denyProvisionOverCap(c, fp, "redis") } if err == nil { - jwtToken, jti, jwtErr := h.issueOnboardingJWT(ctx, fp, country, vendor, "redis", []string{existing.Token.String()}) + jwtToken, jti, jwtErr := h.issueOnboardingJWT(c, fp, country, vendor, "redis", []string{existing.Token.String()}) if jwtErr == nil && jti != "" { if evErr := h.createOnboardingEvent(ctx, fp, jti, existing.Token); evErr != nil { slog.Error("cache.new.onboarding_event_failed_limit_path", "error", evErr, "request_id", requestID) @@ -239,7 +239,7 @@ func (h *CacheHandler) NewCache(c *fiber.Ctx) error { return respondProvisionFailed(c, finErr, "Failed to persist Redis resource") } - jwtToken, jti, jwtErr := h.issueOnboardingJWT(ctx, fp, country, vendor, "redis", []string{tokenStr}) + jwtToken, jti, jwtErr := h.issueOnboardingJWT(c, fp, country, vendor, "redis", []string{tokenStr}) if jwtErr != nil { slog.Error("cache.new.jwt_issue_failed", "error", jwtErr, "request_id", requestID) } diff --git a/internal/handlers/claim_fingerprint_isolation_test.go b/internal/handlers/claim_fingerprint_isolation_test.go new file mode 100644 index 00000000..12eccb19 --- /dev/null +++ b/internal/handlers/claim_fingerprint_isolation_test.go @@ -0,0 +1,464 @@ +package handlers_test + +// claim_fingerprint_isolation_test.go — regression tests for the +// claim-by-fingerprint ownership transfer (P0, confirmed live in production +// 2026-08-13). +// +// The bug, in two layers: +// +// Layer 1 — provision_helper.issueOnboardingJWT built the signed JWT's `tok` +// array by sweeping models.GetAllActiveResourcesByFingerprint(fp). A +// fingerprint is SHA256(/24 subnet + ASN), so every caller behind one +// NAT/CGNAT range shares a bucket. The JWT handed to caller A therefore +// ENUMERATED caller B's live resource tokens. The signature was no defence: +// the contents were assembled from the network. +// +// Layer 2 — onboarding.Claim, after binding everything the JWT listed, swept +// the fingerprint AGAIN and attached any unclaimed match. So even a JWT that +// listed nothing of B's would still hand B's resources to A. +// +// Confirmed in prod: a test team ended up owning three Postgres databases it +// never created, one created before its first API call existed. +// +// The fix: ownership derives only from a capability the caller HOLDS — the +// signed onboarding token it was handed at provision time — never from its +// network address. Multi-service bundling is preserved by chaining a prior +// signed token on X-Instant-Upgrade-Token (see upgrade_token_chain_test.go). +// +// EVERY test in this file is written against the PUBLIC surface only (no +// post-fix symbols), so it compiles — and fails — against the pre-fix tree. +// That is deliberate: it is the proof-of-vulnerability suite. + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sort" + "strings" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/crypto" + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// natCaller is one of two independent agents that happen to share a public +// egress IP — the everyday NAT / CGNAT / office-wifi / CI-runner case that +// makes the fingerprint a shared bucket rather than an identity. +type natCaller struct { + Token string + JWT string +} + +// provisionBehindNAT POSTs /cache/new with the shared X-Forwarded-For and +// returns the caller's own token plus the onboarding JWT it was handed. +// Optional headers let a test chain a prior upgrade token. +// +// `label` makes the request body unique per caller. That is NOT cosmetic: the +// idempotency middleware's body-fingerprint fallback is scoped by the same +// network fingerprint (internal/middleware/idempotency.go — scope = +// GetFingerprint(c) for anonymous callers), so two byte-identical anonymous +// POSTs from one /24 inside 120s replay ONE response — i.e. one caller +// receives the other's credentials. That is a separate finding, reported but +// deliberately not fixed here; these tests route around it so they exercise +// the claim path rather than the replay cache. +func provisionBehindNAT(t *testing.T, app *fiber.App, sharedIP, label string, headers map[string]string) natCaller { + t.Helper() + reqBody := fmt.Sprintf(`{"name":%q}`, label+"-"+uuid.NewString()[:8]) + req := httptest.NewRequest(http.MethodPost, "/cache/new", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", sharedIP) + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusCreated, resp.StatusCode, + "provisioning behind the shared NAT must succeed — the wedge is non-negotiable") + + var body struct { + Token string `json:"token"` + UpgradeJWT string `json:"upgrade_jwt"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.NotEmpty(t, body.Token) + require.NotEmpty(t, body.UpgradeJWT, "anonymous provision must hand back an upgrade_jwt") + return natCaller{Token: body.Token, JWT: body.UpgradeJWT} +} + +// jwtTokens returns the `tok` array carried by a signed onboarding JWT. +func jwtTokens(t *testing.T, signed string) []string { + t.Helper() + claims, err := crypto.VerifyOnboardingJWT([]byte(testhelpers.TestJWTSecret), signed) + require.NoError(t, err, "server-issued onboarding JWT must verify") + return claims.Tokens +} + +// claimAs runs POST /claim with a fresh email and returns the HTTP status. +func claimAs(t *testing.T, app *fiber.App, onboardingJWT string) int { + t.Helper() + resp := testhelpers.PostJSON(t, app, "/claim", map[string]any{ + "token": onboardingJWT, + "email": testhelpers.UniqueEmail(t), + "team_name": "team-" + uuid.NewString()[:8], + }) + defer func() { _ = resp.Body.Close() }() + return resp.StatusCode +} + +// ownerTeamID returns resources.team_id for a token, or "" when still unowned. +func ownerTeamID(t *testing.T, db *sql.DB, token string) string { + t.Helper() + var teamID sql.NullString + err := db.QueryRow(`SELECT team_id::text FROM resources WHERE token = $1`, token).Scan(&teamID) + require.NoError(t, err, "resource row must exist for token %s", token) + if !teamID.Valid { + return "" + } + return teamID.String +} + +// dropResources removes the rows a test created, plus any team that ended up +// owning them, so a shared test DB does not accumulate state. +func dropResources(db *sql.DB, tokens ...string) { + for _, tok := range tokens { + _, _ = db.Exec(`DELETE FROM teams WHERE id = (SELECT team_id FROM resources WHERE token = $1)`, tok) + _, _ = db.Exec(`DELETE FROM resources WHERE token = $1`, tok) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// THE regression test. Two DIFFERENT callers, one fingerprint. +// ───────────────────────────────────────────────────────────────────────────── + +// TestClaim_TwoCallersOneFingerprint_ClaimBindsOnlyOwnResources is the direct +// regression test for the live P0. Two unrelated agents provision from the same +// /24 inside one TTL window; the first to claim must NOT inherit the other's +// live database credential. +// +// It asserts BOTH layers, because fixing only one leaves the hole open: +// +// Layer 1 — the signed JWT handed to caller B must not even ENUMERATE caller +// A's token. (Pre-fix: B's `tok` array contains A's token.) +// Layer 2 — claiming with caller A's JWT must leave caller B's resource +// unowned. (Pre-fix: the claim-time sweep attaches it to A's team.) +func TestClaim_TwoCallersOneFingerprint_ClaimBindsOnlyOwnResources(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + // One public egress IP, two strangers behind it. + sharedIP := testhelpers.FingerprintToIP(testhelpers.UniqueFingerprint(t)) + + alice := provisionBehindNAT(t, app, sharedIP, "alice", nil) + bob := provisionBehindNAT(t, app, sharedIP, "bob", nil) + defer dropResources(db, alice.Token, bob.Token) + + require.NotEqual(t, alice.Token, bob.Token, + "sanity: two provisions behind one NAT must yield two distinct resources") + + // ── Layer 1: the signed JWT must not enumerate the other caller ────────── + assert.Equal(t, []string{bob.Token}, jwtTokens(t, bob.JWT), + "LAYER 1 REGRESSION: Bob's onboarding JWT must list only Bob's own token. "+ + "Building `tok` from GetAllActiveResourcesByFingerprint leaks Alice's "+ + "resource token to Bob inside a signed credential.") + assert.Equal(t, []string{alice.Token}, jwtTokens(t, alice.JWT), + "LAYER 1: Alice's onboarding JWT must list only Alice's own token") + + // ── Layer 2: claiming must bind only what the JWT lists ────────────────── + require.Equal(t, http.StatusCreated, claimAs(t, app, alice.JWT), + "Alice's claim must succeed") + + aliceOwner := ownerTeamID(t, db, alice.Token) + assert.NotEmpty(t, aliceOwner, "Alice's own resource must be bound to her new team") + + assert.Empty(t, ownerTeamID(t, db, bob.Token), + "LAYER 2 REGRESSION: Bob's resource must still be UNOWNED after Alice claims. "+ + "The claim-time fingerprint sweep handed a stranger's live credential to "+ + "whoever claimed first — this is the exact prod incident.") + + // And Bob can still claim his own — the fix must not strand him. + require.Equal(t, http.StatusCreated, claimAs(t, app, bob.JWT), + "Bob must still be able to claim his own resource afterwards") + bobOwner := ownerTeamID(t, db, bob.Token) + assert.NotEmpty(t, bobOwner, "Bob's resource must bind to Bob's team") + assert.NotEqual(t, aliceOwner, bobOwner, + "Alice and Bob must end up in different teams — a shared /24 is not a shared account") +} + +// ───────────────────────────────────────────────────────────────────────────── +// /claim/preview must not over-promise. +// ───────────────────────────────────────────────────────────────────────────── + +// TestClaimPreview_MatchesWhatClaimBinds pins the preview-equals-claim +// invariant. The preview is what an agent shows the user before they commit; +// a preview that lists a stranger's resource is its own bug, and it is the +// surface that made the claim-by-fingerprint hole look intentional. +// +// The assertion is a set comparison computed from the DB after the claim, so +// it fails if EITHER side drifts — not just if the preview is wrong. +func TestClaimPreview_MatchesWhatClaimBinds(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + sharedIP := testhelpers.FingerprintToIP(testhelpers.UniqueFingerprint(t)) + alice := provisionBehindNAT(t, app, sharedIP, "alice", nil) + bob := provisionBehindNAT(t, app, sharedIP, "bob", nil) + defer dropResources(db, alice.Token, bob.Token) + + // What the preview promises Alice. + resp := testhelpers.GetReq(t, app, "/claim/preview?t="+alice.JWT) + var preview struct { + OK bool `json:"ok"` + TokenValid bool `json:"token_valid"` + Items []struct { + Token string `json:"token"` + } `json:"items"` + } + testhelpers.DecodeJSON(t, resp, &preview) + require.True(t, preview.OK) + require.True(t, preview.TokenValid) + + promised := make([]string, 0, len(preview.Items)) + for _, it := range preview.Items { + promised = append(promised, it.Token) + } + sort.Strings(promised) + + assert.Equal(t, []string{alice.Token}, promised, + "the preview must promise Alice exactly her own resource — never Bob's, "+ + "which merely shares her /24") + + // What the claim actually binds. + require.Equal(t, http.StatusCreated, claimAs(t, app, alice.JWT)) + + var bound []string + for _, tok := range []string{alice.Token, bob.Token} { + if ownerTeamID(t, db, tok) != "" { + bound = append(bound, tok) + } + } + sort.Strings(bound) + + assert.Equal(t, promised, bound, + "PREVIEW-EQUALS-CLAIM: /claim/preview must list exactly the set /claim binds") +} + +// ───────────────────────────────────────────────────────────────────────────── +// A JWT naming a token someone else already owns. +// ───────────────────────────────────────────────────────────────────────────── + +// TestClaim_SkipsTokenAlreadyClaimedByAnotherTeam covers the residual case the +// fix must still handle safely: a token appears in a verified JWT, but by the +// time it is redeemed the resource belongs to a different team. +// +// This is not hypothetical. Onboarding JWTs live 7 days, and every JWT minted +// BEFORE this fix shipped carries fingerprint-swept strangers' tokens. Those +// tokens must be skipped, never re-pointed at the redeeming team. +// +// The hostile token is built by re-signing Alice's real JWT with Bob's token +// appended, preserving the JTI so it still resolves against onboarding_events +// — i.e. exactly the shape of a legacy pre-fix JWT. +func TestClaim_SkipsTokenAlreadyClaimedByAnotherTeam(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + sharedIP := testhelpers.FingerprintToIP(testhelpers.UniqueFingerprint(t)) + alice := provisionBehindNAT(t, app, sharedIP, "alice", nil) + bob := provisionBehindNAT(t, app, sharedIP, "bob", nil) + defer dropResources(db, alice.Token, bob.Token) + + // Bob claims first — his resource now belongs to Bob's team. + require.Equal(t, http.StatusCreated, claimAs(t, app, bob.JWT)) + bobOwner := ownerTeamID(t, db, bob.Token) + require.NotEmpty(t, bobOwner) + + // Alice presents a (legitimately signed) token that also names Bob's + // resource — the legacy pre-fix JWT shape. + base, err := crypto.VerifyOnboardingJWT([]byte(testhelpers.TestJWTSecret), alice.JWT) + require.NoError(t, err) + legacy := crypto.OnboardingClaims{ + Fingerprint: base.Fingerprint, + Country: base.Country, + CloudVendor: base.CloudVendor, + OrgName: base.OrgName, + Tokens: []string{alice.Token, bob.Token}, + ResourceTypes: base.ResourceTypes, + SuggestedPlan: base.SuggestedPlan, + RegisteredClaims: jwt.RegisteredClaims{ + ID: base.ID, // preserved so onboarding_events resolves + IssuedAt: base.IssuedAt, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + }, + } + legacyToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, legacy). + SignedString([]byte(testhelpers.TestJWTSecret)) + require.NoError(t, err) + + // The preview must already refuse to promise Bob's resource. + resp := testhelpers.GetReq(t, app, "/claim/preview?t="+legacyToken) + var preview struct { + Items []struct { + Token string `json:"token"` + } `json:"items"` + } + testhelpers.DecodeJSON(t, resp, &preview) + for _, it := range preview.Items { + assert.NotEqual(t, bob.Token, it.Token, + "preview must not promise a resource another team already owns") + } + + require.Equal(t, http.StatusCreated, claimAs(t, app, legacyToken), + "an unowned token alongside an already-owned one must still claim cleanly") + + assert.Equal(t, bobOwner, ownerTeamID(t, db, bob.Token), + "SKIPPED, NOT STOLEN: an already-claimed token named in a JWT must stay "+ + "with its existing owner") + assert.NotEmpty(t, ownerTeamID(t, db, alice.Token), + "Alice's own resource must still bind") +} + +// TestClaim_IgnoresUnparseableAndUnknownTokensInJWT covers the two remaining +// skip arms of the claim transfer loop (and their preview twins): a `tok` +// entry that is not a UUID, and a well-formed UUID with no resource row. +// Neither may abort the claim or leak into the bound set. +func TestClaim_IgnoresUnparseableAndUnknownTokensInJWT(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + sharedIP := testhelpers.FingerprintToIP(testhelpers.UniqueFingerprint(t)) + alice := provisionBehindNAT(t, app, sharedIP, "alice", nil) + defer dropResources(db, alice.Token) + + base, err := crypto.VerifyOnboardingJWT([]byte(testhelpers.TestJWTSecret), alice.JWT) + require.NoError(t, err) + + ghost := uuid.NewString() // well-formed, no row + noisy := crypto.OnboardingClaims{ + Fingerprint: base.Fingerprint, + // "not-a-uuid" exercises the parse-failure skip; ghost exercises the + // lookup-failure skip; the duplicate exercises the dedup skip. + Tokens: []string{"not-a-uuid", ghost, alice.Token, alice.Token}, + RegisteredClaims: jwt.RegisteredClaims{ + ID: base.ID, + IssuedAt: base.IssuedAt, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + }, + } + noisyToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, noisy). + SignedString([]byte(testhelpers.TestJWTSecret)) + require.NoError(t, err) + + resp := testhelpers.GetReq(t, app, "/claim/preview?t="+noisyToken) + var preview struct { + Items []struct { + Token string `json:"token"` + } `json:"items"` + } + testhelpers.DecodeJSON(t, resp, &preview) + require.Len(t, preview.Items, 1, "only the one real, unowned resource may be previewed") + assert.Equal(t, alice.Token, preview.Items[0].Token) + + require.Equal(t, http.StatusCreated, claimAs(t, app, noisyToken)) + assert.NotEmpty(t, ownerTeamID(t, db, alice.Token)) + + var ghostRows int + require.NoError(t, db.QueryRow( + `SELECT count(*) FROM resources WHERE token = $1`, ghost).Scan(&ghostRows)) + assert.Zero(t, ghostRows, "a claim must never conjure a row for an unknown token") +} + +// TestGetAllActiveResourcesByFingerprint_HasNoOwnershipCallSites is the +// coverage test rule 17 asks for: it fails if a NEW ownership-granting call +// site of the fingerprint sweep appears later. +// +// The query itself is legitimate and stays — the recycle gate uses it to decide +// "is this fingerprint mid-session", which is quota logic, not ownership. What +// must never come back is a call from the claim/JWT-issuance path. This test +// pins the behavioural contract that such a call would break: a resource that +// exists ONLY in the fingerprint bucket (never named in any JWT) is invisible +// to both /claim/preview and /claim. +func TestGetAllActiveResourcesByFingerprint_HasNoOwnershipCallSites(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + sharedIP := testhelpers.FingerprintToIP(testhelpers.UniqueFingerprint(t)) + alice := provisionBehindNAT(t, app, sharedIP, "alice", nil) + defer dropResources(db, alice.Token) + + // A stranger's row seeded straight into Alice's fingerprint bucket — the + // "provisioned after the JWT was issued" case the removed sweep existed to + // serve, and the exact shape the prod incident took. + var fingerprint string + require.NoError(t, db.QueryRow( + `SELECT fingerprint FROM resources WHERE token = $1`, alice.Token).Scan(&fingerprint)) + + strangerExpiry := time.Now().UTC().Add(24 * time.Hour) + stranger, err := models.CreateResource(t.Context(), db, models.CreateResourceParams{ + ResourceType: "redis", + Name: fmt.Sprintf("stranger-%s", uuid.NewString()[:8]), + Tier: "anonymous", + Fingerprint: fingerprint, + ExpiresAt: &strangerExpiry, + }) + require.NoError(t, err) + defer dropResources(db, stranger.Token.String()) + // CreateResource lands rows in 'pending'; the sweep only sees 'active'. + require.NoError(t, models.MarkResourceActive(t.Context(), db, stranger.ID)) + + // Sanity: the sweep really would find it. + swept, err := models.GetAllActiveResourcesByFingerprint(t.Context(), db, fingerprint) + require.NoError(t, err) + require.GreaterOrEqual(t, len(swept), 2, + "sanity: both rows share the fingerprint bucket, so a sweep would return both") + + resp := testhelpers.GetReq(t, app, "/claim/preview?t="+alice.JWT) + var preview struct { + Items []struct { + Token string `json:"token"` + } `json:"items"` + } + testhelpers.DecodeJSON(t, resp, &preview) + require.Len(t, preview.Items, 1, + "preview must be built from the JWT alone — a fingerprint sweep would return 2") + assert.Equal(t, alice.Token, preview.Items[0].Token) + + require.Equal(t, http.StatusCreated, claimAs(t, app, alice.JWT)) + assert.Empty(t, ownerTeamID(t, db, stranger.Token.String()), + "a resource reachable only via the fingerprint must never be claimed") +} diff --git a/internal/handlers/db.go b/internal/handlers/db.go index dc7defbc..366c3f8f 100644 --- a/internal/handlers/db.go +++ b/internal/handlers/db.go @@ -170,7 +170,7 @@ func (h *DBHandler) NewDB(c *fiber.Ctx) error { return h.denyProvisionOverCap(c, fp, "postgres") } if err == nil { - jwtToken, jti, jwtErr := h.issueOnboardingJWT(ctx, fp, country, vendor, "postgres", []string{existing.Token.String()}) + jwtToken, jti, jwtErr := h.issueOnboardingJWT(c, fp, country, vendor, "postgres", []string{existing.Token.String()}) if jwtErr == nil && jti != "" { if evErr := h.createOnboardingEvent(ctx, fp, jti, existing.Token); evErr != nil { slog.Error("db.new.onboarding_event_failed_limit_path", "error", evErr, "request_id", requestID) @@ -280,7 +280,7 @@ func (h *DBHandler) NewDB(c *fiber.Ctx) error { return respondProvisionFailed(c, finErr, "Failed to persist Postgres resource") } - jwtToken, jti, jwtErr := h.issueOnboardingJWT(ctx, fp, country, vendor, "postgres", []string{tokenStr}) + jwtToken, jti, jwtErr := h.issueOnboardingJWT(c, fp, country, vendor, "postgres", []string{tokenStr}) if jwtErr != nil { slog.Error("db.new.jwt_issue_failed", "error", jwtErr, "request_id", requestID) } diff --git a/internal/handlers/nosql.go b/internal/handlers/nosql.go index ef748014..ec356ea2 100644 --- a/internal/handlers/nosql.go +++ b/internal/handlers/nosql.go @@ -141,7 +141,7 @@ func (h *NoSQLHandler) NewNoSQL(c *fiber.Ctx) error { return h.denyProvisionOverCap(c, fp, "mongodb") } if err == nil { - jwtToken, jti, jwtErr := h.issueOnboardingJWT(ctx, fp, country, vendor, "mongodb", []string{existing.Token.String()}) + jwtToken, jti, jwtErr := h.issueOnboardingJWT(c, fp, country, vendor, "mongodb", []string{existing.Token.String()}) if jwtErr == nil && jti != "" { if evErr := h.createOnboardingEvent(ctx, fp, jti, existing.Token); evErr != nil { slog.Error("nosql.new.onboarding_event_failed_limit_path", "error", evErr, "request_id", requestID) @@ -238,7 +238,7 @@ func (h *NoSQLHandler) NewNoSQL(c *fiber.Ctx) error { return respondProvisionFailed(c, finErr, "Failed to persist MongoDB resource") } - jwtToken, jti, jwtErr := h.issueOnboardingJWT(ctx, fp, country, vendor, "mongodb", []string{tokenStr}) + jwtToken, jti, jwtErr := h.issueOnboardingJWT(c, fp, country, vendor, "mongodb", []string{tokenStr}) if jwtErr != nil { slog.Error("nosql.new.jwt_issue_failed", "error", jwtErr, "request_id", requestID) } diff --git a/internal/handlers/onboarding.go b/internal/handlers/onboarding.go index f393b1fc..c6d241eb 100644 --- a/internal/handlers/onboarding.go +++ b/internal/handlers/onboarding.go @@ -125,11 +125,25 @@ func (h *OnboardingHandler) ClaimPreview(c *fiber.Ctx) error { }) } - // Build deduplicated resource list — same logic as StartLanding. + // Build the deduplicated resource list. + // + // PREVIEW-EQUALS-CLAIM INVARIANT (2026-08-13): this loop must accept and + // reject exactly what Claim's transfer loop accepts and rejects, or the + // preview over-promises — its own bug, and the surface that made the + // claim-by-fingerprint hole look intentional. Both now: + // - iterate ONLY claims.Tokens (no fingerprint sweep: a fingerprint is + // SHA256(/24 + ASN) and buckets strangers behind one NAT together), + // - skip tokens that do not parse, + // - skip tokens with no resource row, + // - skip resources already owned by a team (Claim cannot take those). + // TestClaimPreview_MatchesWhatClaimBinds pins the pair together. seenTokens := map[string]bool{} var resources []fiber.Map for _, tokenStr := range claims.Tokens { + if seenTokens[tokenStr] { + continue + } seenTokens[tokenStr] = true tok, parseErr := uuid.Parse(tokenStr) if parseErr != nil { @@ -139,6 +153,11 @@ func (h *OnboardingHandler) ClaimPreview(c *fiber.Ctx) error { if lookupErr != nil { continue } + if r.TeamID.Valid { + // Already claimed — by this caller's own earlier claim or by + // someone else. Claim skips it; so must the preview. + continue + } resources = append(resources, fiber.Map{ "id": r.ID, "token": r.Token, @@ -149,29 +168,6 @@ func (h *OnboardingHandler) ClaimPreview(c *fiber.Ctx) error { }) } - // Also include any resources provisioned after JWT issuance for this fingerprint. - if claims.Fingerprint != "" { - fpResources, fpErr := models.GetAllActiveResourcesByFingerprint(ctx, h.db, claims.Fingerprint) - if fpErr != nil { - slog.Warn("onboarding.claim_preview.fingerprint_lookup_failed", "error", fpErr, "request_id", requestID) - } - for _, r := range fpResources { - tokStr := r.Token.String() - if seenTokens[tokStr] { - continue - } - seenTokens[tokStr] = true - resources = append(resources, fiber.Map{ - "id": r.ID, - "token": r.Token, - "resource_type": r.ResourceType, - "tier": r.Tier, - "status": r.Status, - "created_at": r.CreatedAt, - }) - } - } - if resources == nil { resources = []fiber.Map{} } @@ -234,11 +230,17 @@ const ( ) // attachClaimedResourceToTeam links a single anonymous resource to the claiming -// team and elevates it anonymous->free. It is the shared write path for BOTH -// claim-time resource grabs (the JWT-listed loop and the fingerprint-discovered -// loop) so they behave identically and observably. +// team and elevates it anonymous->free. +// +// It is now the platform's ONLY resource-ownership-transfer write, and it has +// exactly ONE caller: Claim's loop over the verified JWT's `tok` array. The +// second caller — a loop over models.GetAllActiveResourcesByFingerprint — was +// deleted on 2026-08-13 because a fingerprint is a shared NAT bucket, not an +// identity (see the SECURITY note in Claim). Keep it at one caller: any new +// call site is a new answer to "who owns this resource", and the only +// acceptable answer is "whoever presented a signed token naming it". // -// Pre-fix both call sites used `_, _ = h.db.ExecContext(...)`, swallowing any +// Historically both call sites used `_, _ = h.db.ExecContext(...)`, swallowing any // error: a failed UPDATE left the resource with team_id IS NULL AFTER a // successful claim — an orphaned-after-claim resource with NO log and NO metric, // invisible to operators (the user "claimed" but their resource never attached). @@ -461,9 +463,25 @@ func (h *OnboardingHandler) Claim(c *fiber.Ctx) error { } // Transfer anonymous resources to new team. - // Collect all resource IDs to transfer: start from JWT-listed tokens, then - // augment with any resources for this fingerprint that were provisioned after - // the JWT was issued (e.g. DB provisioned after the onboarding JWT was created). + // + // SECURITY (2026-08-13): the ONLY source of resource IDs is the verified + // JWT's `tok` array. There used to be a second pass here that swept + // models.GetAllActiveResourcesByFingerprint(claims.Fingerprint) and + // attached every unclaimed match. A fingerprint is SHA256(/24 subnet + + // ASN) — a bucket shared by everyone behind one NAT/CGNAT range — so two + // strangers provisioning inside the same 24h TTL window landed in one + // bucket and whoever claimed first inherited the other's live database + // credential. Confirmed in prod: a team owned three Postgres databases it + // never created, one of them older than its first API call. + // + // Ownership now derives only from a capability the caller holds (the + // signed token it was handed at provision time). Multi-service bundling is + // preserved at ISSUE time instead, by chaining the prior signed token on + // HeaderPriorUpgradeToken — see handlers.issueOnboardingJWT. There is no + // fingerprint fallback here, by design. + // + // claims.Fingerprint is still read below for funnel analytics; it confers + // nothing. claimedIDs := map[uuid.UUID]bool{} for _, tokenStr := range claims.Tokens { @@ -489,20 +507,7 @@ func (h *OnboardingHandler) Claim(c *fiber.Ctx) error { _ = attachClaimedResourceToTeam(ctx, h.db, team.ID, resource.ID, requestID) } - // Also claim any additional fingerprint resources not yet in the JWT. - if claims.Fingerprint != "" { - fpResources, fpErr := models.GetAllActiveResourcesByFingerprint(ctx, h.db, claims.Fingerprint) - if fpErr != nil { - slog.Warn("onboarding.claim.fingerprint_lookup_failed", - "error", fpErr, "request_id", requestID) - } - for _, r := range fpResources { - if claimedIDs[r.ID] || r.TeamID.Valid { - continue - } - _ = attachClaimedResourceToTeam(ctx, h.db, team.ID, r.ID, requestID) - } - } + // (No fingerprint sweep here — see the SECURITY note above.) // "Pay from day one" — no trial, no auto-elevation. The team is created // at the default plan_tier; resources keep their anonymous tier + 24h diff --git a/internal/handlers/onboarding_residual_test.go b/internal/handlers/onboarding_residual_test.go index ac23bee8..c99690a4 100644 --- a/internal/handlers/onboarding_residual_test.go +++ b/internal/handlers/onboarding_residual_test.go @@ -216,10 +216,17 @@ func TestResidualMaskEmailForLog(t *testing.T) { assert.NotPanics(t, func() { _ = handlers.MaskEmailForLogForTest("") }) } -// TestResidualClaimPreview_FingerprintResources drives the ClaimPreview -// fingerprint-augmentation loop (147-167): a preview whose JWT carries a -// fingerprint with active resources NOT in the token list. -func TestResidualClaimPreview_FingerprintResources(t *testing.T) { +// TestResidualClaimPreview_IgnoresFingerprintResources is the inverted +// descendant of TestResidualClaimPreview_FingerprintResources, which used to +// assert that ClaimPreview augmented its list from +// GetAllActiveResourcesByFingerprint. That augmentation WAS the bug (see +// claim_fingerprint_isolation_test.go): a fingerprint is SHA256(/24 + ASN), so +// the "augmented" rows routinely belonged to a stranger behind the same NAT, +// and /claim then bound them. +// +// The same fixture now pins the opposite contract: a JWT with an empty token +// list previews NOTHING, no matter how many active rows share its fingerprint. +func TestResidualClaimPreview_IgnoresFingerprintResources(t *testing.T) { db, clean := testhelpers.SetupTestDB(t) defer clean() app := onboardingResidualApp(t, db) @@ -239,13 +246,17 @@ func TestResidualClaimPreview_FingerprintResources(t *testing.T) { `, uuid.NewString(), fp) require.NoError(t, err) } - signed := mintOnboardingJWT(t, jti, fp, nil) // empty token list → all via fingerprint + signed := mintOnboardingJWT(t, jti, fp, nil) // empty token list resp := doGet(t, app, "/claim/preview?t="+signed) require.Equal(t, http.StatusOK, resp.StatusCode) var body map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + res, _ := body["resources"].([]any) - assert.GreaterOrEqual(t, len(res), 2, "fingerprint-augmented resources must appear in preview") + assert.Empty(t, res, + "a preview must list only what the JWT names — never rows discovered by fingerprint") + items, _ := body["items"].([]any) + assert.Empty(t, items, "the canonical `items` alias must agree with `resources`") } // TestResidualClaim_AccountExists_409 drives the account-takeover-guard arm @@ -314,7 +325,10 @@ func TestResidualClaim_HappyPath_ClaimsResources(t *testing.T) { `, listedToken, fp) require.NoError(t, err) - // A fingerprint-only anonymous resource NOT in the JWT token list. + // A fingerprint-only anonymous resource NOT in the JWT token list — i.e. + // what a stranger behind the same NAT would have. It must NOT be claimed + // (this assertion was inverted on 2026-08-13 when the claim-time + // fingerprint sweep was removed; see claim_fingerprint_isolation_test.go). fpToken := uuid.NewString() _, err = db.ExecContext(ctx, ` INSERT INTO resources (token, resource_type, tier, env, status, fingerprint) @@ -343,14 +357,27 @@ func TestResidualClaim_HappyPath_ClaimsResources(t *testing.T) { defer resp.Body.Close() require.Equal(t, http.StatusCreated, resp.StatusCode) - // Both resources should now belong to the new team at tier=free. + // The JWT-listed resource is claimed → free. The fingerprint-only one is + // untouched: it was never named by a token the caller could produce. var listedTier, fpTier string + var fpTeam sql.NullString require.NoError(t, db.QueryRowContext(ctx, `SELECT tier FROM resources WHERE token = $1`, listedToken).Scan(&listedTier)) require.NoError(t, db.QueryRowContext(ctx, - `SELECT tier FROM resources WHERE token = $1`, fpToken).Scan(&fpTier)) + `SELECT tier, team_id::text FROM resources WHERE token = $1`, fpToken).Scan(&fpTier, &fpTeam)) assert.Equal(t, "free", listedTier, "JWT-listed resource must be claimed → free") - assert.Equal(t, "free", fpTier, "fingerprint resource must be claimed → free") + assert.Equal(t, "anonymous", fpTier, + "a fingerprint-only resource must NOT be elevated by someone else's claim") + assert.False(t, fpTeam.Valid, + "a fingerprint-only resource must NOT be attached to the claiming team") + + // The already-claimed token in the list stays with its original owner. + var claimedOwner sql.NullString + require.NoError(t, db.QueryRowContext(ctx, + `SELECT team_id::text FROM resources WHERE token = $1`, claimedToken).Scan(&claimedOwner)) + require.True(t, claimedOwner.Valid) + assert.Equal(t, otherTeam, claimedOwner.String, + "an already-claimed token named in the JWT must be skipped, not re-pointed") } // ── Claim create-failure arms (sqlmock mid-sequence) ───────────────────────── diff --git a/internal/handlers/provision_helper.go b/internal/handlers/provision_helper.go index 1697f29a..cd8bc50f 100644 --- a/internal/handlers/provision_helper.go +++ b/internal/handlers/provision_helper.go @@ -336,8 +336,8 @@ func (h *provisionHelper) recycleGate(c *fiber.Ctx, fp, resourceType string) boo // anonymous provision already returns in upgrade_url/claim_url. The JWT // carries the fingerprint so /start can hydrate the claim landing even // though there are zero ACTIVE resources right now (the recycle case is - // "I had something yesterday"); it captures any still-listed tokens too. - claimURL := h.recycleClaimURL(ctx, fp, resourceType) + // "I had something yesterday"); it captures any chained tokens too. + claimURL := h.recycleClaimURL(c, fp, resourceType) // Route through the canonical ErrorResponse envelope (request_id + // retry_after_seconds + claim_url) instead of a hand-built fiber.Map. @@ -353,10 +353,10 @@ func (h *provisionHelper) recycleGate(c *fiber.Ctx, fp, resourceType string) boo // successfully), so without this seam the F1 fail-soft branch is unreachable. // Mirrors the promoteDeploymentTTLsForTeamFn pattern in billing.go. var issueOnboardingJWTFn = func( - h *provisionHelper, ctx context.Context, + h *provisionHelper, c *fiber.Ctx, fp, country, vendor, resourceType string, tokens []string, ) (string, string, error) { - return h.issueOnboardingJWT(ctx, fp, country, vendor, resourceType, tokens) + return h.issueOnboardingJWT(c, fp, country, vendor, resourceType, tokens) } // recycleClaimURL mints a short-lived claim JWT for the recycling fingerprint @@ -371,15 +371,17 @@ var issueOnboardingJWTFn = func( // path we bump the minted metric. We do NOT persist an onboarding_events row // here: the recycle gate is a best-effort recovery nudge, not a tracked // conversion event, and a DB write would add a failure mode to the gate path. -func (h *provisionHelper) recycleClaimURL(ctx context.Context, fp, resourceType string) string { - // issueOnboardingJWT looks up any still-listed resources for the - // fingerprint and folds them into the JWT; on the recycle path that set is - // typically empty (that's why the gate fired), but a partially-expired - // session may still have one. country/vendor are advisory upsell hints on - // the landing page — empty is fine here, the claim itself only needs fp. - // resourceType is the type the gated agent was trying to provision, so the - // landing page reflects what the user is here to claim. - jwtToken, _, err := issueOnboardingJWTFn(h, ctx, fp, "", "", resourceType, nil) +func (h *provisionHelper) recycleClaimURL(c *fiber.Ctx, fp, resourceType string) string { + // The gate only fires when this fingerprint has ZERO active resources, so + // there is nothing of the caller's own to list here — the minted token + // carries only whatever the caller chained on HeaderPriorUpgradeToken (a + // partially-expired session may still have one live row). It is never + // populated from the fingerprint; see issueOnboardingJWT. + // country/vendor are advisory upsell hints on the landing page — empty is + // fine here, the claim itself only needs fp. resourceType is the type the + // gated agent was trying to provision, so the landing page reflects what + // the user is here to claim. + jwtToken, _, err := issueOnboardingJWTFn(h, c, fp, "", "", resourceType, nil) if err != nil || jwtToken == "" { slog.Warn("provision.recycle_gate.claim_jwt_failed", "error", err, "fingerprint", fp) @@ -583,26 +585,88 @@ func emitProvisionPersistenceFailedAudit( } } +// HeaderPriorUpgradeToken is the request header through which a caller chains +// a previously issued onboarding token — the `upgrade_jwt` field returned by +// every anonymous provisioning response — onto a subsequent provision, so one +// agent session's services end up in ONE claimable bundle. +// +// This header exists because ownership must derive from a capability the +// caller HOLDS, never from the caller's network address. See +// issueOnboardingJWT for the full rationale. +const HeaderPriorUpgradeToken = "X-Instant-Upgrade-Token" + +// maxChainedUpgradeTokens caps how many resource tokens a single onboarding +// JWT may carry. The chain grows by exactly one token per anonymous provision +// and the per-fingerprint daily cap already bounds that, so this ceiling is +// belt-and-braces against an unbounded header/JWT: past it, the oldest entries +// are dropped rather than letting the token grow without limit. Overflow is +// counted (metrics.UpgradeTokenChain{result="truncated"}) and logged. +const maxChainedUpgradeTokens = 25 + +// priorUpgradeClaims returns the VERIFIED claims of the onboarding token the +// caller chained on HeaderPriorUpgradeToken, or nil when the header is absent, +// malformed, expired, or signed with a key that is not ours. +// +// It NEVER fails the provision. An invalid chain degrades to "this request's +// own token only" — which is also the no-header default — because a broken or +// stale chain header is a client bug, not a reason to deny an agent the +// credentials it just asked for. The failure is logged at WARN and counted so +// a broken client (or someone probing the chain) is visible in NR. +func (h *provisionHelper) priorUpgradeClaims(c *fiber.Ctx) *crypto.OnboardingClaims { + raw := strings.TrimSpace(c.Get(HeaderPriorUpgradeToken)) + if raw == "" { + return nil + } + + claims, err := crypto.VerifyOnboardingJWT([]byte(h.cfg.JWTSecret), raw) + if err != nil { + // Deliberately does NOT log the token itself — it is a bearer + // credential for whatever resources it legitimately lists. + slog.Warn("provision.upgrade_chain.rejected", + "error", err, + "reason", "prior upgrade token failed verification; degrading to single-token JWT") + metrics.UpgradeTokenChain.WithLabelValues("rejected").Inc() + return nil + } + + metrics.UpgradeTokenChain.WithLabelValues("accepted").Inc() + return claims +} + // issueOnboardingJWT signs a short-lived JWT for the upgrade CTA. -// It looks up ALL active resources for the fingerprint so the landing page -// reflects the full session (not just the current service). +// +// SECURITY (2026-08-13) — the token list is built from CAPABILITY, never from +// the network. This function used to call +// models.GetAllActiveResourcesByFingerprint and fold every match into the +// signed `tok` array. Because a fingerprint is SHA256(/24 subnet + ASN), every +// caller behind one NAT/CGNAT range shares a bucket: the JWT handed to caller +// A enumerated caller B's live resource tokens, and POST /claim then bound +// them to A's brand-new team. The signature was no defence — the *contents* +// were assembled from the network. Confirmed live in prod: a team ended up +// owning three Postgres databases it never created. +// +// The multi-service bundle is preserved by CHAINING instead: a caller who +// re-presents its previous, signature-verified onboarding token on +// HeaderPriorUpgradeToken has proven it received that token, and its list is +// carried forward. A stranger on the same /24 cannot produce one. +// +// Absent / invalid prior token → this request's `tokens` only. There is no +// fingerprint fallback, by design. `fp` is still stamped into the claims +// because the claim landing page and the conversion-funnel analytics read it — +// it is an attribute of the session, not a grant of ownership. +// // Returns ("", "", err) if signing fails — callers treat this as a soft error // and proceed without the JWT (upgrade URL will be empty). func (h *provisionHelper) issueOnboardingJWT( - ctx context.Context, + c *fiber.Ctx, fp, country, vendor string, resourceType string, tokens []string, ) (jwtToken, jti string, err error) { - // Look up all active resources for this fingerprint so the JWT captures - // every service provisioned in one agent session. - allResources, lookupErr := models.GetAllActiveResourcesByFingerprint(ctx, h.db, fp) - if lookupErr != nil { - slog.Warn("issueOnboardingJWT: fingerprint lookup failed (using current token only)", - "error", lookupErr, "fingerprint", fp) - } - - allTokens := tokens + // Copy rather than alias: append() must never write through the caller's + // backing array. Tokens land newest-first (this request, then the chain in + // reverse-provision order) so the truncation below drops the OLDEST. + allTokens := append([]string(nil), tokens...) allTypes := []string{resourceType} // Use "type:" prefix consistently for type dedup keys to avoid collision // with token UUID strings (which have no prefix). @@ -610,23 +674,33 @@ func (h *provisionHelper) issueOnboardingJWT( for _, tok := range tokens { seen[tok] = true } - for _, r := range allResources { - // Skip resource types that are not enabled in config. The JWT should only - // advertise claimable services. - if !h.cfg.IsServiceEnabled(r.ResourceType) { - continue - } - tokStr := r.Token.String() - if !seen[tokStr] { - allTokens = append(allTokens, tokStr) - seen[tokStr] = true + + if prior := h.priorUpgradeClaims(c); prior != nil { + for _, tok := range prior.Tokens { + if tok == "" || seen[tok] { + continue + } + allTokens = append(allTokens, tok) + seen[tok] = true } - if !seen["type:"+r.ResourceType] { - allTypes = append(allTypes, r.ResourceType) - seen["type:"+r.ResourceType] = true + for _, rt := range prior.ResourceTypes { + // The JWT should only advertise claimable services — a service + // disabled since the prior token was minted is not one. + if rt == "" || seen["type:"+rt] || !h.cfg.IsServiceEnabled(rt) { + continue + } + allTypes = append(allTypes, rt) + seen["type:"+rt] = true } } + if len(allTokens) > maxChainedUpgradeTokens { + slog.Warn("provision.upgrade_chain.truncated", + "chained", len(allTokens), "cap", maxChainedUpgradeTokens, "fingerprint", fp) + metrics.UpgradeTokenChain.WithLabelValues("truncated").Inc() + allTokens = allTokens[:maxChainedUpgradeTokens] + } + secret := []byte(h.cfg.JWTSecret) claims := crypto.OnboardingClaims{ Fingerprint: fp, diff --git a/internal/handlers/queue.go b/internal/handlers/queue.go index 7847b1e6..514b6357 100644 --- a/internal/handlers/queue.go +++ b/internal/handlers/queue.go @@ -263,7 +263,7 @@ func (h *QueueHandler) NewQueue(c *fiber.Ctx) error { return h.denyProvisionOverCap(c, fp, "queue") } if err == nil { - jwtToken, jti, jwtErr := h.issueOnboardingJWT(ctx, fp, country, vendor, "queue", []string{existing.Token.String()}) + jwtToken, jti, jwtErr := h.issueOnboardingJWT(c, fp, country, vendor, "queue", []string{existing.Token.String()}) if jwtErr == nil && jti != "" { if evErr := h.createOnboardingEvent(ctx, fp, jti, existing.Token); evErr != nil { slog.Error("queue.new.onboarding_event_failed_limit_path", "error", evErr, "request_id", requestID) @@ -381,7 +381,7 @@ func (h *QueueHandler) NewQueue(c *fiber.Ctx) error { } } - jwtToken, jti, jwtErr := h.issueOnboardingJWT(ctx, fp, country, vendor, "queue", []string{tokenStr}) + jwtToken, jti, jwtErr := h.issueOnboardingJWT(c, fp, country, vendor, "queue", []string{tokenStr}) if jwtErr != nil { slog.Error("queue.new.jwt_issue_failed", "error", jwtErr, "request_id", requestID) } diff --git a/internal/handlers/recycle_gate_test.go b/internal/handlers/recycle_gate_test.go index c5d3472f..d46910b2 100644 --- a/internal/handlers/recycle_gate_test.go +++ b/internal/handlers/recycle_gate_test.go @@ -167,9 +167,12 @@ func TestRecycleGate_FiresWith402_WhenMarkerExistsAndNoActiveRow(t *testing.T) { // SELECT ... FROM resources WHERE fingerprint = $1 AND team_id IS NULL // AND status = 'active' ORDER BY created_at DESC // (cross-service: any active resource for this fingerprint counts). - // We return zero rows. F1: the gate now mints a claim JWT, which issues a - // SECOND identical fingerprint lookup inside issueOnboardingJWT — expect - // both (each returns zero rows; sqlmock matches in order). + // We return zero rows. EXACTLY ONE fingerprint query is expected: the + // gate's own recycle check. The claim JWT the gate then mints is built + // from capability (this request's tokens + any chained prior token), NOT + // from a second fingerprint sweep — that sweep was the claim-by- + // fingerprint hole and was deleted (see issueOnboardingJWT). sqlmock's + // ExpectationsWereMet below fails if a second sweep ever comes back. emptyRows := func() *sqlmock.Rows { return sqlmock.NewRows([]string{ "id", "team_id", "token", "resource_type", "name", "connection_url", @@ -182,9 +185,6 @@ func TestRecycleGate_FiresWith402_WhenMarkerExistsAndNoActiveRow(t *testing.T) { mock.ExpectQuery(`SELECT.*FROM resources.*fingerprint`). WithArgs(fp). WillReturnRows(emptyRows()) - mock.ExpectQuery(`SELECT.*FROM resources.*fingerprint`). - WithArgs(fp). - WillReturnRows(emptyRows()) var gateFired bool status, body := drive(t, func(c *fiber.Ctx) error { @@ -259,7 +259,7 @@ func TestRecycleGate_ClaimURL_MintFailedFallsBackToBareURL(t *testing.T) { // Force the mint to fail → exercises the mint_failed arm + bare-URL fallback. orig := issueOnboardingJWTFn issueOnboardingJWTFn = func( - _ *provisionHelper, _ context.Context, + _ *provisionHelper, _ *fiber.Ctx, _, _, _, _ string, _ []string, ) (string, string, error) { return "", "", errors.New("simulated jwt sign failure") @@ -310,7 +310,7 @@ func TestRecycleGate_ClaimURL_MintSucceeds_EmbedsJWT(t *testing.T) { orig := issueOnboardingJWTFn issueOnboardingJWTFn = func( - _ *provisionHelper, _ context.Context, + _ *provisionHelper, _ *fiber.Ctx, _, _, _, _ string, _ []string, ) (string, string, error) { return "minted.jwt.token", "jti-1", nil diff --git a/internal/handlers/storage.go b/internal/handlers/storage.go index c8c3ba81..711addba 100644 --- a/internal/handlers/storage.go +++ b/internal/handlers/storage.go @@ -194,7 +194,7 @@ func (h *StorageHandler) NewStorage(c *fiber.Ctx) error { return h.denyProvisionOverCap(c, fp, "storage") } if err == nil { - jwtToken, jti, jwtErr := h.issueOnboardingJWT(ctx, fp, country, vendor, "storage", []string{existing.Token.String()}) + jwtToken, jti, jwtErr := h.issueOnboardingJWT(c, fp, country, vendor, "storage", []string{existing.Token.String()}) if jwtErr == nil && jti != "" { if evErr := h.createOnboardingEvent(ctx, fp, jti, existing.Token); evErr != nil { slog.Error("storage.new.onboarding_event_failed_limit_path", "error", evErr, "request_id", requestID) @@ -348,7 +348,7 @@ func (h *StorageHandler) NewStorage(c *fiber.Ctx) error { return respondProvisionFailed(c, finErr, "Failed to persist storage resource") } - jwtToken, jti, jwtErr := h.issueOnboardingJWT(ctx, fp, country, vendor, "storage", []string{tokenStr}) + jwtToken, jti, jwtErr := h.issueOnboardingJWT(c, fp, country, vendor, "storage", []string{tokenStr}) if jwtErr != nil { slog.Error("storage.new.jwt_issue_failed", "error", jwtErr, "request_id", requestID) } diff --git a/internal/handlers/upgrade_chain_whitebox_test.go b/internal/handlers/upgrade_chain_whitebox_test.go new file mode 100644 index 00000000..efb37af4 --- /dev/null +++ b/internal/handlers/upgrade_chain_whitebox_test.go @@ -0,0 +1,209 @@ +package handlers + +// upgrade_chain_whitebox_test.go — in-package coverage for the token-chaining +// arms of issueOnboardingJWT that are awkward to reach over HTTP: the +// maxChainedUpgradeTokens ceiling, the empty/duplicate token skips, and the +// enabled-services filter on chained resource types. +// +// The end-to-end behaviour lives in upgrade_token_chain_test.go; this file +// pins the internal invariants that keep the JWT bounded and honest. + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/crypto" + "instant.dev/internal/plans" +) + +const chainTestSecret = "test_secret_must_be_at_least_32_bytes_long_xx" + +// newChainHelper builds a provisionHelper with a real enabled-services list so +// the resource-type filter in issueOnboardingJWT is exercised rather than +// short-circuited. No DB is wired: issueOnboardingJWT must not touch one — +// that is the whole point of the fix. +func newChainHelper(t *testing.T, enabledServices string) (provisionHelper, func()) { + t.Helper() + mr, err := miniredis.Run() + require.NoError(t, err) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + cfg := &config.Config{JWTSecret: chainTestSecret, EnabledServices: enabledServices} + h := newProvisionHelper(nil, rdb, cfg, plans.Default()) + return h, func() { + _ = rdb.Close() + mr.Close() + } +} + +// signPrior mints a prior upgrade token carrying the given lists. +func signPrior(t *testing.T, tokens, resourceTypes []string) string { + t.Helper() + signed, _, err := crypto.SignOnboardingJWT([]byte(chainTestSecret), crypto.OnboardingClaims{ + Tokens: tokens, + ResourceTypes: resourceTypes, + }) + require.NoError(t, err) + return signed +} + +// issueWithChain runs issueOnboardingJWT inside a real Fiber request carrying +// the prior-token header, and returns the decoded claims of the JWT it minted. +func issueWithChain(t *testing.T, h provisionHelper, resourceType string, tokens []string, priorHeader string) *crypto.OnboardingClaims { + t.Helper() + app := fiber.New() + var minted string + var issueErr error + app.Get("/probe", func(c *fiber.Ctx) error { + minted, _, issueErr = h.issueOnboardingJWT(c, "fp_chain", "XX", "unknown", resourceType, tokens) + return c.JSON(fiber.Map{"ok": true}) + }) + + req := httptest.NewRequest(http.MethodGet, "/probe", nil) + if priorHeader != "" { + req.Header.Set(HeaderPriorUpgradeToken, priorHeader) + } + resp, err := app.Test(req, 2000) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + + require.NoError(t, issueErr) + require.NotEmpty(t, minted) + claims, err := crypto.VerifyOnboardingJWT([]byte(chainTestSecret), minted) + require.NoError(t, err) + return claims +} + +// TestIssueOnboardingJWT_NoHeader_IsSingleToken pins the default: with no +// prior token the JWT lists exactly what this request provisioned. There is no +// DB on the helper, so a reintroduced fingerprint sweep would panic here. +func TestIssueOnboardingJWT_NoHeader_IsSingleToken(t *testing.T) { + h, cleanup := newChainHelper(t, "redis,postgres") + defer cleanup() + + tok := uuid.NewString() + claims := issueWithChain(t, h, "redis", []string{tok}, "") + + assert.Equal(t, []string{tok}, claims.Tokens) + assert.Equal(t, []string{"redis"}, claims.ResourceTypes) + assert.Equal(t, "fp_chain", claims.Fingerprint, + "the fingerprint is still stamped for analytics — it just confers nothing") +} + +// TestIssueOnboardingJWT_ChainSkipsEmptyAndDuplicateTokens covers the two skip +// arms of the token-merge loop. +func TestIssueOnboardingJWT_ChainSkipsEmptyAndDuplicateTokens(t *testing.T) { + h, cleanup := newChainHelper(t, "redis,postgres") + defer cleanup() + + current := uuid.NewString() + older := uuid.NewString() + + // Prior lists: an empty string, the current token again, and the older + // token twice. Only `older` may be added, exactly once. + prior := signPrior(t, []string{"", current, older, older}, nil) + claims := issueWithChain(t, h, "redis", []string{current}, prior) + + assert.Equal(t, []string{current, older}, claims.Tokens, + "the merge must dedup and drop empties, preserving this request's token first") +} + +// TestIssueOnboardingJWT_ChainFiltersResourceTypesByEnabledServices covers the +// resource-type merge arms: empty, already-seen, and disabled-service. +func TestIssueOnboardingJWT_ChainFiltersResourceTypesByEnabledServices(t *testing.T) { + h, cleanup := newChainHelper(t, "redis,postgres") + defer cleanup() + + prior := signPrior(t, + []string{uuid.NewString()}, + // "" → skipped; "redis" → already seen (it is this request's type); + // "mongodb" → not in EnabledServices; "postgres" → kept. + []string{"", "redis", "mongodb", "postgres"}, + ) + claims := issueWithChain(t, h, "redis", []string{uuid.NewString()}, prior) + + assert.Equal(t, []string{"redis", "postgres"}, claims.ResourceTypes, + "a JWT must only advertise services that are still claimable") +} + +// TestIssueOnboardingJWT_ChainTruncatesAtCap covers the maxChainedUpgradeTokens +// ceiling. The caller's own token for THIS request must survive truncation — +// it is at the head of the list. +func TestIssueOnboardingJWT_ChainTruncatesAtCap(t *testing.T) { + h, cleanup := newChainHelper(t, "redis") + defer cleanup() + + current := uuid.NewString() + oversized := make([]string, 0, maxChainedUpgradeTokens*2) + for i := 0; i < maxChainedUpgradeTokens*2; i++ { + oversized = append(oversized, fmt.Sprintf("%s-%d", uuid.NewString(), i)) + } + + claims := issueWithChain(t, h, "redis", []string{current}, signPrior(t, oversized, nil)) + + require.Len(t, claims.Tokens, maxChainedUpgradeTokens, + "the chain must be capped so the signed token cannot grow without bound") + assert.Equal(t, current, claims.Tokens[0], + "this request's own token must never be the one truncated away") +} + +// TestPriorUpgradeClaims_RejectsUnverifiableHeader pins the verification gate +// directly, including the whitespace-only short circuit that must not even +// attempt a verify. +func TestPriorUpgradeClaims_RejectsUnverifiableHeader(t *testing.T) { + h, cleanup := newChainHelper(t, "redis") + defer cleanup() + + cases := map[string]struct { + header string + wantNil bool + }{ + "absent": {header: "", wantNil: true}, + "whitespace_only": {header: " \t ", wantNil: true}, + "garbage": {header: "not.a.jwt", wantNil: true}, + "foreign_key": {header: func() string { + signed, _, err := crypto.SignOnboardingJWT([]byte("some_other_key_at_least_32_bytes_long!!"), + crypto.OnboardingClaims{Tokens: []string{uuid.NewString()}}) + require.NoError(t, err) + return signed + }(), wantNil: true}, + "genuine": {header: signPrior(t, []string{uuid.NewString()}, nil), wantNil: false}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + app := fiber.New() + var got *crypto.OnboardingClaims + app.Get("/probe", func(c *fiber.Ctx) error { + got = h.priorUpgradeClaims(c) + return c.SendStatus(fiber.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/probe", nil) + if tc.header != "" { + req.Header.Set(HeaderPriorUpgradeToken, tc.header) + } + resp, err := app.Test(req, 2000) + require.NoError(t, err) + _ = resp.Body.Close() + + if tc.wantNil { + assert.Nil(t, got, "an unverifiable prior token must contribute nothing") + } else { + require.NotNil(t, got) + assert.Len(t, got.Tokens, 1) + } + }) + } +} diff --git a/internal/handlers/upgrade_token_chain_test.go b/internal/handlers/upgrade_token_chain_test.go new file mode 100644 index 00000000..eeb791ce --- /dev/null +++ b/internal/handlers/upgrade_token_chain_test.go @@ -0,0 +1,277 @@ +package handlers_test + +// upgrade_token_chain_test.go — the capability that REPLACED the network +// fingerprint as the basis for multi-service claim bundling. +// +// Before the 2026-08-13 fix, one agent session's services ended up in one +// claimable bundle because the onboarding JWT was assembled from +// GetAllActiveResourcesByFingerprint — which also swept in every stranger +// behind the same NAT (see claim_fingerprint_isolation_test.go). +// +// The bundle survives; the mechanism changed. An agent re-presents the +// `upgrade_jwt` it was handed on the previous provision via the +// X-Instant-Upgrade-Token request header. The server VERIFIES the signature +// and carries that token list forward. Holding the prior signed token proves +// the caller received it; a stranger on the same /24 cannot produce one. +// +// The header was chosen over a body field because: +// - it applies uniformly to all seven /{service}/new endpoints plus the +// multipart /deploy path, with no per-endpoint JSON schema change; +// - it never collides with the caller's own `name`/`env`/`dedicated` body; +// - old clients that don't send it are unaffected — absent means "this +// request's token only", which is the safe default. +// +// Degradation contract, asserted below: an absent, malformed, expired, +// wrong-key, or otherwise unverifiable prior token NEVER fails the provision. +// It degrades to a single-token JWT and is logged + counted. + +import ( + "net/http" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/crypto" + "instant.dev/internal/handlers" + "instant.dev/internal/testhelpers" +) + +// chainHeader builds the request-header map that chains a prior upgrade token. +func chainHeader(prior string) map[string]string { + return map[string]string{handlers.HeaderPriorUpgradeToken: prior} +} + +// TestUpgradeTokenChain_ValidPriorTokenBindsAllResources is the "one caller +// chaining a valid prior JWT → all their resources bind" case. It is the +// functional replacement for the deleted fingerprint sweep, and it must work +// even while a stranger is provisioning from the very same /24. +func TestUpgradeTokenChain_ValidPriorTokenBindsAllResources(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + sharedIP := testhelpers.FingerprintToIP(testhelpers.UniqueFingerprint(t)) + + first := provisionBehindNAT(t, app, sharedIP, "alice-1", nil) + // A stranger on the same /24, provisioning in the middle of Alice's + // session. Nothing about Alice's chain may pick this up. + stranger := provisionBehindNAT(t, app, sharedIP, "stranger", nil) + second := provisionBehindNAT(t, app, sharedIP, "alice-2", chainHeader(first.JWT)) + third := provisionBehindNAT(t, app, sharedIP, "alice-3", chainHeader(second.JWT)) + defer dropResources(db, first.Token, second.Token, third.Token, stranger.Token) + + chained := jwtTokens(t, third.JWT) + assert.ElementsMatch(t, []string{third.Token, second.Token, first.Token}, chained, + "a two-hop chain must accumulate exactly the caller's own three tokens") + assert.NotContains(t, chained, stranger.Token, + "chaining must never absorb a same-/24 stranger's token") + + // The whole bundle claims in one call. + require.Equal(t, http.StatusCreated, claimAs(t, app, third.JWT)) + + owner := ownerTeamID(t, db, third.Token) + require.NotEmpty(t, owner) + assert.Equal(t, owner, ownerTeamID(t, db, first.Token), + "the first resource in the chain must bind to the same team") + assert.Equal(t, owner, ownerTeamID(t, db, second.Token), + "the middle resource in the chain must bind to the same team") + assert.Empty(t, ownerTeamID(t, db, stranger.Token), + "the stranger's resource must remain unowned") +} + +// TestUpgradeTokenChain_ClaimPreviewShowsTheWholeChain keeps the preview +// honest in the other direction: now that the chain is what bundles services, +// the preview must promise the whole chain — no more, no less. +func TestUpgradeTokenChain_ClaimPreviewShowsTheWholeChain(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + sharedIP := testhelpers.FingerprintToIP(testhelpers.UniqueFingerprint(t)) + first := provisionBehindNAT(t, app, sharedIP, "alice-1", nil) + stranger := provisionBehindNAT(t, app, sharedIP, "stranger", nil) + second := provisionBehindNAT(t, app, sharedIP, "alice-2", chainHeader(first.JWT)) + defer dropResources(db, first.Token, second.Token, stranger.Token) + + resp := testhelpers.GetReq(t, app, "/claim/preview?t="+second.JWT) + var preview struct { + Items []struct { + Token string `json:"token"` + } `json:"items"` + } + testhelpers.DecodeJSON(t, resp, &preview) + + promised := make([]string, 0, len(preview.Items)) + for _, it := range preview.Items { + promised = append(promised, it.Token) + } + assert.ElementsMatch(t, []string{first.Token, second.Token}, promised, + "the preview must promise the whole chain and nothing outside it") + + require.Equal(t, http.StatusCreated, claimAs(t, app, second.JWT)) + assert.NotEmpty(t, ownerTeamID(t, db, first.Token)) + assert.NotEmpty(t, ownerTeamID(t, db, second.Token)) + assert.Empty(t, ownerTeamID(t, db, stranger.Token)) +} + +// TestUpgradeTokenChain_BadPriorTokenDegradesButNeverFails is the degradation +// contract. Every unusable prior token must leave the provision succeeding +// with a single-token JWT — never a 4xx, never a 5xx, and never a silent +// fallback to the fingerprint. +func TestUpgradeTokenChain_BadPriorTokenDegradesButNeverFails(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + // Each subtest gets its OWN shared /24 so it stays under the 5/day + // per-fingerprint provisioning cap, while the degraded caller still sits + // in the SAME fingerprint bucket as its victim — which is what makes + // "must not fall back to the fingerprint" a real assertion rather than a + // vacuous one. + cases := []struct { + name string + // prior returns the hostile header value. victim is a genuine, + // already-provisioned caller in the same fingerprint bucket. + prior func(t *testing.T, victim natCaller) string + }{ + { + // Flip one character of the payload segment: the signature no + // longer covers the bytes presented. + name: "tampered_payload", + prior: func(_ *testing.T, victim natCaller) string { + b := []byte(victim.JWT) + for i, ch := range b { + if ch == '.' { + if b[i+1] == 'e' { + b[i+1] = 'f' + } else { + b[i+1] = 'e' + } + break + } + } + return string(b) + }, + }, + { + name: "expired", + prior: func(t *testing.T, victim natCaller) string { + return mintPriorToken(t, victim, testhelpers.TestJWTSecret, time.Now().Add(-time.Second)) + }, + }, + { + name: "wrong_signature", + prior: func(t *testing.T, victim natCaller) string { + return mintPriorToken(t, victim, "an_entirely_different_secret_at_least_32b!", + time.Now().Add(time.Hour)) + }, + }, + { + name: "not_a_jwt", + prior: func(*testing.T, natCaller) string { return "definitely-not-a-jwt" }, + }, + { + // TrimSpace makes this indistinguishable from "no header at all": + // the safe default, reached without a verification attempt. + name: "whitespace_only", + prior: func(*testing.T, natCaller) string { return " " }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sharedIP := testhelpers.FingerprintToIP(testhelpers.UniqueFingerprint(t)) + victim := provisionBehindNAT(t, app, sharedIP, "victim", nil) + defer dropResources(db, victim.Token) + + // provisionBehindNAT already require()s HTTP 201 — an unusable + // prior token must not turn a provision into an error. + got := provisionBehindNAT(t, app, sharedIP, "degraded", chainHeader(tc.prior(t, victim))) + defer dropResources(db, got.Token) + + tokens := jwtTokens(t, got.JWT) + assert.Equal(t, []string{got.Token}, tokens, + "an unusable prior token must degrade to THIS request's token only") + assert.NotContains(t, tokens, victim.Token, + "degradation must not fall back to the fingerprint — that is the bug being fixed") + }) + } +} + +// mintPriorToken signs a prior-upgrade-token candidate naming the victim's +// resource, with a caller-chosen key and expiry, so each hostile variant +// differs from a working token in exactly one way. +func mintPriorToken(t *testing.T, victim natCaller, secret string, exp time.Time) string { + t.Helper() + signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, crypto.OnboardingClaims{ + Tokens: []string{victim.Token}, + RegisteredClaims: jwt.RegisteredClaims{ + ID: "prior-" + victim.Token, + IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)), + ExpiresAt: jwt.NewNumericDate(exp), + }, + }).SignedString([]byte(secret)) + require.NoError(t, err) + return signed +} + +// TestUpgradeTokenChain_PriorTokenDoesNotLaunderAnotherCallersToken closes the +// obvious attack on the new mechanism: a stranger who somehow forges or +// re-signs a token naming a victim's resource must gain nothing, because the +// signature check is the whole gate — and even a correctly signed token +// naming a resource owned by someone else is skipped at claim time. +func TestUpgradeTokenChain_PriorTokenDoesNotLaunderAnotherCallersToken(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + sharedIP := testhelpers.FingerprintToIP(testhelpers.UniqueFingerprint(t)) + victim := provisionBehindNAT(t, app, sharedIP, "victim", nil) + defer dropResources(db, victim.Token) + + // The attacker signs a prior token naming the victim's resource with a + // key it does not have. The chain must reject it outright. + forged, err := jwt.NewWithClaims(jwt.SigningMethodHS256, crypto.OnboardingClaims{ + Tokens: []string{victim.Token}, + RegisteredClaims: jwt.RegisteredClaims{ + ID: "forged", + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + }).SignedString([]byte("attacker_key_that_is_not_the_platform_key")) + require.NoError(t, err) + + attacker := provisionBehindNAT(t, app, sharedIP, "attacker", chainHeader(forged)) + defer dropResources(db, attacker.Token) + + assert.Equal(t, []string{attacker.Token}, jwtTokens(t, attacker.JWT), + "a forged prior token must contribute nothing") + + // The victim claims first — the resource now belongs to the victim's team. + require.Equal(t, http.StatusCreated, claimAs(t, app, victim.JWT)) + victimTeam := ownerTeamID(t, db, victim.Token) + require.NotEmpty(t, victimTeam) + + require.Equal(t, http.StatusCreated, claimAs(t, app, attacker.JWT)) + assert.Equal(t, victimTeam, ownerTeamID(t, db, victim.Token), + "the victim's resource must stay with the victim's team") +} diff --git a/internal/handlers/vector.go b/internal/handlers/vector.go index 8ea5c1be..3d5f4174 100644 --- a/internal/handlers/vector.go +++ b/internal/handlers/vector.go @@ -286,7 +286,7 @@ func (h *VectorHandler) NewVector(c *fiber.Ctx) error { return h.denyProvisionOverCap(c, fp, models.ResourceTypeVector) } if lookupErr == nil { - jwtToken, jti, jwtErr := h.issueOnboardingJWT(ctx, fp, country, vendor, models.ResourceTypeVector, []string{existing.Token.String()}) + jwtToken, jti, jwtErr := h.issueOnboardingJWT(c, fp, country, vendor, models.ResourceTypeVector, []string{existing.Token.String()}) if jwtErr == nil && jti != "" { if evErr := h.createOnboardingEvent(ctx, fp, jti, existing.Token); evErr != nil { slog.Error("vector.new.onboarding_event_failed_limit_path", "error", evErr, "request_id", requestID) @@ -377,7 +377,7 @@ func (h *VectorHandler) NewVector(c *fiber.Ctx) error { return respondProvisionFailed(c, finErr, "Failed to persist vector resource") } - jwtToken, jti, jwtErr := h.issueOnboardingJWT(ctx, fp, country, vendor, models.ResourceTypeVector, []string{tokenStr}) + jwtToken, jti, jwtErr := h.issueOnboardingJWT(c, fp, country, vendor, models.ResourceTypeVector, []string{tokenStr}) if jwtErr != nil { slog.Error("vector.new.jwt_issue_failed", "error", jwtErr, "request_id", requestID) } diff --git a/internal/handlers/webhook.go b/internal/handlers/webhook.go index d8048bd4..6da587d4 100644 --- a/internal/handlers/webhook.go +++ b/internal/handlers/webhook.go @@ -260,7 +260,7 @@ func (h *WebhookHandler) NewWebhook(c *fiber.Ctx) error { return h.denyProvisionOverCap(c, fp, "webhook") } if err == nil { - jwtToken, jti, jwtErr := h.issueOnboardingJWT(ctx, fp, country, vendor, "webhook", []string{existing.Token.String()}) + jwtToken, jti, jwtErr := h.issueOnboardingJWT(c, fp, country, vendor, "webhook", []string{existing.Token.String()}) if jwtErr == nil && jti != "" { if evErr := h.createOnboardingEvent(ctx, fp, jti, existing.Token); evErr != nil { slog.Error("webhook.new.onboarding_event_failed_limit_path", "error", evErr, "request_id", requestID) @@ -340,7 +340,7 @@ func (h *WebhookHandler) NewWebhook(c *fiber.Ctx) error { return respondProvisionFailed(c, finErr, "Failed to persist webhook resource") } - jwtToken, jti, jwtErr := h.issueOnboardingJWT(ctx, fp, country, vendor, "webhook", []string{tokenStr}) + jwtToken, jti, jwtErr := h.issueOnboardingJWT(c, fp, country, vendor, "webhook", []string{tokenStr}) if jwtErr != nil { slog.Error("webhook.new.jwt_issue_failed", "error", jwtErr, "request_id", requestID) } diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 01974aee..0c7a2ae7 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -102,6 +102,27 @@ var ( Help: "Recycle-gate 402s by claim-JWT recovery outcome (minted/mint_failed). F1/F7.", }, []string{"result"}) + // UpgradeTokenChain counts anonymous provisions that presented a prior + // onboarding token on the X-Instant-Upgrade-Token request header, by + // verification outcome. This is the capability that replaced the network + // fingerprint as the basis for multi-service claim bundling (SEC: + // claim-by-fingerprint, 2026-08-13). result: + // "accepted" — signature verified; the prior token list was carried + // forward into the newly issued onboarding JWT. + // "rejected" — absent-but-nonempty / malformed / expired / wrong-key + // header. The provision still succeeds, degraded to this + // request's own token only. A sustained "rejected" rate is + // either a broken client or someone probing the chain. + // "truncated" — the chain exceeded maxChainedUpgradeTokens and the + // overflow was dropped. + // Lazy *Vec — no series at /metrics until the first chained provision. + // The Prom rule + NR tile for this counter are owned by the infra agent + // (rule 25); see the PR report for the exact follow-up. + UpgradeTokenChain = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "instant_upgrade_token_chain_total", + Help: "Anonymous provisions presenting a prior onboarding token, by verification outcome (accepted/rejected/truncated).", + }, []string{"result"}) + // ConversionFunnel counts conversion funnel steps: // provision, jwt_issued, landing_viewed, claimed, paid. ConversionFunnel = promauto.NewCounterVec(prometheus.CounterOpts{ diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go index f0337b95..2a56b73b 100644 --- a/internal/metrics/metrics_test.go +++ b/internal/metrics/metrics_test.go @@ -113,6 +113,9 @@ func TestAllMetricsRegistered(t *testing.T) { RazorpayWebhookSigFail.Inc() RecycleClaimRecovery.WithLabelValues("minted").Inc() RecycleClaimRecovery.WithLabelValues("mint_failed").Inc() + UpgradeTokenChain.WithLabelValues("accepted").Inc() + UpgradeTokenChain.WithLabelValues("rejected").Inc() + UpgradeTokenChain.WithLabelValues("truncated").Inc() PGPoolInUse.WithLabelValues("platform_db").Set(3) PGPoolIdle.WithLabelValues("platform_db").Set(2) PGPoolOpen.WithLabelValues("platform_db").Set(5) diff --git a/internal/testhelpers/testhelpers.go b/internal/testhelpers/testhelpers.go index b39b5382..f1538c0e 100644 --- a/internal/testhelpers/testhelpers.go +++ b/internal/testhelpers/testhelpers.go @@ -910,7 +910,7 @@ func LastBulkTwinHandler() *handlers.BulkTwinHandler { // NewTestApp creates a Fiber app wired to the provided DB and Redis clients // using the same handler/middleware chain as production (minus GeoIP lookup). -// Routes registered: POST /cache/new, GET /start, POST /claim, /api/v1/resources. +// Routes registered: POST /cache/new, GET /start, POST /claim, GET /claim/preview, /api/v1/resources. // Only the "redis" service is enabled. Use NewTestAppWithServices to enable others. // provisioningNamePaths is the set of JSON provisioning endpoints where // `name` is now a STRICTLY REQUIRED field. injectDefaultProvisionName @@ -1061,6 +1061,10 @@ func NewTestAppWithServices(t *testing.T, db *sql.DB, rdb *redis.Client, service app.Get("/start", onboardH.StartLanding) app.Post("/claim", onboardH.Claim) + // /claim/preview is registered alongside /claim on purpose: the + // preview-equals-claim invariant (onboarding.go) can only be asserted + // end-to-end if both live on the same app with the same DB. + app.Get("/claim/preview", onboardH.ClaimPreview) app.Get("/auth/me", middleware.RequireAuth(cfg), cliAuthH.GetCurrentUser) // Wave 3 P2 (BugHunt 2026-05-20): mirror production routes that the