fix(identity): 拒绝跨站浏览器会话端点

统一认证使用 HttpOnly SameSite=Strict 会话 Cookie。配对时若 Public Base 与 Web Base 不属于同一 schemeful site,浏览器回调会丢失事务 Cookie。新增基于 Public Suffix List 的同站校验,并在运行时重建时重复门禁,防止旧持久化数据绕过。

验证:go test ./... -count=1(apps/api)
This commit is contained in:
2026-07-28 05:52:46 +08:00
parent 1b48a91af0
commit b51ecc5eed
5 changed files with 81 additions and 1 deletions
@@ -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
@@ -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",