From e12351e0f451762c238d381d5a95b2467ffa421e Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Fri, 24 Jul 2026 08:37:40 +0200 Subject: [PATCH] Require verified certs for portal redirect hosts AddCustomDomain only validates the domain's format before inserting the row; certificate issuance then runs asynchronously. Every host that row resolved to was accepted by the OIDC, magic-link, and compliance-portal OAuth `continue` redirect allowlists, so anyone could self-register an org, claim an arbitrary domain, and have users redirected there right after a real login. Found while re-checking GHSA-r9mf-88r7-g6j9 against the compliance portal rework: the original session-transfer leak is gone, but this open redirect on the same allowlist was not. Gate those allowlists on the domain's certificate having reached Active or Renewing status, which only happens once DNS has pointed at Probo's edge and an ACME challenge has actually succeeded. Signed-off-by: Bryan Frimin --- .../management/domain_service.go | 18 ++++++++++++++++++ pkg/complianceportal/visitor/service.go | 14 ++++++++++++++ pkg/server/api/api.go | 4 +--- pkg/server/api/complianceportal/v1/mux.go | 3 +-- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/pkg/complianceportal/management/domain_service.go b/pkg/complianceportal/management/domain_service.go index 5cbb4ef31..0d272eba6 100644 --- a/pkg/complianceportal/management/domain_service.go +++ b/pkg/complianceportal/management/domain_service.go @@ -140,6 +140,24 @@ func (s *Service) RemoveCustomDomain( ) } +func (s *Service) IsCustomDomainVerified(ctx context.Context, host string) (bool, error) { + certificate, err := s.certManager.GetByHostname(ctx, host) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return false, nil + } + + return false, fmt.Errorf("cannot load certificate: %w", err) + } + + switch certificate.Status { + case coredata.CertificateStatusActive, coredata.CertificateStatusRenewing: + return true, nil + default: + return false, nil + } +} + // GetDomain returns a custom domain by ID. func (s *Service) GetDomain( ctx context.Context, diff --git a/pkg/complianceportal/visitor/service.go b/pkg/complianceportal/visitor/service.go index d101b6a8a..803fa4632 100644 --- a/pkg/complianceportal/visitor/service.go +++ b/pkg/complianceportal/visitor/service.go @@ -220,6 +220,20 @@ func (s *Service) GetPortalByDomainName(ctx context.Context, domain string) (*co return compliancePage, err } +func (s *Service) IsVerifiedRedirectHost(ctx context.Context, host string) bool { + if _, err := s.GetPortalByDomainName(ctx, host); err != nil { + return false + } + + verified, err := s.management.IsCustomDomainVerified(ctx, host) + if err != nil { + s.logger.ErrorCtx(ctx, "cannot check custom domain verification", log.Error(err), log.String("host", host)) + return false + } + + return verified +} + // GetPortalEmailPresenterConfigByOrganizationID resolves the emails.PresenterConfig for // the compliance page that belongs to the given organization. This is used by the // esign certificate worker which needs per-org branding at render time. diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index 9a08f65ad..6b5e4a3db 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -257,9 +257,7 @@ func NewServer(cfg Config) (*Server, error) { return true } - _, err := cfg.Visitor.GetPortalByDomainName(ctx, host) - - return err == nil + return cfg.Visitor.IsVerifiedRedirectHost(ctx, host) }, cfg.GraphQLLimits, ), diff --git a/pkg/server/api/complianceportal/v1/mux.go b/pkg/server/api/complianceportal/v1/mux.go index 875a1f7c4..98c85a502 100644 --- a/pkg/server/api/complianceportal/v1/mux.go +++ b/pkg/server/api/complianceportal/v1/mux.go @@ -67,8 +67,7 @@ func NewMux(cfg MuxConfig) (http.Handler, error) { r.Get("/sitemap.xml", markdownHandler.HandleSitemap) allowedHost := func(ctx context.Context, host string) bool { - _, err := cfg.Visitor.GetPortalByDomainName(ctx, host) - return err == nil + return cfg.Visitor.IsVerifiedRedirectHost(ctx, host) } oauthInitiateHandler := NewOAuthInitiateHandler(