fix(auth): close OAuth open-redirect by wiring AllowedRedirectHosts (#2049)

* fix(auth): close OAuth open-redirect by wiring AllowedRedirectHosts

Bump go-pkgz/auth/v2 to master (v2.1.2-0.20260421203319-686683f19cf7)
which carries the `from` redirect validator from go-pkgz/auth#275.

The library default with a nil AllowedRedirectHosts is permissive
(preserves legacy behavior for existing consumers on a dep bump), so
just bumping the dep leaves remark42 vulnerable — a crafted
/auth/<provider>/login?from=https://evil.example.com/... still issues
the 307 to the attacker host after the user completes legitimate
OAuth. Verified end-to-end against a local dev-auth instance before
and after this commit.

Wire Opts.AllowedRedirectHosts in getAuthenticator to the operator's
existing --allowed-hosts config, stripping the CSP "self" sentinel
which is not a real hostname. RemarkURL's own host is always implicit
per the library contract, so a default single-site deployment gains
the protection with no config change. Multi-host embeds work as soon
as their embedding hosts are added to AllowedHosts (they already need
to be there for CSP frame-ancestors).

Refreshed vendor tree to match the new module version.

* chore(lint): suppress G703 false positives on image Save

CI's newer gosec flags os.MkdirAll/os.WriteFile in FileSystem.Save with
G703 because id flows in from the caller. id is validated at the HTTP
layer (safePictureSegment in rest_public.go) and dst is derived via
f.location — not a real traversal. Targeted //nolint with reason.

* fix(auth): normalise AllowedRedirectHosts entries + add unit test

Address Copilot review on PR #2049. The previous closure passed raw
s.AllowedHosts entries straight to the auth library, but --allowed-hosts
holds CSP frame-ancestors source expressions: scheme-prefixed values
(https://blog.example.com), entries with ports, and wildcards
(*.cdn.example.com) are all valid there but the auth library compares
against u.Hostname() and would silently drop them — breaking legitimate
redirects on multi-host deployments.

Extract getAllowedRedirectHosts that:
* trims whitespace, drops empty / 'self' / "self" / wildcard entries
* prepends https:// if scheme missing then url.Parse to extract Hostname
* logs a warning on parse failure rather than poisoning the allowlist

Wire the closure in getAuthenticator to call the helper.

Test_getAllowedRedirectHosts covers all the edge cases Copilot flagged
(scheme stripping, port handling, self spellings, wildcards, empty,
mixed real-world).

* fix(auth): preserve explicit port in AllowedRedirectHosts + clarify fs_store nolint

Address Copilot follow-up on PR #2049:

* getAllowedRedirectHosts stripped explicit ports via u.Hostname(), which
  broadened the allowlist. The auth validator checks both Hostname() and
  Host, so an entry like admin.example.com:8443 can and should be kept
  host:port — allowing only that port, not any. Emit u.Host when
  u.Port() != "", u.Hostname() otherwise. Updated tests.

* fs_store Save nolint rationale said "id validated at HTTP layer", but
  Save is reached via image.Service.Save and SaveWithID (cache), neither
  of which is HTTP validation. id is actually a server-generated hash in
  both paths. Updated the comment.
This commit is contained in:
Dmitry Verkhoturov
2026-04-21 19:09:26 -05:00
committed by GitHub
parent ee782785f0
commit c9ba8520c7
13 changed files with 274 additions and 60 deletions
+69 -52
View File
@@ -63,6 +63,15 @@ type Opts struct {
URL string // root url for the rest service, i.e. http://blah.example.com, required
Validator token.Validator // validator allows to reject some valid tokens with user-defined logic
// AllowedRedirectHosts lists hostnames accepted in the "from" query
// parameter of OAuth/verify login flows. Setting this field enables
// host validation: the host of URL is always implicit, and any other
// host must appear here. Nil (the default) disables validation and
// preserves legacy permissive behavior — any non-empty "from" value
// is honored. Hardening is opt-in; to restrict to the service host
// only, pass a getter returning an empty slice.
AllowedRedirectHosts token.AllowedHosts
AvatarStore avatar.Store // store to save/load avatars, required (use avatar.NoOp to disable avatars support)
AvatarResizeLimit int // resize avatar's limit in pixels
AvatarRoutePath string // avatar routing prefix, i.e. "/api/v1/avatar", default `/avatar`
@@ -227,14 +236,15 @@ func (s *Service) Middleware() middleware.Authenticator {
// AddProviderWithUserAttributes adds provider with user attributes mapping
func (s *Service) AddProviderWithUserAttributes(name, cid, csecret string, userAttributes provider.UserAttributes) {
p := provider.Params{
URL: s.opts.URL,
JwtService: s.jwtService,
Issuer: s.issuer,
AvatarSaver: s.avatarProxy,
Cid: cid,
Csecret: csecret,
L: s.logger,
UserAttributes: userAttributes,
URL: s.opts.URL,
JwtService: s.jwtService,
Issuer: s.issuer,
AvatarSaver: s.avatarProxy,
Cid: cid,
Csecret: csecret,
L: s.logger,
UserAttributes: userAttributes,
AllowedRedirectHosts: s.opts.AllowedRedirectHosts,
}
s.addProviderByName(name, p)
}
@@ -309,14 +319,15 @@ func (s *Service) isValidProviderName(name string) bool {
// AddProvider adds provider for given name
func (s *Service) AddProvider(name, cid, csecret string) {
p := provider.Params{
URL: s.opts.URL,
JwtService: s.jwtService,
Issuer: s.issuer,
AvatarSaver: s.avatarProxy,
Cid: cid,
Csecret: csecret,
L: s.logger,
UserAttributes: map[string]string{},
URL: s.opts.URL,
JwtService: s.jwtService,
Issuer: s.issuer,
AvatarSaver: s.avatarProxy,
Cid: cid,
Csecret: csecret,
L: s.logger,
UserAttributes: map[string]string{},
AllowedRedirectHosts: s.opts.AllowedRedirectHosts,
}
s.addProviderByName(name, p)
}
@@ -327,15 +338,16 @@ func (s *Service) AddProvider(name, cid, csecret string) {
// For advanced configuration (e.g., UserAttributes), construct provider.Params directly.
func (s *Service) AddMicrosoftProvider(cid, csecret, tenant string) {
p := provider.Params{
URL: s.opts.URL,
JwtService: s.jwtService,
Issuer: s.issuer,
AvatarSaver: s.avatarProxy,
Cid: cid,
Csecret: csecret,
L: s.logger,
UserAttributes: map[string]string{},
MicrosoftTenant: tenant,
URL: s.opts.URL,
JwtService: s.jwtService,
Issuer: s.issuer,
AvatarSaver: s.avatarProxy,
Cid: cid,
Csecret: csecret,
L: s.logger,
UserAttributes: map[string]string{},
MicrosoftTenant: tenant,
AllowedRedirectHosts: s.opts.AllowedRedirectHosts,
}
s.addProvider(provider.NewMicrosoft(p))
}
@@ -343,13 +355,14 @@ func (s *Service) AddMicrosoftProvider(cid, csecret, tenant string) {
// AddDevProvider with a custom host and port
func (s *Service) AddDevProvider(host string, port int) {
p := provider.Params{
URL: s.opts.URL,
JwtService: s.jwtService,
Issuer: s.issuer,
AvatarSaver: s.avatarProxy,
L: s.logger,
Port: port,
Host: host,
URL: s.opts.URL,
JwtService: s.jwtService,
Issuer: s.issuer,
AvatarSaver: s.avatarProxy,
L: s.logger,
Port: port,
Host: host,
AllowedRedirectHosts: s.opts.AllowedRedirectHosts,
}
s.addProvider(provider.NewDev(p))
}
@@ -357,11 +370,12 @@ func (s *Service) AddDevProvider(host string, port int) {
// AddAppleProvider allow SignIn with Apple ID
func (s *Service) AddAppleProvider(appleConfig provider.AppleConfig, privKeyLoader provider.PrivateKeyLoaderInterface) error {
p := provider.Params{
URL: s.opts.URL,
JwtService: s.jwtService,
Issuer: s.issuer,
AvatarSaver: s.avatarProxy,
L: s.logger,
URL: s.opts.URL,
JwtService: s.jwtService,
Issuer: s.issuer,
AvatarSaver: s.avatarProxy,
L: s.logger,
AllowedRedirectHosts: s.opts.AllowedRedirectHosts,
}
// error checking at create need for catch one when apple private key init
@@ -377,13 +391,14 @@ func (s *Service) AddAppleProvider(appleConfig provider.AppleConfig, privKeyLoad
// AddCustomProvider adds custom provider (e.g. https://gopkg.in/oauth2.v3)
func (s *Service) AddCustomProvider(name string, client Client, copts provider.CustomHandlerOpt) {
p := provider.Params{
URL: s.opts.URL,
JwtService: s.jwtService,
Issuer: s.issuer,
AvatarSaver: s.avatarProxy,
Cid: client.Cid,
Csecret: client.Csecret,
L: s.logger,
URL: s.opts.URL,
JwtService: s.jwtService,
Issuer: s.issuer,
AvatarSaver: s.avatarProxy,
Cid: client.Cid,
Csecret: client.Csecret,
L: s.logger,
AllowedRedirectHosts: s.opts.AllowedRedirectHosts,
}
s.addProvider(provider.NewCustom(name, p, copts))
}
@@ -421,14 +436,16 @@ func (s *Service) AddDirectProviderWithUserIDFunc(name string, credChecker provi
// AddVerifProvider adds provider user's verification sent by sender
func (s *Service) AddVerifProvider(name, msgTmpl string, sender provider.Sender) {
dh := provider.VerifyHandler{
L: s.logger,
ProviderName: name,
Issuer: s.issuer,
TokenService: s.jwtService,
AvatarSaver: s.avatarProxy,
Sender: sender,
Template: msgTmpl,
UseGravatar: s.useGravatar,
L: s.logger,
ProviderName: name,
Issuer: s.issuer,
TokenService: s.jwtService,
AvatarSaver: s.avatarProxy,
Sender: sender,
Template: msgTmpl,
UseGravatar: s.useGravatar,
URL: s.opts.URL,
AllowedRedirectHosts: s.opts.AllowedRedirectHosts,
}
s.addProvider(dh)
}
+5
View File
@@ -393,6 +393,11 @@ func (ah AppleHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {
// redirect to back url if presented in login query params
if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" {
if !isAllowedRedirect(oauthClaims.Handshake.From, ah.URL, ah.AllowedRedirectHosts) {
ah.Logf("[WARN] rejected redirect to disallowed host: %s", redirectHostForLog(oauthClaims.Handshake.From))
rest.RenderJSON(w, &u)
return
}
http.Redirect(w, r, oauthClaims.Handshake.From, http.StatusTemporaryRedirect)
return
}
+5
View File
@@ -163,6 +163,11 @@ func (h Oauth1Handler) AuthHandler(w http.ResponseWriter, r *http.Request) {
// redirect to back url if presented in login query params
if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" {
if !isAllowedRedirect(oauthClaims.Handshake.From, h.URL, h.AllowedRedirectHosts) {
h.Logf("[WARN] rejected redirect to disallowed host: %s", redirectHostForLog(oauthClaims.Handshake.From))
rest.RenderJSON(w, &u)
return
}
http.Redirect(w, r, oauthClaims.Handshake.From, http.StatusTemporaryRedirect)
return
}
+13
View File
@@ -42,6 +42,14 @@ type Params struct {
AvatarSaver AvatarSaver
UserAttributes UserAttributes
// AllowedRedirectHosts lists hostnames accepted in the "from" query
// parameter. Setting this field enables host validation: the host of
// URL is always implicit, and any other host must appear here. Nil
// disables validation and preserves legacy permissive behavior — any
// non-empty "from" value is honored. See isAllowedRedirect for the
// full policy.
AllowedRedirectHosts token.AllowedHosts
Port int // relevant for providers supporting port customization, for example dev oauth2
Host string // relevant for providers supporting host customization, for example dev oauth2
@@ -239,6 +247,11 @@ func (p Oauth2Handler) AuthHandler(w http.ResponseWriter, r *http.Request) {
// redirect to back url if presented in login query params
if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" {
if !isAllowedRedirect(oauthClaims.Handshake.From, p.URL, p.AllowedRedirectHosts) {
p.Logf("[WARN] rejected redirect to disallowed host: %s", redirectHostForLog(oauthClaims.Handshake.From))
rest.RenderJSON(w, &u)
return
}
http.Redirect(w, r, oauthClaims.Handshake.From, http.StatusTemporaryRedirect)
return
}
+69
View File
@@ -0,0 +1,69 @@
package provider
import (
"net/url"
"strings"
"github.com/go-pkgz/auth/v2/token"
)
// isAllowedRedirect reports whether the "from" URL is safe to redirect to
// after a successful auth handshake.
//
// The check is opt-in: when allowed is nil the function returns true for any
// non-empty input, preserving the behavior of versions before the redirect
// validator existed. This keeps a dependency bump from breaking existing
// consumers; hardening is enabled by setting Opts.AllowedRedirectHosts.
//
// When allowed is non-nil:
// - only http/https schemes are accepted
// - relative paths and unparseable URLs are rejected
// - the service's own host (derived from serviceURL) is always allowed
// - any other host must appear in the allowed list
//
// Hostname comparison is case-insensitive and ignores the port:
// https://app.example.com:443 and https://App.Example.Com are treated as the
// same host. Operators wanting strict port-aware checks should list each
// host:port form explicitly via AllowedHosts.
func isAllowedRedirect(from, serviceURL string, allowed token.AllowedHosts) bool {
// permissive default: no allowlist configured = legacy behavior.
// guard against typed-nil AllowedHostsFunc values (non-nil interface
// wrapping a nil func) to avoid panicking in Get().
if allowed == nil {
return from != ""
}
if fn, ok := allowed.(token.AllowedHostsFunc); ok && fn == nil {
return from != ""
}
u, err := url.Parse(from)
if err != nil || u.Hostname() == "" {
return false
}
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
fromHost := u.Hostname()
if svc, sErr := url.Parse(serviceURL); sErr == nil && svc.Hostname() != "" && strings.EqualFold(svc.Hostname(), fromHost) {
return true
}
hosts, hErr := allowed.Get()
if hErr != nil {
return false
}
for _, h := range hosts {
if strings.EqualFold(h, fromHost) || strings.EqualFold(h, u.Host) {
return true
}
}
return false
}
// redirectHostForLog extracts just the hostname from a from-URL for logging
// on rejection, so attacker-supplied paths/queries do not leak into operator
// logs. Returns a sentinel if the URL cannot be parsed.
func redirectHostForLog(from string) string {
if u, err := url.Parse(from); err == nil && u.Hostname() != "" {
return u.Hostname()
}
return "<unparseable>"
}
+15
View File
@@ -28,6 +28,16 @@ type VerifyHandler struct {
Sender Sender
Template string
UseGravatar bool
// URL is the service's own root URL; its host is always permitted as
// a "from" redirect target. Optional but recommended.
URL string
// AllowedRedirectHosts lists additional hostnames permitted as "from"
// redirect targets. Setting this field enables host validation: the
// host of URL is always implicit, and any other host must appear
// here. Nil disables validation and preserves legacy permissive
// behavior — any non-empty "from" value is honored.
AllowedRedirectHosts token.AllowedHosts
}
// Sender defines interface to send emails
@@ -127,6 +137,11 @@ func (e VerifyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
return
}
if confClaims.Handshake != nil && confClaims.Handshake.From != "" {
if !isAllowedRedirect(confClaims.Handshake.From, e.URL, e.AllowedRedirectHosts) {
e.Logf("[WARN] rejected redirect to disallowed host: %s", redirectHostForLog(confClaims.Handshake.From))
rest.RenderJSON(w, claims.User)
return
}
http.Redirect(w, r, confClaims.Handshake.From, http.StatusTemporaryRedirect)
return
}
+21 -1
View File
@@ -178,7 +178,7 @@ func (j *Service) Parse(tokenString string) (Claims, error) {
return Claims{}, fmt.Errorf("can't get secret: %w", err)
}
token, err := parser.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
token, err := parser.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
@@ -451,3 +451,23 @@ type AudienceFunc func() ([]string, error)
func (f AudienceFunc) Get() ([]string, error) {
return f()
}
// AllowedHosts defines interface returning list of hostnames allowed in the
// "from" redirect parameter of OAuth and verify flows. The service's own host
// (derived from Opts.URL) is always allowed implicitly; this list is for
// additional hosts.
type AllowedHosts interface {
Get() ([]string, error)
}
// AllowedHostsFunc adapter to allow ordinary functions to be used as AllowedHosts.
// Assigning a nil AllowedHostsFunc to an interface field (e.g.
// Opts.AllowedRedirectHosts) produces a typed-nil interface that would panic
// when Get is called; the provider-side validator recognizes this form and
// treats it as "no allowlist configured".
type AllowedHostsFunc func() ([]string, error)
// Get calls f()
func (f AllowedHostsFunc) Get() ([]string, error) {
return f()
}