diff options
| author | Fuwn <[email protected]> | 2026-06-01 15:37:21 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-06-01 15:37:21 +0000 |
| commit | 662631a27948d431e6bd37fed15077b1bcccc7f8 (patch) | |
| tree | b71ddebe246ffb51fb214664233f6a38a26a6211 /src/lib | |
| parent | fix(security): authorize shadowHide target in badges endpoint (IDOR) (diff) | |
| download | due.moe-662631a27948d431e6bd37fed15077b1bcccc7f8.tar.xz due.moe-662631a27948d431e6bd37fed15077b1bcccc7f8.zip | |
fix(security): allow-list web-push endpoints to stop SSRF
Stored push subscriptions carry a client-supplied `endpoint`, and the
notifications job POSTed to it with no host check, so a subscription
with an internal/metadata URL turned the Trigger.dev worker into a blind
SSRF primitive. Add isAllowedPushEndpoint (https + known vendor hosts:
FCM, Mozilla, Apple, WNS), skip non-conforming endpoints in the job, and
reject them at subscribe time. Browser-minted subscriptions always match
a vendor host, so real delivery is unchanged; a behaviour-gate test
asserts vendor endpoints pass and internal/non-https/look-alikes fail.
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/Utility/pushEndpoint.test.ts | 49 | ||||
| -rw-r--r-- | src/lib/Utility/pushEndpoint.ts | 26 |
2 files changed, 75 insertions, 0 deletions
diff --git a/src/lib/Utility/pushEndpoint.test.ts b/src/lib/Utility/pushEndpoint.test.ts new file mode 100644 index 00000000..5a2e90c8 --- /dev/null +++ b/src/lib/Utility/pushEndpoint.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { isAllowedPushEndpoint } from "./pushEndpoint"; + +describe("isAllowedPushEndpoint", () => { + // Behaviour gate: real subscriptions minted by the browser must keep working. + it("allows genuine vendor push endpoints", () => { + expect( + isAllowedPushEndpoint("https://fcm.googleapis.com/fcm/send/abc123"), + ).toBe(true); + expect( + isAllowedPushEndpoint( + "https://updates.push.services.mozilla.com/wpush/v2/abc", + ), + ).toBe(true); + expect(isAllowedPushEndpoint("https://web.push.apple.com/QABC/def")).toBe( + true, + ); + expect( + isAllowedPushEndpoint("https://db5p.notify.windows.com/w/?token=abc"), + ).toBe(true); + }); + + // The fix: arbitrary / internal / non-https endpoints must not be reachable. + it("blocks SSRF and non-vendor endpoints", () => { + expect(isAllowedPushEndpoint("http://fcm.googleapis.com/fcm/send/x")).toBe( + false, + ); // not https + expect(isAllowedPushEndpoint("https://evil.example.com/collect")).toBe( + false, + ); + expect( + isAllowedPushEndpoint("http://169.254.169.254/latest/meta-data"), + ).toBe(false); // cloud metadata + expect(isAllowedPushEndpoint("http://localhost:8080/internal")).toBe(false); + expect(isAllowedPushEndpoint("https://127.0.0.1/internal")).toBe(false); + expect(isAllowedPushEndpoint("http://10.0.0.5/admin")).toBe(false); + }); + + it("rejects look-alike hostnames", () => { + expect(isAllowedPushEndpoint("https://fcm.googleapis.com.evil.com/x")).toBe( + false, + ); + expect(isAllowedPushEndpoint("https://notfcm.googleapis.com/x")).toBe( + false, + ); + expect(isAllowedPushEndpoint("not a url")).toBe(false); + expect(isAllowedPushEndpoint("")).toBe(false); + }); +}); diff --git a/src/lib/Utility/pushEndpoint.ts b/src/lib/Utility/pushEndpoint.ts new file mode 100644 index 00000000..702164ae --- /dev/null +++ b/src/lib/Utility/pushEndpoint.ts @@ -0,0 +1,26 @@ +/** + * Web Push endpoints are minted by the browser's PushManager and always point at + * a known vendor push service — the user and the app never choose them. Limiting + * outbound delivery to these hosts stops the notifications job from being used as + * an SSRF primitive: a stored subscription with an arbitrary `endpoint` would + * otherwise have the worker POST to any URL, including internal/metadata hosts. + */ +const allowedPushHosts: RegExp[] = [ + /^fcm\.googleapis\.com$/, // Chrome, Edge, Brave, Opera, Samsung Internet + /(^|\.)push\.services\.mozilla\.com$/, // Firefox + /^web\.push\.apple\.com$/, // Safari / Apple + /(^|\.)notify\.windows\.com$/, // legacy Edge / Windows (WNS) +]; + +export const isAllowedPushEndpoint = (endpoint: string): boolean => { + try { + const url = new URL(endpoint); + + return ( + url.protocol === "https:" && + allowedPushHosts.some((host) => host.test(url.hostname)) + ); + } catch { + return false; + } +}; |