From b51ecc5eedf2878b56c98d41bcf34451e7330c87 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Tue, 28 Jul 2026 05:52:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(identity):=20=E6=8B=92=E7=BB=9D=E8=B7=A8?= =?UTF-8?q?=E7=AB=99=E6=B5=8F=E8=A7=88=E5=99=A8=E4=BC=9A=E8=AF=9D=E7=AB=AF?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一认证使用 HttpOnly SameSite=Strict 会话 Cookie。配对时若 Public Base 与 Web Base 不属于同一 schemeful site,浏览器回调会丢失事务 Cookie。新增基于 Public Suffix List 的同站校验,并在运行时重建时重复门禁,防止旧持久化数据绕过。 验证:go test ./... -count=1(apps/api) --- apps/api/go.mod | 1 + apps/api/go.sum | 2 + apps/api/internal/identity/configuration.go | 25 +++++++++ .../internal/identity/configuration_test.go | 53 ++++++++++++++++++- go.work.sum | 1 + 5 files changed, 81 insertions(+), 1 deletion(-) diff --git a/apps/api/go.mod b/apps/api/go.mod index b51f472..cd2eb53 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -15,6 +15,7 @@ require ( github.com/riverqueue/river/riverdriver/riverpgxv5 v0.24.0 github.com/riverqueue/river/rivertype v0.24.0 golang.org/x/crypto v0.52.0 + golang.org/x/net v0.54.0 golang.org/x/oauth2 v0.36.0 ) diff --git a/apps/api/go.sum b/apps/api/go.sum index 1229fec..2f9a8e9 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -71,6 +71,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= diff --git a/apps/api/internal/identity/configuration.go b/apps/api/internal/identity/configuration.go index 5cb41e5..8fc0666 100644 --- a/apps/api/internal/identity/configuration.go +++ b/apps/api/internal/identity/configuration.go @@ -8,6 +8,7 @@ import ( "time" "github.com/google/uuid" + "golang.org/x/net/publicsuffix" ) var ( @@ -205,6 +206,9 @@ func (input PairingInput) ConsumerMetadata(sessionRevocation bool, appEnv string if err != nil { return ConsumerMetadata{}, errors.New("web base URL is invalid") } + if !sameSiteBaseURLs(publicBase, webBase) { + return ConsumerMetadata{}, errors.New("public base URL and web base URL must be same-site") + } if strings.TrimSpace(input.LocalTenantKey) == "" { return ConsumerMetadata{}, errors.New("local tenant mapping is required") } @@ -280,6 +284,9 @@ func ValidateRevisionURLs(revision Revision, appEnv string) error { return errors.New("identity " + candidate.label + " URL must use HTTPS in this environment") } } + if !sameSiteBaseURLs(revision.PublicBaseURL, revision.WebBaseURL) { + return errors.New("identity public base and web base URLs must be same-site") + } if revision.SessionRevocation || revision.SecurityEventIssuer != "" || revision.SecurityEventConfigURL != "" { if validatePublicIdentityURL(revision.SecurityEventIssuer, appEnv) != nil || validatePublicIdentityURL(revision.SecurityEventConfigURL, appEnv) != nil { @@ -289,6 +296,24 @@ func ValidateRevisionURLs(revision Revision, appEnv string) error { return nil } +func sameSiteBaseURLs(left, right string) bool { + leftURL, leftErr := url.Parse(left) + rightURL, rightErr := url.Parse(right) + if leftErr != nil || rightErr != nil || + !strings.EqualFold(leftURL.Scheme, rightURL.Scheme) { + return false + } + leftHost := strings.TrimSuffix(strings.ToLower(leftURL.Hostname()), ".") + rightHost := strings.TrimSuffix(strings.ToLower(rightURL.Hostname()), ".") + if leftHost == rightHost { + return true + } + leftDomain, leftErr := publicsuffix.EffectiveTLDPlusOne(leftHost) + rightDomain, rightErr := publicsuffix.EffectiveTLDPlusOne(rightHost) + return leftErr == nil && rightErr == nil && + strings.EqualFold(leftDomain, rightDomain) +} + func isLoopbackHost(host string) bool { if host == "localhost" { return true diff --git a/apps/api/internal/identity/configuration_test.go b/apps/api/internal/identity/configuration_test.go index f71eede..18bb07d 100644 --- a/apps/api/internal/identity/configuration_test.go +++ b/apps/api/internal/identity/configuration_test.go @@ -70,6 +70,57 @@ func TestValidatePairingInputRejectsRemoteHTTPAndURLCredentials(t *testing.T) { } } +func TestValidatePairingInputRejectsCrossSiteBrowserEndpoints(t *testing.T) { + for _, test := range []struct { + name string + appEnv string + input PairingInput + }{ + { + name: "different registrable domains", + appEnv: "production", + input: PairingInput{ + AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com", + WebBaseURL: "https://gateway.example.net", LocalTenantKey: "default", + }, + }, + { + name: "different loopback sites", + appEnv: "test", + input: PairingInput{ + AuthCenterURL: "http://127.0.0.1:18000", PublicBaseURL: "http://127.0.0.1:18088", + WebBaseURL: "http://localhost:15178", LocalTenantKey: "default", + }, + }, + { + name: "different schemes", + appEnv: "test", + input: PairingInput{ + AuthCenterURL: "http://127.0.0.1:18000", PublicBaseURL: "http://localhost:18088", + WebBaseURL: "https://localhost:15178", LocalTenantKey: "default", + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := test.input.ConsumerMetadata(false, test.appEnv); err == nil { + t.Fatalf("cross-site browser endpoints were accepted: %#v", test.input) + } + }) + } +} + +func TestValidateRevisionURLsRejectsCrossSiteBrowserEndpoints(t *testing.T) { + revision := Revision{ + AuthCenterURL: "https://auth.example.com", + Issuer: "https://auth.example.com/issuer/shared", + PublicBaseURL: "https://api.example.com", + WebBaseURL: "https://gateway.example.net", + } + if err := ValidateRevisionURLs(revision, "production"); err == nil { + t.Fatal("cross-site persisted browser endpoints were accepted") + } +} + func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testing.T) { draft, err := NewDraft(PairingInput{ AuthCenterURL: "https://auth.example.com", OnboardingCode: "onb1.must-never-be-persisted", @@ -91,7 +142,7 @@ func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testin func TestNewDraftAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) { localInput := PairingInput{ AuthCenterURL: "http://localhost:18000", PublicBaseURL: "http://127.0.0.1:18089", - WebBaseURL: "http://localhost:5178", LocalTenantKey: "default", + WebBaseURL: "http://127.0.0.1:5178", LocalTenantKey: "default", } secureInput := PairingInput{ AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com", diff --git a/go.work.sum b/go.work.sum index 197da72..54c0c1e 100644 --- a/go.work.sum +++ b/go.work.sum @@ -10,6 +10,7 @@ golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=