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:
@@ -15,6 +15,7 @@ require (
|
|||||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.24.0
|
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.24.0
|
||||||
github.com/riverqueue/river/rivertype v0.24.0
|
github.com/riverqueue/river/rivertype v0.24.0
|
||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.52.0
|
||||||
|
golang.org/x/net v0.54.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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=
|
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 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
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 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/net/publicsuffix"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -205,6 +206,9 @@ func (input PairingInput) ConsumerMetadata(sessionRevocation bool, appEnv string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ConsumerMetadata{}, errors.New("web base URL is invalid")
|
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) == "" {
|
if strings.TrimSpace(input.LocalTenantKey) == "" {
|
||||||
return ConsumerMetadata{}, errors.New("local tenant mapping is required")
|
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")
|
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 revision.SessionRevocation || revision.SecurityEventIssuer != "" || revision.SecurityEventConfigURL != "" {
|
||||||
if validatePublicIdentityURL(revision.SecurityEventIssuer, appEnv) != nil ||
|
if validatePublicIdentityURL(revision.SecurityEventIssuer, appEnv) != nil ||
|
||||||
validatePublicIdentityURL(revision.SecurityEventConfigURL, appEnv) != nil {
|
validatePublicIdentityURL(revision.SecurityEventConfigURL, appEnv) != nil {
|
||||||
@@ -289,6 +296,24 @@ func ValidateRevisionURLs(revision Revision, appEnv string) error {
|
|||||||
return nil
|
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 {
|
func isLoopbackHost(host string) bool {
|
||||||
if host == "localhost" {
|
if host == "localhost" {
|
||||||
return true
|
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) {
|
func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testing.T) {
|
||||||
draft, err := NewDraft(PairingInput{
|
draft, err := NewDraft(PairingInput{
|
||||||
AuthCenterURL: "https://auth.example.com", OnboardingCode: "onb1.must-never-be-persisted",
|
AuthCenterURL: "https://auth.example.com", OnboardingCode: "onb1.must-never-be-persisted",
|
||||||
@@ -91,7 +142,7 @@ func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testin
|
|||||||
func TestNewDraftAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
|
func TestNewDraftAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
|
||||||
localInput := PairingInput{
|
localInput := PairingInput{
|
||||||
AuthCenterURL: "http://localhost:18000", PublicBaseURL: "http://127.0.0.1:18089",
|
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{
|
secureInput := PairingInput{
|
||||||
AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com",
|
AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com",
|
||||||
|
|||||||
@@ -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/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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
|||||||
Reference in New Issue
Block a user