diff --git a/e2e/loadtest_harness_test.go b/e2e/loadtest_harness_test.go index 1f71896..ec58c0e 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 3a40376..553327a 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 0000000..12eccb1 --- /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 dc7defb..366c3f8 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 ef74801..ec356ea 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 f393b1f..c6d241e 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 ac23bee..c99690a 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 1697f29..cd8bc50 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 7847b1e..514b635 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 c5d3472..d46910b 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 c8c3ba8..711addb 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 0000000..efb37af --- /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 0000000..eeb791c --- /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 8ea5c1b..3d5f417 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 d8048bd..6da587d 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 01974ae..0c7a2ae 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 f0337b9..2a56b73 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 b39b538..f1538c0 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