diff --git a/src/mod/auth/sso/forward/const.go b/src/mod/auth/sso/forward/const.go index 6902e58d..4c70e0f7 100644 --- a/src/mod/auth/sso/forward/const.go +++ b/src/mod/auth/sso/forward/const.go @@ -15,6 +15,7 @@ const ( DatabaseKeyRequestExcludedCookies = "requestExcludedCookies" DatabaseKeyRequestIncludeBody = "requestIncludeBody" DatabaseKeyUseXOriginalHeaders = "useXOriginalHeaders" + DatabaseKeyIgnoredPaths = "ignoredPaths" HeaderXForwardedProto = "X-Forwarded-Proto" HeaderXForwardedHost = "X-Forwarded-Host" diff --git a/src/mod/auth/sso/forward/forward.go b/src/mod/auth/sso/forward/forward.go index 368408d2..535a30b0 100644 --- a/src/mod/auth/sso/forward/forward.go +++ b/src/mod/auth/sso/forward/forward.go @@ -43,6 +43,12 @@ type AuthRouterOptions struct { // X-Forwarded-* headers. UseXOriginalHeaders bool + // IgnoredPaths is a list of request path prefixes that bypass forward auth entirely. Any + // request whose (normalized) path falls within one of these prefixes is served WITHOUT + // authentication. Used for auth callback subpaths that must not be gated, e.g. Authentik's + // /outpost.goauthentik.io in single-application mode. Matching is hardened against traversal. + IgnoredPaths []string + Logger *logger.Logger Database *database.Database } @@ -60,6 +66,7 @@ func NewAuthRouter(options *AuthRouterOptions) *AuthRouter { options.Database.Read(DatabaseTable, DatabaseKeyAddress, &options.Address) responseHeaders, responseClientHeaders, requestHeaders, requestIncludedCookies, requestExcludedCookies := "", "", "", "", "" + ignoredPaths := "" options.Database.Read(DatabaseTable, DatabaseKeyResponseHeaders, &responseHeaders) options.Database.Read(DatabaseTable, DatabaseKeyResponseClientHeaders, &responseClientHeaders) @@ -68,12 +75,14 @@ func NewAuthRouter(options *AuthRouterOptions) *AuthRouter { options.Database.Read(DatabaseTable, DatabaseKeyRequestExcludedCookies, &requestExcludedCookies) options.Database.Read(DatabaseTable, DatabaseKeyRequestIncludeBody, &options.RequestIncludeBody) options.Database.Read(DatabaseTable, DatabaseKeyUseXOriginalHeaders, &options.UseXOriginalHeaders) + options.Database.Read(DatabaseTable, DatabaseKeyIgnoredPaths, &ignoredPaths) options.ResponseHeaders = cleanSplit(responseHeaders) options.ResponseClientHeaders = cleanSplit(responseClientHeaders) options.RequestHeaders = cleanSplit(requestHeaders) options.RequestIncludedCookies = cleanSplit(requestIncludedCookies) options.RequestExcludedCookies = cleanSplit(requestExcludedCookies) + options.IgnoredPaths = cleanSplit(ignoredPaths) r := &AuthRouter{ client: &http.Client{ @@ -113,6 +122,7 @@ func (ar *AuthRouter) handleOptionsGET(w http.ResponseWriter, r *http.Request) { DatabaseKeyRequestExcludedCookies: ar.options.RequestExcludedCookies, DatabaseKeyRequestIncludeBody: ar.options.RequestIncludeBody, DatabaseKeyUseXOriginalHeaders: ar.options.UseXOriginalHeaders, + DatabaseKeyIgnoredPaths: ar.options.IgnoredPaths, }) utils.SendJSONResponse(w, string(js)) @@ -137,6 +147,7 @@ func (ar *AuthRouter) handleOptionsPOST(w http.ResponseWriter, r *http.Request) requestExcludedCookies, _ := utils.PostPara(r, DatabaseKeyRequestExcludedCookies) requestIncludeBody, _ := utils.PostPara(r, DatabaseKeyRequestIncludeBody) useXOriginalHeaders, _ := utils.PostPara(r, DatabaseKeyUseXOriginalHeaders) + ignoredPaths, _ := utils.PostPara(r, DatabaseKeyIgnoredPaths) // Write changes to runtime ar.options.Address = address @@ -147,6 +158,7 @@ func (ar *AuthRouter) handleOptionsPOST(w http.ResponseWriter, r *http.Request) ar.options.RequestExcludedCookies = cleanSplit(requestExcludedCookies) ar.options.RequestIncludeBody, _ = strconv.ParseBool(requestIncludeBody) ar.options.UseXOriginalHeaders, _ = strconv.ParseBool(useXOriginalHeaders) + ar.options.IgnoredPaths = cleanSplit(ignoredPaths) // Write changes to database ar.options.Database.Write(DatabaseTable, DatabaseKeyAddress, address) @@ -157,6 +169,7 @@ func (ar *AuthRouter) handleOptionsPOST(w http.ResponseWriter, r *http.Request) ar.options.Database.Write(DatabaseTable, DatabaseKeyRequestExcludedCookies, requestExcludedCookies) ar.options.Database.Write(DatabaseTable, DatabaseKeyRequestIncludeBody, ar.options.RequestIncludeBody) ar.options.Database.Write(DatabaseTable, DatabaseKeyUseXOriginalHeaders, ar.options.UseXOriginalHeaders) + ar.options.Database.Write(DatabaseTable, DatabaseKeyIgnoredPaths, ignoredPaths) ar.logOptions() @@ -172,6 +185,7 @@ func (ar *AuthRouter) handleOptionsDelete(w http.ResponseWriter, r *http.Request ar.options.RequestExcludedCookies = nil ar.options.RequestIncludeBody = false ar.options.UseXOriginalHeaders = false + ar.options.IgnoredPaths = nil ar.options.Database.Delete(DatabaseTable, DatabaseKeyAddress) ar.options.Database.Delete(DatabaseTable, DatabaseKeyResponseHeaders) @@ -181,6 +195,7 @@ func (ar *AuthRouter) handleOptionsDelete(w http.ResponseWriter, r *http.Request ar.options.Database.Delete(DatabaseTable, DatabaseKeyRequestExcludedCookies) ar.options.Database.Delete(DatabaseTable, DatabaseKeyRequestIncludeBody) ar.options.Database.Delete(DatabaseTable, DatabaseKeyUseXOriginalHeaders) + ar.options.Database.Delete(DatabaseTable, DatabaseKeyIgnoredPaths) utils.SendOK(w) } @@ -272,5 +287,5 @@ func (ar *AuthRouter) handle500Error(w http.ResponseWriter, err error, message s } func (ar *AuthRouter) logOptions() { - ar.options.Logger.PrintAndLog(LogTitle, fmt.Sprintf("Forward Authz Options -> Address: %s, Response Headers: %s, Response Client Headers: %s, Request Headers: %s, Request Included Cookies: %s, Request Excluded Cookies: %s, Request Include Body: %t, Use X-Original Headers: %t", ar.options.Address, strings.Join(ar.options.ResponseHeaders, ";"), strings.Join(ar.options.ResponseClientHeaders, ";"), strings.Join(ar.options.RequestHeaders, ";"), strings.Join(ar.options.RequestIncludedCookies, ";"), strings.Join(ar.options.RequestExcludedCookies, ";"), ar.options.RequestIncludeBody, ar.options.UseXOriginalHeaders), nil) + ar.options.Logger.PrintAndLog(LogTitle, fmt.Sprintf("Forward Authz Options -> Address: %s, Response Headers: %s, Response Client Headers: %s, Request Headers: %s, Request Included Cookies: %s, Request Excluded Cookies: %s, Request Include Body: %t, Use X-Original Headers: %t, Ignored Paths: %s", ar.options.Address, strings.Join(ar.options.ResponseHeaders, ";"), strings.Join(ar.options.ResponseClientHeaders, ";"), strings.Join(ar.options.RequestHeaders, ";"), strings.Join(ar.options.RequestIncludedCookies, ";"), strings.Join(ar.options.RequestExcludedCookies, ";"), ar.options.RequestIncludeBody, ar.options.UseXOriginalHeaders, strings.Join(ar.options.IgnoredPaths, ";")), nil) } diff --git a/src/mod/auth/sso/forward/ignoredpaths.go b/src/mod/auth/sso/forward/ignoredpaths.go new file mode 100644 index 00000000..3385be41 --- /dev/null +++ b/src/mod/auth/sso/forward/ignoredpaths.go @@ -0,0 +1,58 @@ +package forward + +import ( + "net/url" + "path" + "strings" +) + +/* + ignoredpaths.go + + Implements "Ignored Paths" for forward auth: a list of request path prefixes that bypass + the forward auth check entirely. Requests whose path falls within one of these prefixes are + served WITHOUT authentication. + + The primary use case is auth-provider callback subpaths that are served on the protected + application's own domain and therefore must not be gated by the auth check, e.g. Authentik's + /outpost.goauthentik.io in single-application mode (see issue #895). It is intentionally + generic: any path the operator explicitly wants public can be listed. +*/ + +// requestPathWithinPrefix reports whether the given request URI falls *within* the supplied +// path prefix using normalized path boundaries. It is hardened against: +// - boundary tricks e.g. "/outpost.goauthentik.io.evil" must NOT match "/outpost.goauthentik.io" +// - path traversal e.g. "/outpost.goauthentik.io/../admin" must NOT match (resolves to "/admin") +// - encoded traversal e.g. "/outpost.goauthentik.io/%2e%2e/admin" +// +// This matters because a false positive would skip authentication for a path the operator +// did not intend to expose. +func requestPathWithinPrefix(requestURI string, prefix string) bool { + requestPath := requestURI + if u, err := url.ParseRequestURI(requestURI); err == nil { + //Use the decoded path only, dropping any query string / fragment + requestPath = u.Path + } + requestPath = path.Clean("/" + requestPath) + cleanedPrefix := path.Clean("/" + prefix) + return requestPath == cleanedPrefix || strings.HasPrefix(requestPath, cleanedPrefix+"/") +} + +// IsIgnoredPath reports whether the given request URI should bypass forward auth because it +// falls within one of the configured ignored path prefixes. Empty / whitespace-only entries +// are skipped so a stray comma cannot accidentally disable auth for the whole site. +func (ar *AuthRouter) IsIgnoredPath(requestURI string) bool { + if ar == nil { + return false + } + for _, prefix := range ar.options.IgnoredPaths { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + continue + } + if requestPathWithinPrefix(requestURI, prefix) { + return true + } + } + return false +} diff --git a/src/mod/auth/sso/forward/ignoredpaths_test.go b/src/mod/auth/sso/forward/ignoredpaths_test.go new file mode 100644 index 00000000..dbc9ea3c --- /dev/null +++ b/src/mod/auth/sso/forward/ignoredpaths_test.go @@ -0,0 +1,74 @@ +package forward + +import "testing" + +// TestRequestPathWithinPrefix verifies the hardened path matching used by Ignored Paths. The +// boundary and path-traversal cases are security relevant: a false positive would skip +// authentication on a path the operator did not intend to expose. +func TestRequestPathWithinPrefix(t *testing.T) { + const outpost = "/outpost.goauthentik.io" + + cases := []struct { + name string + uri string + prefix string + want bool + }{ + {"callback with query", "/outpost.goauthentik.io/callback?code=abc&state=xyz", outpost, true}, + {"exact", "/outpost.goauthentik.io", outpost, true}, + {"trailing slash", "/outpost.goauthentik.io/", outpost, true}, + {"start endpoint", "/outpost.goauthentik.io/start", outpost, true}, + {"sign_out endpoint", "/outpost.goauthentik.io/sign_out", outpost, true}, + {"boundary sibling", "/outpost.goauthentik.io.evil/x", outpost, false}, + {"prefix as substring", "/outpost.goauthentik.ioxyz", outpost, false}, + {"dot dot traversal", "/outpost.goauthentik.io/../admin", outpost, false}, + {"encoded traversal", "/outpost.goauthentik.io/%2e%2e/admin", outpost, false}, + {"nested traversal", "/outpost.goauthentik.io/foo/../../secret", outpost, false}, + {"unrelated path", "/api/v1/data", outpost, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := requestPathWithinPrefix(tc.uri, tc.prefix); got != tc.want { + t.Errorf("requestPathWithinPrefix(%q, %q) = %v, want %v", tc.uri, tc.prefix, got, tc.want) + } + }) + } +} + +// TestAuthRouterIsIgnoredPath verifies multi-prefix matching, that empty/whitespace entries +// are skipped, that traversal cannot escape a configured prefix, and that an empty list never +// bypasses authentication. +func TestAuthRouterIsIgnoredPath(t *testing.T) { + ar := &AuthRouter{ + options: &AuthRouterOptions{ + IgnoredPaths: []string{"/outpost.goauthentik.io", " ", "/healthz"}, + }, + } + + cases := []struct { + name string + uri string + want bool + }{ + {"first prefix", "/outpost.goauthentik.io/callback?code=x", true}, + {"second prefix exact", "/healthz", true}, + {"second prefix subpath", "/healthz/live", true}, + {"not listed", "/admin", false}, + {"traversal escapes", "/outpost.goauthentik.io/../admin", false}, + {"boundary", "/healthzzz", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ar.IsIgnoredPath(tc.uri); got != tc.want { + t.Errorf("IsIgnoredPath(%q) = %v, want %v", tc.uri, got, tc.want) + } + }) + } + + empty := &AuthRouter{options: &AuthRouterOptions{}} + if empty.IsIgnoredPath("/outpost.goauthentik.io/callback") { + t.Errorf("empty IgnoredPaths must never bypass authentication") + } +} diff --git a/src/mod/dynamicproxy/authProviders.go b/src/mod/dynamicproxy/authProviders.go index 0b53c88f..dcfa016a 100644 --- a/src/mod/dynamicproxy/authProviders.go +++ b/src/mod/dynamicproxy/authProviders.go @@ -155,6 +155,12 @@ func handleBasicAuth(w http.ResponseWriter, r *http.Request, pe *ProxyEndpoint) // Handle forward auth routing func (h *ProxyHandler) handleForwardAuth(w http.ResponseWriter, r *http.Request) error { + // Skip forward auth for SSO-ignored paths. These paths are served WITHOUT authentication + // (e.g. an auth provider callback subpath that must reach its own handler), so matching is + // hardened against path traversal / boundary tricks (see forward.IsIgnoredPath). + if h.Parent.Option.ForwardAuthRouter.IsIgnoredPath(r.RequestURI) { + return nil + } return h.Parent.Option.ForwardAuthRouter.HandleAuthProviderRouting(w, r) } diff --git a/src/web/components/sso.html b/src/web/components/sso.html index 96360ff2..476b4da0 100644 --- a/src/web/components/sso.html +++ b/src/web/components/sso.html @@ -28,7 +28,7 @@

Forward Auth

- The full remote address or URL of the authorization servers forward auth endpoint. Example: http://127.0.0.1:9091/authz/forward-auth + The full remote address or URL of the authorization servers forward auth endpoint. Example: http://127.0.0.1:9091/authz/forward-auth
Using Authentik in single-application (per-app) mode? You also need to set Ignored Paths under Advanced Options below, and add a matching Virtual Directory on each protected host, see the note on that field.
@@ -86,6 +86,18 @@

Forward Auth

+
+ + + + Comma-separated list of request path prefixes that are excluded from Forward Auth. Matching is normalized (path traversal and prefix-boundary tricks are rejected).
+ Authentik (single application): set this to /outpost.goauthentik.io and add a Virtual Directory for the same path pointing to your outpost, so the login callback can complete. +
+
+ + No authentication is performed on these paths. Every request whose path starts with one of these prefixes bypasses Forward Auth entirely and is served with no auth check — only list paths you intentionally want public. +
+
@@ -370,6 +382,11 @@

SSO Behavior

} else { $("#forwardAuthRequestUseXOriginalHeaders").parent().checkbox("set unchecked"); } + if (data.ignoredPaths != null) { + $('#forwardAuthIgnoredPaths').val(data.ignoredPaths.join(",")); + } else { + $('#forwardAuthIgnoredPaths').val(""); + } }, error: function(jqXHR, textStatus, errorThrown) { console.error('Error fetching SSO settings:', textStatus, errorThrown); @@ -391,6 +408,7 @@

SSO Behavior

const requestExcludedCookies = $('#forwardAuthRequestExcludedCookies').val(); const requestIncludeBody = $('#forwardAuthRequestIncludeBody').is(':checked'); const useXOriginalHeaders = $('#forwardAuthRequestUseXOriginalHeaders').is(':checked'); + const ignoredPaths = $('#forwardAuthIgnoredPaths').val(); console.log(`Updating Forward Auth settings. Address: ${address}. Response Headers: ${responseHeaders}. Response Client Headers: ${responseClientHeaders}. Request Headers: ${requestHeaders}. Request Included Cookies: ${requestIncludedCookies}. Request Excluded Cookies: ${requestExcludedCookies}. Request Include Body: ${requestIncludeBody}. Use X-Original-* Headers: ${useXOriginalHeaders}.`); @@ -406,6 +424,7 @@

SSO Behavior

requestExcludedCookies: requestExcludedCookies, requestIncludeBody: requestIncludeBody, useXOriginalHeaders: useXOriginalHeaders, + ignoredPaths: ignoredPaths, }, success: function(data) { if (data.error !== undefined) { @@ -775,4 +794,4 @@

SSO Behavior

} }); } - \ No newline at end of file +