mirror of
https://github.com/authelia/authelia.git
synced 2026-10-28 09:34:52 +08:00
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:
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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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\"}")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user