From d6e42090cf541ca92d1a05d95ca224929e1b8058 Mon Sep 17 00:00:00 2001 From: CrazyWolf13 Date: Sat, 20 Jun 2026 19:06:56 +0200 Subject: [PATCH 1/3] feat(forward-auth): add Ignored Paths (skip forward auth on listed path prefixes) Implements the reviewed direction for the Authentik single-application redirect loop (#895): instead of an Authentik-specific preset, add a generic, comma-separated "Ignored Paths" list to the (global) Forward Auth settings. Any request whose path falls within one of these prefixes bypasses the forward auth check entirely. For Authentik per-app mode you set this to /outpost.goauthentik.io so the OAuth callback reaches the outpost instead of looping. Matching is hardened (decoded + path.Clean + boundary checked) against path traversal and prefix-boundary tricks; empty entries are skipped so a stray comma cannot disable auth site-wide. - forward/const.go: ignoredPaths DB key - forward/forward.go: IgnoredPaths option; load / GET / POST / DELETE / log - forward/ignoredpaths.go: hardened matcher + IsIgnoredPath (+ unit tests) - dynamicproxy/authProviders.go: handleForwardAuth skips auth for ignored paths - web/sso.html: Ignored Paths field under Advanced Options, with a warning Refs #895 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mod/auth/sso/forward/const.go | 1 + src/mod/auth/sso/forward/forward.go | 17 ++++- src/mod/auth/sso/forward/ignoredpaths.go | 58 +++++++++++++++ src/mod/auth/sso/forward/ignoredpaths_test.go | 74 +++++++++++++++++++ src/mod/dynamicproxy/authProviders.go | 6 ++ src/web/components/sso.html | 15 ++++ 6 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 src/mod/auth/sso/forward/ignoredpaths.go create mode 100644 src/mod/auth/sso/forward/ignoredpaths_test.go 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..8922e85a 100644 --- a/src/web/components/sso.html +++ b/src/web/components/sso.html @@ -86,6 +86,14 @@

Forward Auth

+
+ + +
+
No authentication is performed on these paths
+

Comma-separated list of path prefixes that bypass Forward Auth entirely. Any request whose path starts with one of these prefixes is served with no authentication check. Use only for auth callback paths that must not be gated (e.g. Authentik per-application mode uses /outpost.goauthentik.io) or paths you intentionally want public. Matching is normalized — path traversal and prefix-boundary tricks are rejected.

+
+
@@ -370,6 +378,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 +404,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 +420,7 @@

SSO Behavior

requestExcludedCookies: requestExcludedCookies, requestIncludeBody: requestIncludeBody, useXOriginalHeaders: useXOriginalHeaders, + ignoredPaths: ignoredPaths, }, success: function(data) { if (data.error !== undefined) { From fdff61e890d0db730614dfc81053491152f5ab22 Mon Sep 17 00:00:00 2001 From: CrazyWolf13 Date: Sat, 20 Jun 2026 20:29:09 +0200 Subject: [PATCH 2/3] fix(forward-auth): show Ignored Paths help + warning, add Authentik hint The Ignored Paths help/warning was a Semantic UI .warning.message inside the form, which Semantic UI hides by default, so the field rendered with nothing below it. Use an always-visible for the description + Authentik suggestion, plus a forced-visible (.visible) warning message for the no-auth security warning. Also add an Authentik single-application hint under the Address field pointing to Ignored Paths and the required virtual directory. Refs #895 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/web/components/sso.html | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/web/components/sso.html b/src/web/components/sso.html index 8922e85a..8be00900 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.
@@ -89,9 +89,13 @@

Forward Auth

-
-
No authentication is performed on these paths
-

Comma-separated list of path prefixes that bypass Forward Auth entirely. Any request whose path starts with one of these prefixes is served with no authentication check. Use only for auth callback paths that must not be gated (e.g. Authentik per-application mode uses /outpost.goauthentik.io) or paths you intentionally want public. Matching is normalized — path traversal and prefix-boundary tricks are rejected.

+ + 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.
From 29cea7d2b478cf607de6240d92544cab857da5d1 Mon Sep 17 00:00:00 2001 From: Tobias <96661824+CrazyWolf13@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:56:46 +0200 Subject: [PATCH 3/3] fix: ui characters --- src/web/components/sso.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/components/sso.html b/src/web/components/sso.html index 8be00900..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
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.
+ 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.
@@ -794,4 +794,4 @@

SSO Behavior

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