mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user