diff options
| -rw-r--r-- | src/lib/Utility/pushEndpoint.test.ts | 49 | ||||
| -rw-r--r-- | src/lib/Utility/pushEndpoint.ts | 26 | ||||
| -rw-r--r-- | src/routes/api/notifications/subscribe/+server.ts | 17 | ||||
| -rw-r--r-- | src/trigger/notifications.ts | 9 |
4 files changed, 93 insertions, 8 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; + } +}; diff --git a/src/routes/api/notifications/subscribe/+server.ts b/src/routes/api/notifications/subscribe/+server.ts index 51dbf340..b1913e5d 100644 --- a/src/routes/api/notifications/subscribe/+server.ts +++ b/src/routes/api/notifications/subscribe/+server.ts @@ -3,6 +3,7 @@ import { safeUserIdentity } from "$lib/Data/AniList/identity"; import { setUserSubscription } from "$lib/Database/SB/User/notifications"; import { decodeAuthCookieOrNull } from "$lib/Effect/authCookie"; import { decodeRequestJsonOrThrow } from "$lib/Effect/requestBody"; +import { isAllowedPushEndpoint } from "$lib/Utility/pushEndpoint"; const unauthorised = new Response("Unauthorised", { status: 401 }); @@ -20,12 +21,20 @@ export const POST = async ({ cookies, request, url }) => { if (!userId) return unauthorised; + const subscription = await decodeRequestJsonOrThrow( + request, + Schema.Record(Schema.String, Schema.Unknown), + ); + + if ( + typeof subscription.endpoint !== "string" || + !isAllowedPushEndpoint(subscription.endpoint) + ) + return new Response("Invalid push endpoint", { status: 400 }); + await setUserSubscription( userId, - (await decodeRequestJsonOrThrow( - request, - Schema.Record(Schema.String, Schema.Unknown), - )) as unknown as JSON, + subscription as unknown as JSON, fingerprint, ); diff --git a/src/trigger/notifications.ts b/src/trigger/notifications.ts index e36f913f..3daca47e 100644 --- a/src/trigger/notifications.ts +++ b/src/trigger/notifications.ts @@ -1,6 +1,7 @@ import { createClient } from "@supabase/supabase-js"; import { envvars, schedules } from "@trigger.dev/sdk"; import * as webpush from "web-push"; +import { isAllowedPushEndpoint } from "../lib/Utility/pushEndpoint"; const isExpiredSubscriptionError = (error: unknown) => { const statusCode = @@ -94,11 +95,11 @@ export const notificationsTask = schedules.task({ for (const subscription of await getUserSubscriptions()) { const endpoint = subscriptionEndpoint(subscription.subscription); - if (endpoint) { - if (seenEndpoints.has(endpoint)) continue; + if (!endpoint || !isAllowedPushEndpoint(endpoint)) continue; - seenEndpoints.add(endpoint); - } + if (seenEndpoints.has(endpoint)) continue; + + seenEndpoints.add(endpoint); try { await webpush.sendNotification(subscription.subscription, "."); |