Watch
0
0
Fork
You've already forked hyperhive
0

swarm-authelia: match the metrics rules with or without a query string

Authelia 4.39.20 matches `resources` against the cleaned path plus
`?<query>` (authorization/types.go:94 NewObject, utils/url.go:11-34
URLPathFullClean), while nginx's `= /metrics` serves the request whatever
its query. `^/metrics$` therefore missed `/metrics?x=1`: both the collector
rule and the trailing `deny` were skipped and a plain session reached
`default_policy = one_factor`. The pattern is now `^<path>([?].*)?$` for
every published target.

Forgejo strips trailing slashes before routing (routers/common/
middleware.go stripSlashesMiddleware) and serves metrics without a token
when none is configured, so `forge.<swarm>/metrics/` reached its metrics
through the ungated `/` location. The forge vhost now answers
`= /metrics/` with 404; nginx merges slashes and decodes `%2F` before
matching, so that also covers `/metrics//` and `/metrics%2F`.

Part of #5205
This commit is contained in:
atlas 2026-10-13 10:52:51 +02:00 • committed by mara
commit 8eb11a91da
3 changed files with 96 additions and 10 deletions

View file

@ -327,6 +327,13 @@ in
'';
};
# ⚠️ Forgejo strips trailing slashes before routing, so `/metrics/`
# reaches the same unauthenticated handler — and the exact match
# above does not take it, leaving it to the `/` catch-all. nginx
# merges slashes and decodes `%2F` before matching, so this one
# location also takes `/metrics//` and `/metrics%2F`.
"= /metrics/".return = "404";
# The subrequest. Same implementation and header set as
# `swarm-ui.nix` uses, for the same reason: `X-Original-URL`
# and `X-Original-Method` are what authelia's `auth-request`

View file

@ -84,7 +84,11 @@ let
parts = builtins.match "https://([^/]+)(/.*)" url;
target = {
domain = builtins.elemAt parts 0;
resources = [ "^${lib.escapeRegex (builtins.elemAt parts 1)}$" ];
# ⚠️ Authelia matches `resources` against the cleaned path PLUS the
# query string, while nginx's `= <path>` location ignores the query.
# A bare `^<path>$` therefore misses `<path>?x`, which the location
# still serves, and that request falls through to `default_policy`.
resources = [ "^${lib.escapeRegex (builtins.elemAt parts 1)}([?].*)?$" ];
};
in
if parts == null then

View file

@ -107,8 +107,10 @@ let
forgeMetricsUrl = "https://forge.t.local/metrics";
autheliaMetricsUrl = "https://auth.t.local/metrics";
# The policy authelia applies to `subject` requesting `https://<domain><path>`:
# the first rule whose domain, resources and subject all match, else
# The policy authelia applies to `subject` requesting `https://<domain><path>`,
# where `path` is what authelia matches `resources` against: the cleaned path
# plus `?<query>` when there is one.
# It is the first rule whose domain, resources and subject all match, else
# `default_policy`. ⚠️ A rule with a `subject` list does NOT match a subject
# outside it — authelia moves on to the next rule — which is the semantics a
# check of the rule's own fields cannot see.
@ -214,7 +216,7 @@ let
&& builtins.elem forgeMetricsUrl client.audience
&& builtins.elem {
domain = "forge.t.local";
resources = [ "^/metrics$" ];
resources = [ "^/metrics([?].*)?$" ];
policy = "one_factor";
subject = [ "oauth2:client:${h.swarm.otel.clientId}" ];
} rules;
@ -251,17 +253,30 @@ let
}
{
# Every published target admits the collector's client and refuses any
# other authenticated subject. Read through authelia's first-match
# semantics: a client-scoped rule alone lets an operator's session fall
# through to `default_policy = one_factor`.
# other authenticated subject, with or without a query string. Read
# through authelia's first-match semantics: a client-scoped rule alone
# lets an operator's session fall through to `default_policy = one_factor`,
# and so does a request the rule's regex does not match.
name = "every published metrics URL admits the collector and refuses a plain session";
ok =
let
h = autheliaOnly.services.hyperhive;
collector = "oauth2:client:${h.swarm.otel.clientId}";
targets = lib.mapAttrsToList (
_: url: builtins.match "https://([^/]+)(/.*)" url
) h.swarm.otel.publishedScrapeTargets;
targets = lib.concatMap (
url:
let
t = builtins.match "https://([^/]+)(/.*)" url;
in
map
(q: [
(builtins.elemAt t 0)
"${builtins.elemAt t 1}${q}"
])
[
""
"?x=1"
]
) (lib.attrValues h.swarm.otel.publishedScrapeTargets);
policy =
subject: t: autheliaPolicy autheliaOnly subject (builtins.elemAt t 0) (builtins.elemAt t 1);
in
@ -269,10 +284,70 @@ let
autheliaMetricsUrl
forgeMetricsUrl
]
&& lib.length targets == 4
&& lib.all (t: policy collector t == "one_factor") targets
&& lib.all (t: policy "user:someone" t == "deny") targets
&& lib.all (t: policy "group:admins" t == "deny") targets;
}
{
# Which request forms reach `= /metrics`, and whether the rule's regex
# matches the path authelia derives from each. `nginx` records the
# location each form lands in (measured against a stand-alone nginx);
# `path` is authelia's cleaned path plus query for that form. The
# invariant: every form the gated location serves is matched by the rule,
# so none of them reaches `default_policy`.
name = "the metrics rule matches every request form the gated location serves";
ok =
let
rules =
autheliaOnly.containers.swarm-authelia.config.services.authelia.instances.swarm.settings.access_control.rules;
metricsRules = lib.filter (r: r ? resources) rules;
re = lib.head (lib.head metricsRules).resources;
table = [
{
request = "/metrics";
nginx = "= /metrics";
path = "/metrics";
}
{
request = "/metrics?x=1";
nginx = "= /metrics";
path = "/metrics?x=1";
}
{
request = "//metrics";
nginx = "= /metrics";
path = "/metrics";
}
{
request = "/metrics/";
nginx = "= /metrics/ (forge: 404) or /";
path = "/metrics/";
}
{
request = "/metricsx";
nginx = "/";
path = "/metricsx";
}
];
matched = row: builtins.match re row.path != null;
in
lib.length metricsRules == 4
&& lib.all (r: r.resources == [ re ]) metricsRules
&& re == "^/metrics([?].*)?$"
&& lib.all (row: matched row == (row.nginx == "= /metrics")) table;
}
{
# Forgejo strips trailing slashes before routing, so `/metrics/` reaches
# its metrics handler unless nginx refuses it first.
name = "the forge refuses /metrics/ rather than proxying it to forgejo's metrics";
ok =
let
locs =
allLocal.services.nginx.virtualHosts.${allLocal.services.hyperhive.swarm.forge.domain}.locations;
in
(locs."= /metrics/".return or null) == "404";
}
{
# Single host: authelia is scraped once, by its published name. A
# loopback job beside it would scrape the same listener twice — and share