fix(security): keep the operator's ntfy token in and re-check every redirect hop

Two reported advisories, and a third path neither of them covers.

GHSA-7pqc-fj3c-9346 — the ntfy token fell back to the operator's whenever a
request did not carry one, so an authenticated user who named their own server
was handed the operator's decrypted token as `Authorization: Bearer`. The report
covers `test-ntfy`, but the live send path in channels/builtins.ts does the same
thing off a per-user `ntfy_server` setting: no test route needed, just wait for
the next notification. Gating the route on `role === 'admin'` would have closed
one of the two and broken the documented shared-server setup, where the operator
supplies server plus token and users only set a topic. The invariant is the
target, not the role: the operator's token travels to the operator's server and
nowhere else, and that rule fits both paths unchanged. Scheme is part of the
comparison — an http twin of an https server would put it on the wire in clear.

GHSA-8mw6-xphx-886m — both transports checked only the first URL and then let
the platform follow redirects on a dispatcher pinned to that first IP. Pinning
does not help: Node skips the pinned lookup for an IP-literal host, so a 307 to
127.0.0.1 or 169.254.169.254 connected straight through. They go through
safeFetchFollow now, which re-checks and re-pins per hop. safeFetch itself had
the same shape and carried it to sixteen more callers, several with a URL out of
a per-user setting, and one (pipeAsset) streams the response back — which would
have made a redirect into an internal service readable rather than blind. It
now routes through the same follower.

The third: following by hand opts out of the platform's own rules, and both
manual followers replayed the caller's init verbatim. undici drops
`Authorization` across origins itself, so converting ntfy to safeFetchFollow
would have re-opened the first advisory through a redirect. Credential headers
are now dropped on an origin change (same-host http to https stays, self-hosted
setups depend on it), and a 303 — or a 301/302 on a non-idempotent method —
drops the body, so a plugin's client_secret is not re-POSTed at a host the
first one merely pointed at. plugin-oauth pins maxRedirects to 0 like its OIDC
twin, and the Google Places calls get the timeout none of their nine call sites
passed.
This commit is contained in:
Maurice
2026-09-06 13:49:29 +02:00
committed by Maurice
parent 04b31ec2e5
commit 2d315df8bf
12 changed files with 355 additions and 46 deletions
@@ -201,11 +201,16 @@ export class PluginOAuthService {
// pinning the connection to the resolved IP, so a token_url that is a DNS name
// (or IPv6 literal) pointing at metadata can't reach it and can't DNS-rebind.
// Loopback/LAN stay reachable so a self-hosted internal IdP keeps working.
// maxRedirects 0, same as the OIDC twin: following one would hand
// client_secret to a second host, and a token endpoint has no legitimate
// reason to redirect. The timeout is not optional either — without it a
// hanging provider pins the request handler open indefinitely.
const resp = await safeFetchAdminConfigured(cfg.tokenUrl, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
body: body.toString(),
});
signal: AbortSignal.timeout(15000),
}, 0);
if (!resp.ok) throw new Error(`token endpoint returned ${resp.status}`);
const json = (await resp.json()) as Record<string, unknown>;
return {