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:
parent
393e84afd0
commit
8eb11a91da
3 changed files with 96 additions and 10 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue