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
@@ -21,7 +21,29 @@ const { sendMailMock } = vi.hoisted(() => ({ sendMailMock: vi.fn().mockResolvedV
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: sendMailMock, verify: vi.fn() })) } }));
vi.stubGlobal('fetch', vi.fn());
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
vi.mock('../../../src/utils/ssrfGuard', () => ({ checkSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '1.2.3.4' })), createPinnedDispatcher: vi.fn(() => ({})) }));
vi.mock('../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
const checkSsrf = vi.fn(async (_url: string) => ({ allowed: true, resolvedIp: '1.2.3.4' }));
return {
checkSsrf,
createPinnedDispatcher: vi.fn(() => ({})),
SsrfBlockedError,
// The notification transports go through safeFetchFollow now, so the fake
// has to guard and then hand over to the stubbed fetch the way it does.
safeFetchFollow: vi.fn(async (url: string, init?: RequestInit) => {
const verdict = await checkSsrf(url);
if (!verdict.allowed) {
throw new SsrfBlockedError((verdict as { error?: string }).error ?? 'Request blocked by SSRF guard');
}
return fetch(url, init);
}),
};
});
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
+22 -4
View File
@@ -52,10 +52,28 @@ const { mockCheckSsrf, mockCreatePinnedDispatcher } = vi.hoisted(() => ({
),
mockCreatePinnedDispatcher: vi.fn(() => ({})),
}));
vi.mock('../../../src/utils/ssrfGuard', () => ({
checkSsrf: mockCheckSsrf,
createPinnedDispatcher: mockCreatePinnedDispatcher,
}));
vi.mock('../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
return {
checkSsrf: mockCheckSsrf,
createPinnedDispatcher: mockCreatePinnedDispatcher,
SsrfBlockedError,
// The notification transports go through safeFetchFollow now, so the fake
// has to guard and then hand over to the stubbed fetch the way it does.
safeFetchFollow: vi.fn(async (url: string, init?: RequestInit) => {
const verdict = await (mockCheckSsrf)(url);
if (!verdict.allowed) {
throw new SsrfBlockedError((verdict as { error?: string }).error ?? 'Request blocked by SSRF guard');
}
return fetch(url, init);
}),
};
});
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
@@ -18,16 +18,37 @@ vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: vi.fn() })) } }));
vi.stubGlobal('fetch', vi.fn());
// ssrfGuard is mocked per-test in the SSRF describe block; default passes all
vi.mock('../../../src/utils/ssrfGuard', () => ({
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
createPinnedDispatcher: vi.fn(() => ({})),
}));
// ssrfGuard is mocked per-test in the SSRF describe block; default passes all.
// safeFetchFollow is modelled rather than stubbed away: the transports call it
// instead of fetch now, and the assertions below still speak in terms of the
// stubbed global fetch, so the fake has to guard first and then hand the request
// over exactly like the real one does.
vi.mock('../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
const checkSsrf = vi.fn(async (_url: string) => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' }));
return {
checkSsrf,
createPinnedDispatcher: vi.fn(() => ({})),
SsrfBlockedError,
safeFetchFollow: vi.fn(async (url: string, init?: RequestInit) => {
const verdict = await checkSsrf(url);
if (!verdict.allowed) {
throw new SsrfBlockedError((verdict as { error?: string }).error ?? 'Request blocked by SSRF guard');
}
return fetch(url, init);
}),
};
});
import { DatabaseService } from '../../../src/nest/database/database.service';
import { getEventText, buildEmailHtml } from '../../../src/nest/notifications/mailer/email-html';
import { WebhookService, buildWebhookBody } from '../../../src/nest/notifications/transports/webhook.service';
import { NtfyService, resolveNtfyUrl, resolveAdminNtfyUrl, type NtfyConfig } from '../../../src/nest/notifications/transports/ntfy.service';
import { NtfyService, resolveNtfyUrl, resolveAdminNtfyUrl, resolveNtfyToken, type NtfyConfig } from '../../../src/nest/notifications/transports/ntfy.service';
// The transports are providers now; the db stub below is the same one the
// module-level `db` mock used to supply, handed in instead of imported.
@@ -333,6 +354,49 @@ afterAll(() => vi.unstubAllGlobals());
// ── resolveNtfyUrl ────────────────────────────────────────────────────────────
// GHSA-7pqc-fj3c-9346 — this is the live send path, reachable with no test route
// involved at all: anyone can save their own ntfy_server, and the token used to
// fall back to the operator's whatever host that named.
describe('resolveNtfyToken', () => {
const adminCfg: NtfyConfig = { server: 'https://ntfy.operator.example', topic: 'ops', token: 'operator-token' };
it('withholds the operator token when the user points at their own server', () => {
const user: NtfyConfig = { server: 'https://listener.attacker.example', topic: 't', token: null };
expect(resolveNtfyToken(adminCfg, user)).toBeNull();
});
it('sends it when the user rides the operator server, which is the shared setup', () => {
const user: NtfyConfig = { server: null, topic: 't', token: null };
expect(resolveNtfyToken(adminCfg, user)).toBe('operator-token');
});
it('matches on a trailing slash, which either side may carry', () => {
const user: NtfyConfig = { server: 'https://ntfy.operator.example/', topic: 't', token: null };
expect(resolveNtfyToken(adminCfg, user)).toBe('operator-token');
});
it('does not treat the http twin as the same server', () => {
const user: NtfyConfig = { server: 'http://ntfy.operator.example', topic: 't', token: null };
expect(resolveNtfyToken(adminCfg, user)).toBeNull();
});
it("the user's own token always wins, wherever it is going", () => {
const user: NtfyConfig = { server: 'https://elsewhere.example', topic: 't', token: 'mine' };
expect(resolveNtfyToken(adminCfg, user)).toBe('mine');
});
it('both sides unset means ntfy.sh against ntfy.sh, still a match', () => {
const noServer: NtfyConfig = { server: null, topic: 'ops', token: 'operator-token' };
expect(resolveNtfyToken(noServer, { server: null, topic: 't', token: null })).toBe('operator-token');
});
it('honours an explicit server override, which is what the test route passes', () => {
const user: NtfyConfig = { server: null, topic: 't', token: null };
expect(resolveNtfyToken(adminCfg, user, 'https://listener.attacker.example')).toBeNull();
expect(resolveNtfyToken(adminCfg, user, 'https://ntfy.operator.example')).toBe('operator-token');
});
});
describe('resolveNtfyUrl', () => {
const adminCfg: NtfyConfig = { server: 'https://ntfy.sh', topic: 'admin-topic', token: null };
@@ -102,6 +102,42 @@ describe('NotificationsController (parity with the legacy /api/notifications rou
await makeController({ testNtfy, userNtfyConfig, adminNtfyConfig }).testNtfy(user, { token: MASKED });
expect(testNtfy).toHaveBeenCalledWith({ topic: 'saved-topic', server: 'https://ntfy.me', token: 'saved-token' });
});
// GHSA-7pqc-fj3c-9346 — the caller picks `server`, so falling back to the
// admin token unconditionally handed the operator's decrypted credential to
// any host an authenticated user named.
it("withholds the operator token when the request names a server that is not the operator's", async () => {
const testNtfy = vi.fn().mockResolvedValue({ success: true });
const userNtfyConfig = vi.fn().mockReturnValue({ topic: 'mine', server: null, token: null });
const adminNtfyConfig = vi.fn().mockReturnValue({ server: 'https://ntfy.operator.example', token: 'operator-token' });
await makeController({ testNtfy, userNtfyConfig, adminNtfyConfig }).testNtfy(user, { server: 'https://listener.attacker.example' });
expect(testNtfy).toHaveBeenCalledWith({ topic: 'mine', server: 'https://listener.attacker.example', token: null });
});
it("still sends the operator token to the operator's own server", async () => {
const testNtfy = vi.fn().mockResolvedValue({ success: true });
const userNtfyConfig = vi.fn().mockReturnValue({ topic: 'mine', server: null, token: null });
const adminNtfyConfig = vi.fn().mockReturnValue({ server: 'https://ntfy.operator.example/', token: 'operator-token' });
// Trailing slash on one side only: the shared setup, and it must still match.
await makeController({ testNtfy, userNtfyConfig, adminNtfyConfig }).testNtfy(user, { server: 'https://ntfy.operator.example' });
expect(testNtfy).toHaveBeenCalledWith({ topic: 'mine', server: 'https://ntfy.operator.example', token: 'operator-token' });
});
it('a plain http twin of the operator server does not pull the token', async () => {
const testNtfy = vi.fn().mockResolvedValue({ success: true });
const userNtfyConfig = vi.fn().mockReturnValue({ topic: 'mine', server: null, token: null });
const adminNtfyConfig = vi.fn().mockReturnValue({ server: 'https://ntfy.operator.example', token: 'operator-token' });
await makeController({ testNtfy, userNtfyConfig, adminNtfyConfig }).testNtfy(user, { server: 'http://ntfy.operator.example' });
expect(testNtfy).toHaveBeenCalledWith({ topic: 'mine', server: 'http://ntfy.operator.example', token: null });
});
it('a user with their own token keeps using it anywhere', async () => {
const testNtfy = vi.fn().mockResolvedValue({ success: true });
const userNtfyConfig = vi.fn().mockReturnValue({ topic: 'mine', server: null, token: 'my-own-token' });
const adminNtfyConfig = vi.fn().mockReturnValue({ server: 'https://ntfy.operator.example', token: 'operator-token' });
await makeController({ testNtfy, userNtfyConfig, adminNtfyConfig }).testNtfy(user, { server: 'https://somewhere.else.example' });
expect(testNtfy).toHaveBeenCalledWith({ topic: 'mine', server: 'https://somewhere.else.example', token: 'my-own-token' });
});
});
describe('in-app list + counts', () => {
@@ -64,10 +64,29 @@ vi.stubGlobal('fetch', fetchMock);
// RealtimeService imports both names from src/websocket, so the mock must
// export both even though only broadcastToUser is asserted here.
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: broadcastMock }));
vi.mock('../../../src/utils/ssrfGuard', () => ({
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
createPinnedDispatcher: vi.fn(() => ({})),
}));
vi.mock('../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
const checkSsrf = vi.fn(async (_url: string) => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' }));
return {
checkSsrf,
createPinnedDispatcher: vi.fn(() => ({})),
SsrfBlockedError,
// The notification transports go through safeFetchFollow now, so the fake
// has to guard and then hand over to the stubbed fetch the way it does.
safeFetchFollow: vi.fn(async (url: string, init?: RequestInit) => {
const verdict = await checkSsrf(url);
if (!verdict.allowed) {
throw new SsrfBlockedError((verdict as { error?: string }).error ?? 'Request blocked by SSRF guard');
}
return fetch(url, init);
}),
};
});
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';