diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index d44c4cb8a..7d77c1164 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -788,6 +788,7 @@ func (impl *Implm) Run( complianceportal_v1.MuxConfig{ BaseURL: baseURL, ExtraHeaderFields: impl.cfg.Api.ExtraHeaderFields, + AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins, Logger: l.Named("compliance-portal"), IAM: iamService, Visitor: visitorService, diff --git a/pkg/saferedirect/saferedirect.go b/pkg/saferedirect/saferedirect.go index cac0e9986..c607a2d60 100644 --- a/pkg/saferedirect/saferedirect.go +++ b/pkg/saferedirect/saferedirect.go @@ -55,6 +55,37 @@ func StaticHosts(hosts ...string) AllowedHostFunc { } } +// Origins returns an AllowedHost function that matches hosts extracted from +// absolute origin URLs (for example CORS allowed-origins). Invalid or empty +// origins are ignored. The host includes the port when present. +func Origins(origins ...string) AllowedHostFunc { + hosts := make([]string, 0, len(origins)) + for _, origin := range origins { + parsed, err := url.Parse(origin) + if err != nil || parsed.Host == "" { + continue + } + + hosts = append(hosts, parsed.Host) + } + + return StaticHosts(hosts...) +} + +// Any returns an AllowedHost function that allows a host when any of the +// provided functions allow it. Nil functions are skipped. +func Any(fns ...AllowedHostFunc) AllowedHostFunc { + return func(ctx context.Context, host string) bool { + for _, fn := range fns { + if fn != nil && fn(ctx, host) { + return true + } + } + + return false + } +} + func (sr *SafeRedirect) Validate(ctx context.Context, redirectURL string) (string, bool) { if redirectURL == "" { return "", false diff --git a/pkg/saferedirect/saferedirect_test.go b/pkg/saferedirect/saferedirect_test.go index 4928bf702..e18089ad0 100644 --- a/pkg/saferedirect/saferedirect_test.go +++ b/pkg/saferedirect/saferedirect_test.go @@ -439,3 +439,86 @@ func TestStaticHosts(t *testing.T) { } }) } + +func TestOrigins(t *testing.T) { + t.Parallel() + + t.Run("extracts hosts including ports", func(t *testing.T) { + t.Parallel() + + fn := saferedirect.Origins( + "http://localhost:5174", + "https://app.example.com", + ) + if !fn(context.Background(), "localhost:5174") { + t.Error("expected localhost:5174 to be allowed") + } + + if !fn(context.Background(), "app.example.com") { + t.Error("expected app.example.com to be allowed") + } + + if fn(context.Background(), "evil.com") { + t.Error("expected evil.com to be rejected") + } + }) + + t.Run("ignores invalid and empty origins", func(t *testing.T) { + t.Parallel() + + fn := saferedirect.Origins("", "not a url", "http://localhost:5173") + if !fn(context.Background(), "localhost:5173") { + t.Error("expected localhost:5173 to be allowed") + } + + if fn(context.Background(), "") { + t.Error("expected empty host to be rejected") + } + }) +} + +func TestAny(t *testing.T) { + t.Parallel() + + t.Run("allows when any function matches", func(t *testing.T) { + t.Parallel() + + fn := saferedirect.Any( + saferedirect.StaticHosts("a.com"), + saferedirect.StaticHosts("b.com"), + ) + if !fn(context.Background(), "a.com") { + t.Error("expected a.com to be allowed") + } + + if !fn(context.Background(), "b.com") { + t.Error("expected b.com to be allowed") + } + + if fn(context.Background(), "c.com") { + t.Error("expected c.com to be rejected") + } + }) + + t.Run("skips nil functions", func(t *testing.T) { + t.Parallel() + + fn := saferedirect.Any(nil, saferedirect.StaticHosts("example.com"), nil) + if !fn(context.Background(), "example.com") { + t.Error("expected example.com to be allowed") + } + + if fn(context.Background(), "other.com") { + t.Error("expected other.com to be rejected") + } + }) + + t.Run("rejects when no functions provided", func(t *testing.T) { + t.Parallel() + + fn := saferedirect.Any() + if fn(context.Background(), "example.com") { + t.Error("expected example.com to be rejected") + } + }) +} diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index d96f28ba8..decc0c5a9 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -21,7 +21,6 @@ package api import ( - "context" "errors" "fmt" "net/http" @@ -49,6 +48,7 @@ import ( "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/resourcealias" "go.probo.inc/probo/pkg/riskmanagement" + "go.probo.inc/probo/pkg/saferedirect" "go.probo.inc/probo/pkg/securecookie" agent_v1 "go.probo.inc/probo/pkg/server/api/agent/v1" connect_v1 "go.probo.inc/probo/pkg/server/api/connect/v1" @@ -262,13 +262,11 @@ func NewServer(cfg Config) (*Server, error) { cfg.TokenSecret, cfg.File, cfg.BaseURL, - func(ctx context.Context, host string) bool { - if host == cfg.BaseURL.Host() { - return true - } - - return cfg.Visitor.IsVerifiedRedirectHost(ctx, host) - }, + saferedirect.Any( + saferedirect.StaticHosts(cfg.BaseURL.Host()), + saferedirect.Origins(cfg.AllowedOrigins...), + cfg.Visitor.IsVerifiedRedirectHost, + ), cfg.GraphQLLimits, ), agentHandler: agent_v1.NewMux( diff --git a/pkg/server/api/complianceportal/v1/mux.go b/pkg/server/api/complianceportal/v1/mux.go index 98c85a502..89bcc9a27 100644 --- a/pkg/server/api/complianceportal/v1/mux.go +++ b/pkg/server/api/complianceportal/v1/mux.go @@ -15,7 +15,6 @@ package complianceportal_v1 import ( - "context" "net/http" "github.com/go-chi/chi/v5" @@ -27,6 +26,7 @@ import ( "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/resourcealias" + "go.probo.inc/probo/pkg/saferedirect" "go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/server" "go.probo.inc/probo/pkg/server/api/authn" @@ -37,6 +37,7 @@ import ( type MuxConfig struct { BaseURL *baseurl.BaseURL ExtraHeaderFields map[string]string + AllowedOrigins []string Logger *log.Logger IAM *iam.Service Visitor *visitor.Service @@ -66,9 +67,10 @@ func NewMux(cfg MuxConfig) (http.Handler, error) { r.Get("/robots.txt", markdownHandler.HandleRobotsTxt) r.Get("/sitemap.xml", markdownHandler.HandleSitemap) - allowedHost := func(ctx context.Context, host string) bool { - return cfg.Visitor.IsVerifiedRedirectHost(ctx, host) - } + allowedHost := saferedirect.Any( + saferedirect.Origins(cfg.AllowedOrigins...), + cfg.Visitor.IsVerifiedRedirectHost, + ) oauthInitiateHandler := NewOAuthInitiateHandler( cfg.BaseURL,