Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/mod/auth/sso/forward/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const (
DatabaseKeyRequestExcludedCookies = "requestExcludedCookies"
DatabaseKeyRequestIncludeBody = "requestIncludeBody"
DatabaseKeyUseXOriginalHeaders = "useXOriginalHeaders"
DatabaseKeyIgnoredPaths = "ignoredPaths"

HeaderXForwardedProto = "X-Forwarded-Proto"
HeaderXForwardedHost = "X-Forwarded-Host"
Expand Down
17 changes: 16 additions & 1 deletion src/mod/auth/sso/forward/forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
Expand All @@ -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{
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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()

Expand All @@ -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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
58 changes: 58 additions & 0 deletions src/mod/auth/sso/forward/ignoredpaths.go
Original file line number Diff line number Diff line change
@@ -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
}
74 changes: 74 additions & 0 deletions src/mod/auth/sso/forward/ignoredpaths_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
6 changes: 6 additions & 0 deletions src/mod/dynamicproxy/authProviders.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
23 changes: 21 additions & 2 deletions src/web/components/sso.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ <h2>Forward Auth</h2>
<div class="field">
<label for="forwardAuthAddress">Address</label>
<input type="text" id="forwardAuthAddress" name="forwardAuthAddress" placeholder="Enter Forward Auth Address">
<small>The full remote address or URL of the authorization servers forward auth endpoint. <strong>Example:</strong> http://127.0.0.1:9091/authz/forward-auth</small>
<small>The full remote address or URL of the authorization servers forward auth endpoint. <strong>Example:</strong> http://127.0.0.1:9091/authz/forward-auth<br><b>Using Authentik in single-application (per-app) mode?</b> You also need to set <b>Ignored Paths</b> under Advanced Options below, and add a matching Virtual Directory on each protected host, see the note on that field.</small>
</div>
<div class="ui basic segment advanceoptions" style="margin-top:0.6em;">
<div class="ui advancedSSOForwardAuthOptions accordion">
Expand Down Expand Up @@ -86,6 +86,18 @@ <h2>Forward Auth</h2>
<input type="checkbox" id="forwardAuthRequestUseXOriginalHeaders" name="forwardAuthRequestUseXOriginalHeaders" value="Use X-Original-* Headers">
<label for="forwardAuthRequestUseXOriginalHeaders">Use X-Original-* Headers<br><small>This is used for implementations which do not use the X-Forwarded-* headers. In addition if the authorization server responds with a 401 and Location header the status will be changed to 302.</small></label>
</div>
<div class="field" style="margin-top: 1em;">
<label for="forwardAuthIgnoredPaths">Ignored Paths</label>
<input type="text" id="forwardAuthIgnoredPaths" name="forwardAuthIgnoredPaths" placeholder="/outpost.goauthentik.io">
<small>
Comma-separated list of request path prefixes that are <b>excluded from Forward Auth</b>. Matching is normalized (path traversal and prefix-boundary tricks are rejected). <br>
<strong>Authentik (single application):</strong> set this to <code>/outpost.goauthentik.io</code> and add a Virtual Directory for the same path pointing to your outpost, so the login callback can complete.
</small>
<div class="ui visible warning message" style="margin-top: 0.5em;">
<i class="exclamation triangle icon"></i>
<b>No authentication is performed on these paths.</b> 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.
</div>
</div>
</div>
</div>
</div>
Expand Down Expand Up @@ -370,6 +382,11 @@ <h3>SSO Behavior</h3>
} 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);
Expand All @@ -391,6 +408,7 @@ <h3>SSO Behavior</h3>
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}.`);

Expand All @@ -406,6 +424,7 @@ <h3>SSO Behavior</h3>
requestExcludedCookies: requestExcludedCookies,
requestIncludeBody: requestIncludeBody,
useXOriginalHeaders: useXOriginalHeaders,
ignoredPaths: ignoredPaths,
},
success: function(data) {
if (data.error !== undefined) {
Expand Down Expand Up @@ -775,4 +794,4 @@ <h3>SSO Behavior</h3>
}
});
}
</script>
</script>
Loading