aboutsummaryrefslogtreecommitdiff
path: root/src/lib/Utility
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/Utility')
-rw-r--r--src/lib/Utility/pushEndpoint.test.ts49
-rw-r--r--src/lib/Utility/pushEndpoint.ts26
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;
+ }
+};