From 113ad7ed9b5bd5d5bcb29ba34e4a1cbcd6dfb06a Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 30 Jul 2026 13:09:42 +0700 Subject: [PATCH] fix(session): block challenged session generations --- docs/EXTENSION_DEVELOPMENT.md | 7 +- go_backend/extension_signed_session.go | 82 ++++++++++++++++++--- go_backend/extension_signed_session_test.go | 82 ++++++++++++++++++++- 3 files changed, 158 insertions(+), 13 deletions(-) diff --git a/docs/EXTENSION_DEVELOPMENT.md b/docs/EXTENSION_DEVELOPMENT.md index f85bd993..7c592769 100644 --- a/docs/EXTENSION_DEVELOPMENT.md +++ b/docs/EXTENSION_DEVELOPMENT.md @@ -115,7 +115,12 @@ gateway session state for the exact `SESSION_INVALID/bootstrap_session` or applies bounded `Retry-After` handling to retryable `PROVIDER_UNAVAILABLE` responses. `403 REQUEST_AUTH_INVALID` preserves the session and only retries once when a newer session generation is already available. BYOA -reauthentication remains a separate provider action. +reauthentication remains a separate provider action. An active generation that +receives canonical `428 VERIFY_REQUIRED` is blocked in the shared coordinator, +so later requests join the same verification flow instead of hitting the +gateway repeatedly. Every retry is a newly signed request with a fresh +timestamp and nonce; `retryable: true` therefore also asserts that any ticket +attached to the operation is safe/idempotent for that retry. Do not use legacy spellings such as `display_name`, `types`, `permissions.network.domains`, or an object for `permissions.network`. diff --git a/go_backend/extension_signed_session.go b/go_backend/extension_signed_session.go index 26d6ab23..9e4a3794 100644 --- a/go_backend/extension_signed_session.go +++ b/go_backend/extension_signed_session.go @@ -28,7 +28,7 @@ const signedSessionRefreshSkew = time.Hour const ( signedSessionExchangeMaxAttempts = 3 signedSessionMaxRetryAfter = 5 * time.Minute - signedSessionMaxSessionRetries = 2 + signedSessionMaxSessionRetries = 1 signedSessionMaxProviderRetries = 2 signedSessionProviderRetryDelay = time.Second ) @@ -39,6 +39,7 @@ var ( signedSessionCoordinators sync.Map signedSessionRetryWait = time.Sleep signedSessionProviderWait = sleepRetry + signedSessionRequestNow = time.Now ) // signedSessionCoordinator serializes authentication state for every runtime @@ -53,6 +54,7 @@ type signedSessionCoordinator struct { challengeCreatedAt time.Time pendingExtensionIDs map[string]struct{} completedGrantHash string + blockedGeneration string } func (r *extensionRuntime) signedSessionCoordinator(config SignedSessionConfig) (*signedSessionCoordinator, error) { @@ -90,6 +92,27 @@ func (c *signedSessionCoordinator) activeChallenge() bool { time.Since(c.challengeCreatedAt) < pendingAuthRequestTTL } +func signedSessionGeneration(record *signedSessionRecord) string { + if record == nil || record.SessionID == "" || record.SessionSecret == "" { + return "" + } + sum := sha256.Sum256([]byte(record.SessionID + "\n" + record.SessionSecret)) + return hex.EncodeToString(sum[:]) +} + +func (c *signedSessionCoordinator) blockGeneration(record *signedSessionRecord) { + c.blockedGeneration = signedSessionGeneration(record) +} + +func (c *signedSessionCoordinator) generationIsBlocked(record *signedSessionRecord) bool { + generation := signedSessionGeneration(record) + return generation != "" && generation == c.blockedGeneration +} + +func (c *signedSessionCoordinator) clearBlockedGeneration() { + c.blockedGeneration = "" +} + type signedSessionRecord struct { InstallID string `json:"install_id"` SessionID string `json:"session_id,omitempty"` @@ -385,7 +408,8 @@ func (r *extensionRuntime) preflightSignedSession() (bool, error) { if err != nil { return false, err } - if signedSessionRecordIsUsable(record) { + if signedSessionRecordIsUsable(record) && + !coordinator.generationIsBlocked(record) { return false, nil } @@ -423,14 +447,16 @@ func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Valu if err != nil { return r.vm.ToValue(map[string]any{"authenticated": false, "error": err.Error()}) } - authenticated := signedSessionRecordIsUsable(record) + blocked := coordinator.generationIsBlocked(record) + authenticated := signedSessionRecordIsUsable(record) && !blocked return r.vm.ToValue(map[string]any{ - "authenticated": authenticated, - "expires_at": record.ExpiresAt, - "install_id": record.InstallID, - "session_id": record.SessionID, - "app_version": config.AppVersion, - "platform": config.Platform, + "authenticated": authenticated, + "verification_required": blocked, + "expires_at": record.ExpiresAt, + "install_id": record.InstallID, + "session_id": record.SessionID, + "app_version": config.AppVersion, + "platform": config.Platform, }) } @@ -452,6 +478,7 @@ func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value if err := r.saveSignedSession(config, record); err != nil { return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()}) } + coordinator.clearBlockedGeneration() coordinator.clearChallenge() ClearPendingAuthRequest(r.extensionID) return r.vm.ToValue(map[string]any{"success": true}) @@ -510,6 +537,7 @@ func (r *extensionRuntime) exchangeSignedSessionGrantLocked( // instead of consuming the grant again. if coordinator.completedGrantHash == grantHash && signedSessionRecordIsUsable(record) { + coordinator.clearBlockedGeneration() coordinator.clearChallenge() return nil } @@ -584,6 +612,7 @@ func (r *extensionRuntime) exchangeSignedSessionGrantLocked( return err } coordinator.completedGrantHash = grantHash + coordinator.clearBlockedGeneration() coordinator.clearChallenge() return nil } @@ -631,6 +660,33 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value } return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()}) } + if coordinator.generationIsBlocked(record) { + authURL, verificationErr := r.startSignedSessionVerificationLocked( + config, + coordinator, + "signed-fetch-blocked-generation", + ) + if authURL != "" { + coordinator.mu.Unlock() + return r.signedSessionVerificationRequiredValue(authURL) + } + if verificationErr != nil { + coordinator.mu.Unlock() + return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()}) + } + record, err = r.loadSignedSession(config) + if err != nil { + coordinator.mu.Unlock() + return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()}) + } + if !signedSessionRecordIsUsable(record) || coordinator.generationIsBlocked(record) { + coordinator.mu.Unlock() + return r.vm.ToValue(map[string]any{ + "ok": false, + "error": "verification_required: signed-session generation is blocked", + }) + } + } coordinator.mu.Unlock() // A request that loses a race with a successful grant exchange may return a @@ -734,6 +790,7 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value // VERIFY_REQUIRED is an explicit challenge request, not a revocation. // Only SESSION_INVALID is allowed to clear the current gateway session. if gatewayAction == "bootstrap_session" && sameSignedSession(latest, record) { + coordinator.clearBlockedGeneration() latest.SessionID = "" latest.SessionSecret = "" latest.ExpiresAt = "" @@ -741,6 +798,10 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value coordinator.mu.Unlock() return r.vm.ToValue(map[string]any{"ok": false, "error": saveErr.Error()}) } + } else if gatewayAction == "verify" && sameSignedSession(latest, record) { + // Stop subsequent requests from repeatedly hitting the gateway with + // a generation that is known to require human verification. + coordinator.blockGeneration(record) } authURL, verificationErr := r.startSignedSessionVerificationLocked( config, @@ -999,6 +1060,7 @@ func (r *extensionRuntime) startSignedSessionVerificationLocked( if err := r.saveSignedSession(config, record); err != nil { return "", fmt.Errorf("save bootstrapped signed session: %w", err) } + coordinator.clearBlockedGeneration() coordinator.clearChallenge() return "", nil } @@ -1082,7 +1144,7 @@ func (r *extensionRuntime) doSignedSessionRequest( if err != nil { return nil, nil, nil, err } - ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + ts := signedSessionRequestNow().UTC().Format("2006-01-02T15:04:05.000Z") nonce := randomHex(12) bodyHashBytes := sha256.Sum256(body) bodyHash := hex.EncodeToString(bodyHashBytes[:]) diff --git a/go_backend/extension_signed_session_test.go b/go_backend/extension_signed_session_test.go index 61d96c3c..ad28466a 100644 --- a/go_backend/extension_signed_session_test.go +++ b/go_backend/extension_signed_session_test.go @@ -1103,7 +1103,8 @@ func TestSignedSessionFetchCanonicalVerifyDoesNotClearSession(t *testing.T) { calls := 0 transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { calls++ - if strings.Contains(req.URL.Path, "/bootstrap") { + switch { + case strings.Contains(req.URL.Path, "/bootstrap"): payload, _ := json.Marshal(signedSessionExchangeResponse{ AuthURL: "https://auth.example.com/verify", }) @@ -1113,6 +1114,25 @@ func TestSignedSessionFetchCanonicalVerifyDoesNotClearSession(t *testing.T) { Body: io.NopCloser(strings.NewReader(string(payload))), Request: req, }, nil + case strings.Contains(req.URL.Path, "/session/exchange"): + payload, _ := json.Marshal(signedSessionExchangeResponse{ + SessionID: "sess-after-verify", + SessionSecret: "secret-after-verify", + ExpiresAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(string(payload))), + Request: req, + }, nil + case req.Header.Get("X-Sig-Session") == "sess-after-verify": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), + Request: req, + }, nil } return &http.Response{ StatusCode: http.StatusPreconditionRequired, @@ -1146,6 +1166,36 @@ func TestSignedSessionFetchCanonicalVerifyDoesNotClearSession(t *testing.T) { if reloaded.SessionID != "sess-verified" || reloaded.SessionSecret != "secret-sess-verified" { t.Fatalf("VERIFY_REQUIRED cleared the valid gateway session: %+v", reloaded) } + + blockedResult := runtime.signedSessionFetch(call).Export().(map[string]any) + if blockedResult["needsVerification"] != true { + t.Fatalf("blocked generation did not join verification: %+v", blockedResult) + } + if calls != 2 { + t.Fatalf("blocked generation reached the gateway again: calls=%d", calls) + } + verificationRequired, err := runtime.preflightSignedSession() + if err != nil || !verificationRequired { + t.Fatalf("preflight did not preserve the blocked verification state: required=%v err=%v", verificationRequired, err) + } + if calls != 2 { + t.Fatalf("blocked preflight reached the gateway again: calls=%d", calls) + } + status := runtime.signedSessionStatus(goja.FunctionCall{}).Export().(map[string]any) + if status["authenticated"] != false || status["verification_required"] != true { + t.Fatalf("blocked generation status is incorrect: %+v", status) + } + + if err := runtime.exchangeSignedSessionGrant("grant-after-verify"); err != nil { + t.Fatalf("exchange grant: %v", err) + } + afterVerification := runtime.signedSessionFetch(call).Export().(map[string]any) + if afterVerification["ok"] != true { + t.Fatalf("new verified generation remained blocked: %+v", afterVerification) + } + if calls != 4 { + t.Fatalf("network calls = %d, want 428, bootstrap, exchange, success", calls) + } } func TestSignedSessionFetchRequestAuthInvalidPreservesSession(t *testing.T) { @@ -1319,16 +1369,32 @@ func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) { t.Run("temporary provider failure retries with Retry-After", func(t *testing.T) { previousWait := signedSessionProviderWait + previousNow := signedSessionRequestNow var waits []time.Duration signedSessionProviderWait = func(_ context.Context, delay time.Duration) error { waits = append(waits, delay) return nil } - t.Cleanup(func() { signedSessionProviderWait = previousWait }) + nextRequestTime := time.Date(2026, time.July, 30, 12, 0, 0, 0, time.UTC) + signedSessionRequestNow = func() time.Time { + current := nextRequestTime + nextRequestTime = nextRequestTime.Add(time.Second) + return current + } + t.Cleanup(func() { + signedSessionProviderWait = previousWait + signedSessionRequestNow = previousNow + }) calls := 0 + nonces := make(map[string]struct{}) + timestamps := make(map[string]struct{}) + signatures := make(map[string]struct{}) transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { calls++ + nonces[req.Header.Get("X-Sig-Nonce")] = struct{}{} + timestamps[req.Header.Get("X-Sig-Timestamp")] = struct{}{} + signatures[req.Header.Get("X-Sig-Signature")] = struct{}{} if calls <= signedSessionMaxProviderRetries { return &http.Response{ StatusCode: http.StatusServiceUnavailable, @@ -1367,6 +1433,18 @@ func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) { t.Fatalf("Retry-After header was not authoritative: waits=%v", waits) } } + wantSignedAttempts := signedSessionMaxProviderRetries + 1 + if len(nonces) != wantSignedAttempts || + len(timestamps) != wantSignedAttempts || + len(signatures) != wantSignedAttempts { + t.Fatalf( + "provider retries reused signed request headers: nonces=%d timestamps=%d signatures=%d want=%d", + len(nonces), + len(timestamps), + len(signatures), + wantSignedAttempts, + ) + } reloaded, err := runtime.loadSignedSession(resolved) if err != nil { t.Fatal(err)