feat(handlers): use fetch metadata to detect authz navigations (#13527)

The authz endpoints now use the Sec-Fetch-Mode and Sec-Fetch-Dest
headers to decide whether a request is a top-level navigation, falling
back to the X-Requested-With and Accept headers when they are absent.
Requests made with fetch, subresource requests, websockets, and iframe
navigations now receive a 401 with the Location header instead of a
redirect the web application silently follows. All authz responses also
include the Cache-Control no-store and Pragma no-cache headers, and the
Envoy, Istio, and Envoy Gateway integration docs forward the headers
required for detection.
This commit is contained in:
James Elliott authored and GitHub committed 2026-10-20 21:55:13 +00:00
1 parent 85e9006a24
commit fff1b984f7
13 files changed
+233 -11

No files matched your search

@@ -80,6 +80,9 @@ spec:
extAuth:
headersToExtAuth:
- 'accept'
- 'sec-fetch-mode'
- 'sec-fetch-dest'
- 'x-requested-with'
- 'cookie'
- 'location'
- 'authorization'
@@ -118,6 +121,9 @@ spec:
extAuth:
headersToExtAuth:
- 'accept'
- 'sec-fetch-mode'
- 'sec-fetch-dest'
- 'x-requested-with'
- 'cookie'
- 'authorization'
- 'proxy-authorization'
@@ -69,6 +69,9 @@ spec:
pathPrefix: '/api/authz/ext-authz/'
includeRequestHeadersInCheck:
- 'accept'
- 'sec-fetch-mode'
- 'sec-fetch-dest'
- 'x-requested-with'
- 'cookie'
- 'location'
- 'authorization'
@@ -251,6 +251,9 @@ static_resources:
- exact: 'authorization'
- exact: 'proxy-authorization'
- exact: 'accept'
- exact: 'sec-fetch-mode'
- exact: 'sec-fetch-dest'
- exact: 'x-requested-with'
- exact: 'cookie'
- exact: 'location'
http_service:
@@ -265,6 +268,9 @@ static_resources:
- exact: 'authorization'
- exact: 'proxy-authorization'
- exact: 'accept'
- exact: 'sec-fetch-mode'
- exact: 'sec-fetch-dest'
- exact: 'x-requested-with'
- exact: 'cookie'
- exact: 'location'
headers_to_add:
@@ -130,10 +130,10 @@ to authenticate with 1FA, or if they are already authenticated with only 1FA and
redirected to the portal with:
- A HTTP [401 Unauthorized](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401) status if the original request
was an [XMLHTTPRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) provided Authelia is able to
detect it.
was not a top-level browser navigation as described in [Navigation Detection](#navigation-detection).
- A HTTP [302 Found](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/302) status if the original request had
the [GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) or
the [GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET),
[HEAD](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD), or
[OPTIONS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS) method verb.
- A HTTP [303 See Other](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303) status if neither of the above
conditions is met.
@@ -141,6 +141,40 @@ redirected to the portal with:
When the user is denied either by a default policy, or by an explicit policy we respond with a HTTP
[403 Forbidden](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403) status.
#### Navigation Detection
Redirecting a request which is not a top-level browser navigation, such as a request made by a web application using
[fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) or
[XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest), results in the browser silently
following the redirect and the web application receiving the authentication portal instead of the expected response.
For this reason these requests receive a [401 Unauthorized](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401)
status which still includes the [Location](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Location) header,
allowing the web application to detect the status and redirect the user itself. This detection does not apply to the
[AuthRequest](../../reference/guides/proxy-authorization.md#authrequest) implementation which always responds with a
[401 Unauthorized](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401) status.
A request is considered a top-level browser navigation when:
- The [Sec-Fetch-Mode](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode) header has the value
`navigate` and the [Sec-Fetch-Dest](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Dest) header
is either absent or has the value `document`. This means navigations within an `iframe` or similar element are not
considered top-level navigations, as the authentication portal can not be framed.
- If the [Sec-Fetch-Mode](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode) header is absent,
the `X-Requested-With` header does not have the
value `XMLHttpRequest` and the [Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) header
accepts `text/html`.
The proxy must forward the `Sec-Fetch-Mode`, `Sec-Fetch-Dest`, `X-Requested-With`, and `Accept` headers from the
original request to Authelia for this detection to work as intended. Most proxies forward all headers by default, but
some proxies such as [Envoy](envoy.md) require these headers are explicitly allowed, and some proxies such as
[Traefik](traefik.md) can be configured to only forward specific headers (i.e. the `authRequestHeaders` option).
Web applications which use a
[Service Worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) to handle navigations should
forward the original navigation request (i.e. `event.request`) rather than constructing a new request, as a newly
constructed request is not a navigation and will receive a
[401 Unauthorized](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401) status.
### Response Headers
With the exception of the [403 Forbidden](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403) and
@@ -148,6 +182,10 @@ With the exception of the [403 Forbidden](https://developer.mozilla.org/en-US/do
Authelia responds with a [Location](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Location) header to
redirect the user to the authentication portal.
All responses include the [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control)
header with the value `no-store` and the [Pragma](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Pragma)
header with the value `no-cache` as the response varies depending on the session and the request headers.
In the instance of a [200 OK](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/200) status response Authelia
also responds with various headers which can be forwarded by your reverse proxy to the backend application which are
potentially useful for SSO depending on if the backend application supports it.
+2 -5
View File
@@ -118,11 +118,8 @@ type AuthzContext interface {
// AuthzPath should return the path of the authorization request.
AuthzPath() (uri []byte)
// IsXHR should return true if the request is an XHR.
IsXHR() (xhr bool)
// AcceptsMIME should return true if the request accepts the given MIME type.
AcceptsMIME(mime string) (ok bool)
// IsNavigation should return true if the request is a top-level browser navigation.
IsNavigation() (navigation bool)
// ReplyStatusCode should reply with the given status code.
ReplyStatusCode(statusCode int)
+1 -1
View File
@@ -116,7 +116,7 @@ func parseAuthzPortalURL(rawURL []byte) (portalURL *url.URL, err error) {
}
func getAuthzRedirectStatusCode(ctx AuthzContext, method string) (statusCode int) {
if ctx.IsXHR() || !ctx.AcceptsMIME("text/html") {
if !ctx.IsNavigation() {
return fasthttp.StatusUnauthorized
}
@@ -98,6 +98,8 @@ func TestGetAuthzRedirectStatusCode(t *testing.T) {
Method string
Accept string
XHR bool
Mode string
Dest string
Expected int
}{
{
@@ -179,6 +181,76 @@ func TestGetAuthzRedirectStatusCode(t *testing.T) {
Method: fasthttp.MethodGet,
Expected: fasthttp.StatusUnauthorized,
},
{
Name: "ShouldReturnFoundForNavigate",
Method: fasthttp.MethodGet,
Accept: "text/html",
Mode: "navigate",
Dest: "document",
Expected: fasthttp.StatusFound,
},
{
Name: "ShouldReturnSeeOtherForNavigateWithPost",
Method: fasthttp.MethodPost,
Accept: "text/html",
Mode: "navigate",
Dest: "document",
Expected: fasthttp.StatusSeeOther,
},
{
Name: "ShouldReturnFoundForNavigateWithoutDest",
Method: fasthttp.MethodGet,
Mode: "navigate",
Expected: fasthttp.StatusFound,
},
{
Name: "ShouldReturnFoundForNavigateWhenHTMLNotAccepted",
Method: fasthttp.MethodGet,
Accept: "application/json",
Mode: "navigate",
Dest: "document",
Expected: fasthttp.StatusFound,
},
{
Name: "ShouldReturnUnauthorizedForFetchWithAcceptWildcard",
Method: fasthttp.MethodGet,
Accept: "*/*",
Mode: "cors",
Dest: "empty",
Expected: fasthttp.StatusUnauthorized,
},
{
Name: "ShouldReturnUnauthorizedForSameOriginFetch",
Method: fasthttp.MethodGet,
Accept: "text/html",
Mode: "same-origin",
Dest: "empty",
Expected: fasthttp.StatusUnauthorized,
},
{
Name: "ShouldReturnUnauthorizedForScript",
Method: fasthttp.MethodGet,
Accept: "*/*",
Mode: "no-cors",
Dest: "script",
Expected: fasthttp.StatusUnauthorized,
},
{
Name: "ShouldReturnUnauthorizedForWebSocket",
Method: fasthttp.MethodGet,
Accept: "*/*",
Mode: "websocket",
Dest: "websocket",
Expected: fasthttp.StatusUnauthorized,
},
{
Name: "ShouldReturnUnauthorizedForIFrameNavigate",
Method: fasthttp.MethodGet,
Accept: "text/html",
Mode: "navigate",
Dest: "iframe",
Expected: fasthttp.StatusUnauthorized,
},
}
for _, tc := range testCases {
@@ -194,6 +266,14 @@ func TestGetAuthzRedirectStatusCode(t *testing.T) {
mock.Ctx.Request.Header.Set("X-Requested-With", "XMLHttpRequest")
}
if tc.Mode != "" {
mock.Ctx.Request.Header.Set("Sec-Fetch-Mode", tc.Mode)
}
if tc.Dest != "" {
mock.Ctx.Request.Header.Set("Sec-Fetch-Dest", tc.Dest)
}
assert.Equal(t, tc.Expected, getAuthzRedirectStatusCode(mock.Ctx, tc.Method))
})
}
+17
View File
@@ -616,6 +616,23 @@ func (ctx *AutheliaCtx) IssuerURL() (issuerURL *url.URL, err error) {
return issuerURL, nil
}
// IsNavigation returns true if the request is a top-level browser navigation. The Fetch Metadata request headers are
// used when present, otherwise this falls back to the X-Requested-With and Accept headers. Navigations of nested
// browsing contexts such as iframes are not considered top-level navigations.
func (ctx *AutheliaCtx) IsNavigation() (navigation bool) {
if mode := ctx.Request.Header.PeekBytes(headerSecFetchMode); len(mode) != 0 {
if !strings.EqualFold(string(mode), headerValueSecFetchModeNavigate) {
return false
}
dest := ctx.Request.Header.PeekBytes(headerSecFetchDest)
return len(dest) == 0 || strings.EqualFold(string(dest), headerValueSecFetchDestDocument)
}
return !ctx.IsXHR() && ctx.AcceptsMIME("text/html")
}
// IsXHR returns true if the request is a XMLHttpRequest.
func (ctx *AutheliaCtx) IsXHR() (xhr bool) {
if requestedWith := ctx.Request.Header.PeekBytes(headerXRequestedWith); requestedWith != nil && strings.EqualFold(string(requestedWith), headerValueXRequestedWithXHR) {
@@ -1121,6 +1121,41 @@ func TestShouldDetectNonXHR(t *testing.T) {
assert.False(t, mock.Ctx.IsXHR())
}
func TestAutheliaCtx_IsNavigation(t *testing.T) {
testCases := []struct {
name string
headers map[string]string
expected bool
}{
{"ShouldDetectNavigateDocument", map[string]string{"Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "document"}, true},
{"ShouldDetectNavigateWithoutDest", map[string]string{"Sec-Fetch-Mode": "navigate"}, true},
{"ShouldDetectNavigateOverXHRAndAccept", map[string]string{"Sec-Fetch-Mode": "navigate", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest"}, true},
{"ShouldNotDetectNavigateIFrame", map[string]string{"Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "iframe"}, false},
{"ShouldNotDetectNavigateFrame", map[string]string{"Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "frame"}, false},
{"ShouldNotDetectCORS", map[string]string{"Sec-Fetch-Mode": "cors", "Accept": "*/*"}, false},
{"ShouldNotDetectNoCORS", map[string]string{"Sec-Fetch-Mode": "no-cors", "Accept": "text/html"}, false},
{"ShouldNotDetectWebSocket", map[string]string{"Sec-Fetch-Mode": "websocket"}, false},
{"ShouldFallbackToAcceptHTML", map[string]string{"Accept": "text/html"}, true},
{"ShouldFallbackToAcceptWildcard", map[string]string{"Accept": "*/*"}, true},
{"ShouldFallbackToXHR", map[string]string{"Accept": "text/html", "X-Requested-With": "XMLHttpRequest"}, false},
{"ShouldFallbackToAcceptJSON", map[string]string{"Accept": "application/json"}, false},
{"ShouldFallbackToNoHeaders", nil, false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
for k, v := range tc.headers {
mock.Ctx.Request.Header.Set(k, v)
}
assert.Equal(t, tc.expected, mock.Ctx.IsNavigation())
})
}
}
func TestAutheliaCtxMisc(t *testing.T) {
ctx := middlewares.NewAutheliaCtx(&fasthttp.RequestCtx{}, schema.Configuration{}, middlewares.NewProvidersBasic())
+6
View File
@@ -24,6 +24,9 @@ var (
headerXForwardedFor = []byte(fasthttp.HeaderXForwardedFor)
headerXRequestedWith = []byte(fasthttp.HeaderXRequestedWith)
headerSecFetchMode = []byte("Sec-Fetch-Mode")
headerSecFetchDest = []byte("Sec-Fetch-Dest")
headerXForwardedURI = []byte("X-Forwarded-URI")
headerXOriginalURL = []byte("X-Original-URL")
headerXOriginalMethod = []byte("X-Original-Method")
@@ -165,6 +168,9 @@ var (
const (
headerValueXRequestedWithXHR = "XMLHttpRequest"
headerValueSecFetchModeNavigate = "navigate"
headerValueSecFetchDestDocument = "document"
)
var okMessageBytes = []byte("{\"status\":\"OK\"}")
+11
View File
@@ -110,3 +110,14 @@ func SecurityHeadersNoStore(next fasthttp.RequestHandler) fasthttp.RequestHandle
next(ctx)
}
}
// SecurityHeadersNoStoreAfter middleware adds the Cache-Control no-store and Pragma no-cache headers after the next
// handler has been executed, ensuring the header is present even if the next handler resets the response.
func SecurityHeadersNoStoreAfter(next fasthttp.RequestHandler) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
next(ctx)
ctx.Response.Header.SetBytesKV(headerPragma, headerValueNoCache)
ctx.Response.Header.SetBytesKV(headerCacheControl, headerValueNoStore)
}
}
+23
View File
@@ -105,6 +105,29 @@ func TestHeaders(t *testing.T) {
assert.Equal(t, "no-store", string(ctx.Response.Header.Peek(fasthttp.HeaderCacheControl)))
},
},
{
"ShouldHandleSecurityHeadersNoStoreAfter",
SecurityHeadersNoStoreAfter,
nil,
func(t *testing.T, ctx *fasthttp.RequestCtx) {
assert.Equal(t, "no-cache", string(ctx.Response.Header.Peek(fasthttp.HeaderPragma)))
assert.Equal(t, "no-store", string(ctx.Response.Header.Peek(fasthttp.HeaderCacheControl)))
},
},
{
"ShouldHandleSecurityHeadersNoStoreAfterResponseReset",
SecurityHeadersNoStoreAfter,
func(ctx *fasthttp.RequestCtx) {
ctx.Response.Header.SetBytesKV(headerCacheControl, headerValueNoCache)
ctx.Response.Reset()
ctx.SetStatusCode(fasthttp.StatusUnauthorized)
},
func(t *testing.T, ctx *fasthttp.RequestCtx) {
assert.Equal(t, fasthttp.StatusUnauthorized, ctx.Response.StatusCode())
assert.Equal(t, "no-cache", string(ctx.Response.Header.Peek(fasthttp.HeaderPragma)))
assert.Equal(t, "no-store", string(ctx.Response.Header.Peek(fasthttp.HeaderCacheControl)))
},
},
}
for _, tc := range testCases {
+2 -2
View File
@@ -240,9 +240,9 @@ func handlerMain(config *schema.Configuration, providers middlewares.Providers)
authz := handlers.NewAuthzBuilder().WithConfig(config).WithEndpointConfig(endpoint).Build()
handlerAuthz := middlewares.Wrap(metricsVRMW, bridge(func(ctx *middlewares.AutheliaCtx) {
handlerAuthz := middlewares.MultiWrap(bridge(func(ctx *middlewares.AutheliaCtx) {
authz.Handler(ctx)
}))
}), metricsVRMW, middlewares.SecurityHeadersNoStoreAfter)
switch name {
case "legacy":