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
+85 -7
View File
@@ -190,6 +190,7 @@ export async function safeFetchAdminConfigured(
responseTimeoutMs?: number,
): Promise<Response> {
let currentUrl = url;
let hopInit = init;
for (let hop = 0; ; hop++) {
let parsed: URL;
@@ -213,7 +214,7 @@ export async function safeFetchAdminConfigured(
}
const dispatcher = createPinnedDispatcher(resolvedIp, true, responseTimeoutMs);
const response = await fetch(currentUrl, { ...init, redirect: 'manual', dispatcher } as any);
const response = await fetch(currentUrl, { ...hopInit, redirect: 'manual', dispatcher } as any);
// Only a 3xx WITH a Location header is a redirect we follow; anything else
// (2xx/4xx/5xx, or a 3xx with no Location) is the final response.
@@ -234,6 +235,7 @@ export async function safeFetchAdminConfigured(
// Drain the redirect body so the connection can be reused/closed, then loop
// to re-resolve + re-check + re-pin the next hop.
void response.body?.cancel().catch(() => {});
hopInit = nextHopInit(hopInit, currentUrl, nextUrl, status);
currentUrl = nextUrl;
}
}
@@ -275,19 +277,93 @@ export interface SafeFetchOptions {
* Pass `{ rejectUnauthorized: false }` for targets that use self-signed TLS
* certificates (e.g. a Synology NAS on a local network). The SSRF guard still
* applies — only the TLS certificate check is relaxed.
*
* Redirects are followed through safeFetchFollow, so every hop is re-checked and
* re-pinned. It used to hand the platform a `redirect: 'follow'` with a
* dispatcher pinned to the FIRST hop only — the same shape as
* GHSA-8mw6-xphx-886m, and pinning does not help there because Node skips the
* pinned lookup for an IP-literal host. Sixteen callers ride on this, several
* with a URL out of a per-user setting (Immich, Synology, AirTrail), and one
* (pipeAsset) streams the response back to the caller, which would have made a
* redirect to an internal service readable rather than blind.
*/
export async function safeFetch(url: string, init?: RequestInit, options?: SafeFetchOptions): Promise<Response> {
const ssrf = await checkSsrf(url);
if (!ssrf.allowed) {
throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
return safeFetchFollow(url, init, options);
}
/**
* Headers that must not survive a hop to another origin. Following a redirect
* by hand means the platform's own protection does not apply: undici drops
* `Authorization` across origins itself (Fetch, "HTTP-redirect fetch" step 13),
* so a manual follower that replays the caller's headers verbatim is strictly
* weaker than the fetch it replaced. The list is credentials-only on purpose —
* `User-Agent` has to survive, or the goo.gl chain in maps.service resolves to
* a different page than the one its coordinates are parsed out of.
*/
const CREDENTIAL_HEADERS = [
'authorization', 'proxy-authorization', 'cookie', 'cookie2',
'x-api-key', 'api-key', 'x-auth-token',
];
/** Headers that describe the body, and go when the body does. */
const BODY_HEADERS = ['content-type', 'content-length', 'content-encoding', 'content-language', 'content-location'];
/**
* Cross-origin per Fetch, with one carve-out: the same hostname moving from
* http to https is an upgrade, not a host change. A self-hosted IdP or ntfy
* behind a redirecting proxy does exactly that, and a strict origin compare
* would strip the credential those setups depend on.
*/
function isCrossOriginHop(from: string, to: string): boolean {
let a: URL, b: URL;
try { a = new URL(from); b = new URL(to); } catch { return true; }
if (a.origin === b.origin) return false;
return !(a.hostname === b.hostname && a.protocol === 'http:' && b.protocol === 'https:');
}
function stripHeaders(init: RequestInit | undefined, names: string[]): RequestInit | undefined {
if (!init?.headers) return init;
const headers = new Headers(init.headers as ConstructorParameters<typeof Headers>[0]);
for (const name of names) headers.delete(name);
return { ...init, headers };
}
/**
* The init for the next hop of a manual redirect follow. Both followers below
* go through this: following by hand opts out of the platform's own rules, so
* they have to be restated once rather than forgotten twice.
*/
function nextHopInit(
init: RequestInit | undefined,
currentUrl: string,
nextUrl: string,
status: number,
keepCredentials = false,
): RequestInit | undefined {
let next = init;
if (!keepCredentials && isCrossOriginHop(currentUrl, nextUrl)) {
next = stripHeaders(next, CREDENTIAL_HEADERS);
}
const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!, options?.rejectUnauthorized ?? true);
return fetch(url, { ...init, dispatcher } as any);
// RFC 9110 15.4.4 for 303, and 15.4.2/15.4.3 plus what every client actually
// does for 301/302 on a POST: the next hop is a GET with no body. Replaying
// the body would re-POST it — a plugin's client_secret included — at a host
// the first one merely pointed at.
const method = (next?.method ?? 'GET').toUpperCase();
if (status === 303 || ((status === 301 || status === 302) && method !== 'GET' && method !== 'HEAD')) {
next = { ...stripHeaders(next, BODY_HEADERS), method: 'GET', body: undefined };
}
return next;
}
export interface SafeFetchFollowOptions extends SafeFetchOptions {
/** Maximum number of redirects to follow before giving up. Defaults to 5. */
maxRedirects?: number;
/**
* Keep credential headers across an origin change. Off by default, and no
* caller needs it today — it exists so a future one that genuinely does has
* to say so here rather than route around the guard.
*/
keepCredentialsOnRedirect?: boolean;
/**
* When true, private/internal IPs that ALLOW_INTERNAL_NETWORK would normally
* permit are still blocked (matches `checkSsrf(url, true)`). Loopback and
@@ -323,6 +399,7 @@ export async function safeFetchFollow(
const bypassInternalIpAllowed = options?.bypassInternalIpAllowed ?? false;
let currentUrl = url;
let hopInit = init;
for (let hop = 0; ; hop++) {
const ssrf = await checkSsrf(currentUrl, bypassInternalIpAllowed);
@@ -332,7 +409,7 @@ export async function safeFetchFollow(
const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!, rejectUnauthorized);
const response = await fetch(currentUrl, {
...init,
...hopInit,
redirect: 'manual',
dispatcher,
} as any);
@@ -360,6 +437,7 @@ export async function safeFetchFollow(
throw new SsrfBlockedError('Invalid redirect location');
}
void response.body?.cancel().catch(() => {});
hopInit = nextHopInit(hopInit, currentUrl, nextUrl, status, options?.keepCredentialsOnRedirect);
currentUrl = nextUrl;
}
}