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
|
# The subrequest. Same implementation and header set as
|
||||||
# `swarm-ui.nix` uses, for the same reason: `X-Original-URL`
|
# `swarm-ui.nix` uses, for the same reason: `X-Original-URL`
|
||||||
# and `X-Original-Method` are what authelia's `auth-request`
|
# and `X-Original-Method` are what authelia's `auth-request`
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,11 @@ let
|
||||||
parts = builtins.match "https://([^/]+)(/.*)" url;
|
parts = builtins.match "https://([^/]+)(/.*)" url;
|
||||||
target = {
|
target = {
|
||||||
domain = builtins.elemAt parts 0;
|
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
|
in
|
||||||
if parts == null then
|
if parts == null then
|
||||||
|
|
|
||||||
|
|
@ -107,8 +107,10 @@ let
|
||||||
forgeMetricsUrl = "https://forge.t.local/metrics";
|
forgeMetricsUrl = "https://forge.t.local/metrics";
|
||||||
autheliaMetricsUrl = "https://auth.t.local/metrics";
|
autheliaMetricsUrl = "https://auth.t.local/metrics";
|
||||||
|
|
||||||
# The policy authelia applies to `subject` requesting `https://<domain><path>`:
|
# The policy authelia applies to `subject` requesting `https://<domain><path>`,
|
||||||
# the first rule whose domain, resources and subject all match, else
|
# 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
|
# `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
|
# outside it — authelia moves on to the next rule — which is the semantics a
|
||||||
# check of the rule's own fields cannot see.
|
# check of the rule's own fields cannot see.
|
||||||
|
|
@ -214,7 +216,7 @@ let
|
||||||
&& builtins.elem forgeMetricsUrl client.audience
|
&& builtins.elem forgeMetricsUrl client.audience
|
||||||
&& builtins.elem {
|
&& builtins.elem {
|
||||||
domain = "forge.t.local";
|
domain = "forge.t.local";
|
||||||
resources = [ "^/metrics$" ];
|
resources = [ "^/metrics([?].*)?$" ];
|
||||||
policy = "one_factor";
|
policy = "one_factor";
|
||||||
subject = [ "oauth2:client:${h.swarm.otel.clientId}" ];
|
subject = [ "oauth2:client:${h.swarm.otel.clientId}" ];
|
||||||
} rules;
|
} rules;
|
||||||
|
|
@ -251,17 +253,30 @@ let
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# Every published target admits the collector's client and refuses any
|
# Every published target admits the collector's client and refuses any
|
||||||
# other authenticated subject. Read through authelia's first-match
|
# other authenticated subject, with or without a query string. Read
|
||||||
# semantics: a client-scoped rule alone lets an operator's session fall
|
# through authelia's first-match semantics: a client-scoped rule alone
|
||||||
# through to `default_policy = one_factor`.
|
# 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";
|
name = "every published metrics URL admits the collector and refuses a plain session";
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
h = autheliaOnly.services.hyperhive;
|
h = autheliaOnly.services.hyperhive;
|
||||||
collector = "oauth2:client:${h.swarm.otel.clientId}";
|
collector = "oauth2:client:${h.swarm.otel.clientId}";
|
||||||
targets = lib.mapAttrsToList (
|
targets = lib.concatMap (
|
||||||
_: url: builtins.match "https://([^/]+)(/.*)" url
|
url:
|
||||||
) h.swarm.otel.publishedScrapeTargets;
|
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 =
|
policy =
|
||||||
subject: t: autheliaPolicy autheliaOnly subject (builtins.elemAt t 0) (builtins.elemAt t 1);
|
subject: t: autheliaPolicy autheliaOnly subject (builtins.elemAt t 0) (builtins.elemAt t 1);
|
||||||
in
|
in
|
||||||
|
|
@ -269,10 +284,70 @@ let
|
||||||
autheliaMetricsUrl
|
autheliaMetricsUrl
|
||||||
forgeMetricsUrl
|
forgeMetricsUrl
|
||||||
]
|
]
|
||||||
|
&& lib.length targets == 4
|
||||||
&& lib.all (t: policy collector t == "one_factor") targets
|
&& lib.all (t: policy collector t == "one_factor") targets
|
||||||
&& lib.all (t: policy "user:someone" t == "deny") targets
|
&& lib.all (t: policy "user:someone" t == "deny") targets
|
||||||
&& lib.all (t: policy "group:admins" 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
|
# Single host: authelia is scraped once, by its published name. A
|
||||||
# loopback job beside it would scrape the same listener twice — and share
|
# loopback job beside it would scrape the same listener twice — and share
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue