Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 55 additions & 13 deletions admin/server/auth/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"

"github.com/coreos/go-oidc/v3/oidc"
Expand Down Expand Up @@ -166,26 +167,54 @@ func (a *Authenticator) authStart(w http.ResponseWriter, r *http.Request, signup
// Set state in cookie
sess.Values[cookieFieldState] = state

// Set redirect URL in cookie to enable custom redirects after auth has completed
host := originalHost(r)

// Parse custom_domain_flow early — needed to gate the DB lookup for redirect validation.
customDomainFlow := false
if b, err := strconv.ParseBool(r.URL.Query().Get("custom_domain_flow")); err == nil {
customDomainFlow = b
}
if customDomainFlow {
sess.Values[cookieFieldCustomDomainFlow] = true
}

// Validate and store the redirect URL.
redirect := r.URL.Query().Get("redirect")
if redirect != "" {
if !a.admin.URLs.IsSafeRedirectURL(redirect, host) {
// The redirect is not on a primary/canonical host and not on the request host.
// The only legitimate case is the server-generated second call in the custom
// domain login flow (canonical domain, custom_domain_flow=true), where the
// redirect points to <custom-domain>/auth/custom-domain-callback.
if !customDomainFlow {
http.Error(w, "invalid redirect parameter", http.StatusBadRequest)
return
}
parsed, _ := url.Parse(redirect)
_, err := a.admin.DB.FindOrganizationByCustomDomain(r.Context(), parsed.Host)
if errors.Is(err, database.ErrNotFound) {
http.Error(w, "invalid redirect parameter", http.StatusBadRequest)
return
} else if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Path must be the custom-domain callback endpoint (with or without /api prefix).
if !strings.HasSuffix(parsed.Path, "/auth/custom-domain-callback") {
http.Error(w, "invalid redirect parameter", http.StatusBadRequest)
return
}
}
sess.Values[cookieFieldRedirect] = redirect
}

// If this is part of the custom domain login flow, save that info in the cookie since we need that info when handling the auth callback.
customDomainFlow := r.URL.Query().Get("custom_domain_flow")
if b, err := strconv.ParseBool(customDomainFlow); err == nil && b {
sess.Values[cookieFieldCustomDomainFlow] = true
}

// Save cookie
if err := sess.Save(r, w); err != nil {
http.Error(w, fmt.Sprintf("failed to save session: %s", err), http.StatusInternalServerError)
return
}

// Redirect to <canonical-domain>/auth/login (custom domain flow)
host := originalHost(r)
if a.admin.URLs.IsCustomDomain(host) {
customCallbackURL := a.admin.URLs.WithCustomDomain(host).AuthCustomDomainCallback(state)
canonicalLoginURL := a.admin.URLs.AuthLogin(customCallbackURL, true)
Expand Down Expand Up @@ -569,8 +598,12 @@ func (a *Authenticator) authLogout(w http.ResponseWriter, r *http.Request) {
return
}

// Extract custom redirect destination (if any)
// Extract and validate custom redirect destination (if any).
redirect := r.URL.Query().Get("redirect")
if redirect != "" && !a.admin.URLs.IsSafeRedirectURL(redirect, originalHost(r)) {
http.Error(w, "invalid redirect parameter", http.StatusBadRequest)
return
}

// Redirect to authLogoutProvider (see its docstring below for details on why we do this).
http.Redirect(w, r, a.admin.URLs.AuthLogoutProvider(redirect), http.StatusTemporaryRedirect)
Expand All @@ -580,14 +613,23 @@ func (a *Authenticator) authLogout(w http.ResponseWriter, r *http.Request) {
// This is separated from authLogout to support orgs with custom domains where the auth token cookie must be cleared from the custom domain,
// but the redirect destination must be set in a cookie on the primary domain because the auth provider will redirect to authLogoutCallback on the primary domain.
func (a *Authenticator) authLogoutProvider(w http.ResponseWriter, r *http.Request) {
// Set custom redirect destination in cookie for when the logout flow is over (if any)
// Validate and store the custom redirect destination for when the logout flow is over (if any).
redirect := r.URL.Query().Get("redirect")
if redirect != "" {
// Update cookie
if !a.admin.URLs.IsSafeRedirectURL(redirect, "") {
// Not a primary host — check if it's a registered Rill custom domain.
parsed, _ := url.Parse(redirect)
_, err := a.admin.DB.FindOrganizationByCustomDomain(r.Context(), parsed.Host)
if errors.Is(err, database.ErrNotFound) {
http.Error(w, "invalid redirect parameter", http.StatusBadRequest)
return
} else if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
sess := a.cookies.Get(r, cookieName)
sess.Values[cookieFieldRedirect] = redirect

// Save cookie
if err := sess.Save(r, w); err != nil {
http.Error(w, fmt.Sprintf("failed to save session: %s", err), http.StatusInternalServerError)
return
Expand Down
33 changes: 33 additions & 0 deletions admin/urls.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,39 @@ func (u *URLs) WithCustomDomain(domain string) *URLs {
}
}

// IsSafeRedirectURL reports whether redirect is safe to redirect to after an auth flow.
// A redirect is safe when it is:
// - empty (caller defaults to the frontend URL)
// - a relative path (no scheme, no host); protocol-relative "//evil.com" and
// scheme-only "javascript:" / "data:" forms are rejected
// - an absolute URL whose host matches the primary external URL host, the primary
// frontend URL host, or the optionally supplied additionalHost (e.g. the custom
// domain of the current request)
func (u *URLs) IsSafeRedirectURL(redirect, additionalHost string) bool {
if redirect == "" {
return true
}
parsed, err := url.Parse(redirect)
if err != nil {
return false
}
// No host: safe only when there is also no scheme.
// This rejects javascript:, data:, mailto:, and //evil.com forms.
if parsed.Host == "" {
return parsed.Scheme == ""
}
// Absolute URL: host must match a trusted host.
externalURL, err := url.Parse(u.external)
if err == nil && strings.EqualFold(parsed.Host, externalURL.Host) {
return true
}
frontendURL, err := url.Parse(u.frontend)
if err == nil && strings.EqualFold(parsed.Host, frontendURL.Host) {
return true
}
return additionalHost != "" && strings.EqualFold(parsed.Host, additionalHost)
}

// WithCustomDomainFromRedirectURL attempts to infer a custom domain from a redirect URL.
// If it succeeds, it passes the custom domain to WithCustomDomain and returns the result.
// If it does not detect a custom domain in the redirect URL, or the redirect URL is invalid, it fails silently by returning itself unchanged.
Expand Down
Loading