aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/graphql/user/resolvers.ts3
-rw-r--r--src/lib/Database/SB/User/preferences.ts3
-rw-r--r--src/lib/Utility/authorisation.test.ts20
-rw-r--r--src/lib/Utility/authorisation.ts9
-rw-r--r--src/lib/Utility/pushEndpoint.test.ts49
-rw-r--r--src/lib/Utility/pushEndpoint.ts26
-rw-r--r--src/lib/Utility/sanitizeCss.test.ts76
-rw-r--r--src/lib/Utility/sanitizeCss.ts57
-rw-r--r--src/lib/Utility/sanitizeHtml.test.ts57
-rw-r--r--src/lib/Utility/sanitizeHtml.ts32
-rw-r--r--src/routes/api/badges/+server.ts14
-rw-r--r--src/routes/api/notifications/subscribe/+server.ts17
-rw-r--r--src/routes/updates/+page.svelte34
-rw-r--r--src/routes/user/[user]/badges/+page.svelte13
-rw-r--r--src/trigger/notifications.ts9
15 files changed, 390 insertions, 29 deletions
diff --git a/src/graphql/user/resolvers.ts b/src/graphql/user/resolvers.ts
index 905a2b4f..a90c6c4c 100644
--- a/src/graphql/user/resolvers.ts
+++ b/src/graphql/user/resolvers.ts
@@ -25,6 +25,7 @@ import {
type UserPreferences,
} from "$lib/Database/SB/User/preferences";
import { decodeAuthCookieOrNull } from "$lib/Effect/authCookie";
+import { isOwnerOrPrivileged } from "$lib/Utility/authorisation";
import privilegedUser from "$lib/Utility/privilegedUser";
import type { Badge, Resolvers as RootResolvers, WithIndex } from "../$types";
@@ -110,7 +111,7 @@ const ensureOwnerOrPrivileged = (
authorised: boolean,
targetUserId: number,
) => {
- if (!authorised && identity.id !== targetUserId)
+ if (!isOwnerOrPrivileged(identity.id, targetUserId, authorised))
throw new Error("Unauthorized");
};
diff --git a/src/lib/Database/SB/User/preferences.ts b/src/lib/Database/SB/User/preferences.ts
index d1f03ee8..755f21b2 100644
--- a/src/lib/Database/SB/User/preferences.ts
+++ b/src/lib/Database/SB/User/preferences.ts
@@ -1,3 +1,4 @@
+import { sanitizeBadgeWallCss } from "$lib/Utility/sanitizeCss";
import sb from "../../sb.server";
export interface UserPreferences {
@@ -122,7 +123,7 @@ export const toggleHideAWCBadges = async (userId: number) => {
export const setCSS = async (userId: number, css: string) => {
return await setUserPreferences(userId, {
updated_at: new Date().toISOString(),
- badge_wall_css: css,
+ badge_wall_css: sanitizeBadgeWallCss(css),
});
};
diff --git a/src/lib/Utility/authorisation.test.ts b/src/lib/Utility/authorisation.test.ts
new file mode 100644
index 00000000..0027782b
--- /dev/null
+++ b/src/lib/Utility/authorisation.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { isOwnerOrPrivileged } from "./authorisation";
+
+describe("isOwnerOrPrivileged", () => {
+ it("allows the owner to act on their own resources", () => {
+ expect(isOwnerOrPrivileged(7, 7, false)).toBe(true);
+ });
+
+ it("allows a privileged user to act on anyone", () => {
+ expect(isOwnerOrPrivileged(7, 999, true)).toBe(true);
+ });
+
+ it("blocks a non-privileged user acting on someone else (the IDOR case)", () => {
+ expect(isOwnerOrPrivileged(7, 999, false)).toBe(false);
+ });
+
+ it("allows a privileged owner (both conditions)", () => {
+ expect(isOwnerOrPrivileged(7, 7, true)).toBe(true);
+ });
+});
diff --git a/src/lib/Utility/authorisation.ts b/src/lib/Utility/authorisation.ts
new file mode 100644
index 00000000..c6b64414
--- /dev/null
+++ b/src/lib/Utility/authorisation.ts
@@ -0,0 +1,9 @@
+/**
+ * Whether a caller may act on resources belonging to `targetUserId`: either the
+ * caller owns them, or the caller is a privileged (allow-listed) user.
+ */
+export const isOwnerOrPrivileged = (
+ callerUserId: number,
+ targetUserId: number,
+ privileged: boolean,
+) => privileged || callerUserId === targetUserId;
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/lib/Utility/sanitizeCss.test.ts b/src/lib/Utility/sanitizeCss.test.ts
new file mode 100644
index 00000000..f6c22364
--- /dev/null
+++ b/src/lib/Utility/sanitizeCss.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+import { sanitizeBadgeWallCss } from "./sanitizeCss";
+
+describe("sanitizeBadgeWallCss", () => {
+ // Behaviour gate: the CSS people actually write for their badge wall survives.
+ it("preserves ordinary rules, declarations and values", () => {
+ const out = sanitizeBadgeWallCss(
+ ".badge { color: red; opacity: 0.5; border-radius: 8px; }",
+ );
+
+ expect(out).toContain("color");
+ expect(out).toContain("red");
+ expect(out).toContain("border-radius");
+ });
+
+ it("preserves backdrop-filter, content, url() and at-rules", () => {
+ expect(
+ sanitizeBadgeWallCss(".x { backdrop-filter: blur(4px); }"),
+ ).toContain("backdrop-filter");
+ expect(sanitizeBadgeWallCss('.x::before { content: "★"; }')).toContain(
+ "content",
+ );
+ expect(
+ sanitizeBadgeWallCss(
+ ".x { background: url(https://cdn.due.moe/a.png); }",
+ ),
+ ).toContain("url(https://cdn.due.moe/a.png)");
+ expect(
+ sanitizeBadgeWallCss("@media (min-width: 1px) { .x { color: blue; } }"),
+ ).toContain("@media");
+ expect(
+ sanitizeBadgeWallCss(
+ "@keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }",
+ ),
+ ).toContain("@keyframes");
+ });
+
+ it("returns empty string for nullish input", () => {
+ expect(sanitizeBadgeWallCss("")).toBe("");
+ // @ts-expect-error exercising defensive nullish handling
+ expect(sanitizeBadgeWallCss(undefined)).toBe("");
+ });
+
+ // The fix: dangerous constructs are removed while surrounding CSS is kept.
+ it("strips @import, behavior, -moz-binding, expression and js: urls", () => {
+ const imported = sanitizeBadgeWallCss(
+ "@import url(https://evil.example.com/x.css); .x { color: red; }",
+ );
+ expect(imported).not.toContain("@import");
+ expect(imported).toContain("color");
+
+ expect(
+ sanitizeBadgeWallCss(".x { behavior: url(evil.htc); color: red; }"),
+ ).not.toContain("behavior");
+ expect(
+ sanitizeBadgeWallCss(".x { -moz-binding: url(evil.xml#x); }"),
+ ).not.toContain("-moz-binding");
+ expect(
+ sanitizeBadgeWallCss(".x { width: expression(alert(1)); }"),
+ ).not.toContain("expression");
+ expect(
+ sanitizeBadgeWallCss(".x { background: url(javascript:alert(1)); }"),
+ ).not.toContain("javascript:");
+ });
+
+ it("drops <style> break-out attempts entirely", () => {
+ const out = sanitizeBadgeWallCss(
+ ".a { color: red; } </style><script>window.x=1</script> .b { color: blue; }",
+ );
+
+ expect(out).not.toContain("<script");
+ expect(out).not.toContain("</style");
+ expect(out).not.toContain("window.x");
+ expect(out).toContain("color");
+ });
+});
diff --git a/src/lib/Utility/sanitizeCss.ts b/src/lib/Utility/sanitizeCss.ts
new file mode 100644
index 00000000..976fa6a1
--- /dev/null
+++ b/src/lib/Utility/sanitizeCss.ts
@@ -0,0 +1,57 @@
+import * as csstree from "css-tree";
+
+const blockedProperties = new Set(["behavior", "-moz-binding"]);
+const dangerousValue = /expression\s*\(|javascript:|vbscript:/i;
+
+/**
+ * Sanitise user-supplied badge-wall CSS at the trust boundary (write time), so
+ * the stored value is safe regardless of how it is later rendered. Parsing with
+ * css-tree (leniently, like a browser) drops anything that isn't valid CSS —
+ * including `</style>` break-out attempts — and we additionally remove the few
+ * constructs that can load resources or (in legacy engines) run code:
+ * `@import`, `behavior`/`-moz-binding`, and `expression()`/`javascript:` values.
+ *
+ * This is defence in depth: rendering goes through `textContent` (no HTML
+ * parsing, so no break-out) and the CSP blocks inline script regardless.
+ */
+export const sanitizeBadgeWallCss = (css: string): string => {
+ if (!css) return "";
+
+ let ast: csstree.CssNode;
+
+ try {
+ ast = csstree.parse(css, { onParseError: () => {} });
+ } catch {
+ return "";
+ }
+
+ csstree.walk(ast, (node, item, list) => {
+ if (!list || !item) return;
+
+ if (node.type === "Atrule" && node.name.toLowerCase() === "import") {
+ list.remove(item);
+ } else if (
+ node.type === "Rule" &&
+ csstree.generate(node.prelude).includes("<")
+ ) {
+ // css-tree keeps an unparseable selector as a Raw prelude, so a
+ // `</style><script>…` break-out shows up as a rule whose selector
+ // contains `<` (never valid in a real selector). Drop the rule.
+ list.remove(item);
+ } else if (node.type === "Raw" && /[<>]/.test(node.value)) {
+ list.remove(item);
+ } else if (
+ node.type === "Declaration" &&
+ (blockedProperties.has(node.property.toLowerCase()) ||
+ dangerousValue.test(csstree.generate(node.value)))
+ ) {
+ list.remove(item);
+ }
+ });
+
+ try {
+ return csstree.generate(ast);
+ } catch {
+ return "";
+ }
+};
diff --git a/src/lib/Utility/sanitizeHtml.test.ts b/src/lib/Utility/sanitizeHtml.test.ts
new file mode 100644
index 00000000..1094635e
--- /dev/null
+++ b/src/lib/Utility/sanitizeHtml.test.ts
@@ -0,0 +1,57 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from "vitest";
+import { sanitizeFeedHtml } from "./sanitizeHtml";
+
+describe("sanitizeFeedHtml", () => {
+ // Behaviour gate: the formatting real feeds use must survive untouched.
+ it("preserves entities, inline formatting and safe links", () => {
+ expect(sanitizeFeedHtml("Fruits &amp; Vegetables")).toBe(
+ "Fruits &amp; Vegetables",
+ );
+ expect(sanitizeFeedHtml("<i>italic</i> and <b>bold</b>")).toBe(
+ "<i>italic</i> and <b>bold</b>",
+ );
+ expect(sanitizeFeedHtml("Vol. 1 <em>Ch.</em> 5")).toBe(
+ "Vol. 1 <em>Ch.</em> 5",
+ );
+ expect(
+ sanitizeFeedHtml('<a href="https://example.com/x">link</a>'),
+ ).toContain('href="https://example.com/x"');
+ expect(sanitizeFeedHtml("line<br>break")).toContain("<br");
+ });
+
+ it("returns empty string for nullish input", () => {
+ expect(sanitizeFeedHtml(undefined)).toBe("");
+ expect(sanitizeFeedHtml(null)).toBe("");
+ expect(sanitizeFeedHtml("")).toBe("");
+ });
+
+ // The fix: scripts, handlers, dangerous tags and URLs must be removed.
+ it("strips scripts, event handlers and dangerous tags/urls", () => {
+ const script = sanitizeFeedHtml("<script>alert(1)</script>safe");
+ expect(script).not.toContain("script");
+ expect(script).toContain("safe");
+
+ const onerror = sanitizeFeedHtml("before<img src=x onerror=alert(1)>after");
+ expect(onerror).not.toContain("onerror");
+ expect(onerror).not.toContain("<img");
+ expect(onerror).toContain("before");
+ expect(onerror).toContain("after");
+
+ expect(
+ sanitizeFeedHtml('<a href="javascript:alert(1)">x</a>'),
+ ).not.toContain("javascript:");
+ expect(
+ sanitizeFeedHtml('<iframe src="https://evil.example.com"></iframe>'),
+ ).not.toContain("iframe");
+ expect(
+ sanitizeFeedHtml(
+ '<meta http-equiv="refresh" content="0;url=https://evil.example.com">',
+ ),
+ ).not.toContain("meta");
+ expect(sanitizeFeedHtml("<style>body{display:none}</style>")).not.toContain(
+ "style",
+ );
+ expect(sanitizeFeedHtml('<div onclick="steal()">text</div>')).toBe("text");
+ });
+});
diff --git a/src/lib/Utility/sanitizeHtml.ts b/src/lib/Utility/sanitizeHtml.ts
new file mode 100644
index 00000000..3d0229e4
--- /dev/null
+++ b/src/lib/Utility/sanitizeHtml.ts
@@ -0,0 +1,32 @@
+import DOMPurify from "dompurify";
+
+const feedConfig = {
+ ALLOWED_TAGS: [
+ "a",
+ "b",
+ "i",
+ "em",
+ "strong",
+ "u",
+ "s",
+ "br",
+ "p",
+ "span",
+ "small",
+ "sup",
+ "sub",
+ "code",
+ ],
+ ALLOWED_ATTR: ["href", "title"],
+ ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
+};
+
+/**
+ * Sanitise HTML coming from third-party RSS feeds before it reaches an `{@html}`
+ * sink. Keeps the light formatting these feeds actually use (HTML entities,
+ * `<i>`/`<b>`/`<a href>`) and strips anything that could inject content or
+ * behaviour: `<script>`, event-handler attributes, `<iframe>`/`<meta>`/`<style>`,
+ * `javascript:` URLs, and so on. Browser-only — call it from client code.
+ */
+export const sanitizeFeedHtml = (html: string | undefined | null): string =>
+ html ? DOMPurify.sanitize(html, feedConfig) : "";
diff --git a/src/routes/api/badges/+server.ts b/src/routes/api/badges/+server.ts
index 2673273c..10b63125 100644
--- a/src/routes/api/badges/+server.ts
+++ b/src/routes/api/badges/+server.ts
@@ -16,6 +16,7 @@ import {
import { decodeAuthCookieOrNull } from "$lib/Effect/authCookie";
import { decodeRequestJsonOrThrow } from "$lib/Effect/requestBody";
import { appOrigin, appOriginHeaders } from "$lib/Utility/appOrigin";
+import { isOwnerOrPrivileged } from "$lib/Utility/authorisation";
import privilegedUser from "$lib/Utility/privilegedUser";
const unauthorised = () => new Response("Unauthorised", { status: 401 });
@@ -76,11 +77,14 @@ export const PUT = async ({ cookies, url, request }) => {
if (!identity) return unauthorised();
const authorised = privilegedUser(identity.id);
- if (url.searchParams.get("shadowHide"))
- await setShadowHidden(
- Number(url.searchParams.get("shadowHide")),
- authorised,
- );
+ if (url.searchParams.get("shadowHide")) {
+ const targetUserId = Number(url.searchParams.get("shadowHide"));
+
+ if (!isOwnerOrPrivileged(identity.id, targetUserId, authorised))
+ return unauthorised();
+
+ await setShadowHidden(targetUserId, authorised);
+ }
if (url.searchParams.get("import") || undefined) {
const importedBadges = await decodeRequestJsonOrThrow(
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/routes/updates/+page.svelte b/src/routes/updates/+page.svelte
index 357a5906..bbcb087d 100644
--- a/src/routes/updates/+page.svelte
+++ b/src/routes/updates/+page.svelte
@@ -5,6 +5,7 @@ import HeadTitle from "$lib/Home/HeadTitle.svelte";
import Skeleton from "$lib/Loading/Skeleton.svelte";
import { createHeightObserver } from "$lib/Utility/html";
import root from "$lib/Utility/root";
+import { sanitizeFeedHtml } from "$lib/Utility/sanitizeHtml";
import locale from "$stores/locale";
let feed:
@@ -22,6 +23,7 @@ let novelFeed:
}[];
};
}
+ | null
| undefined;
let startTime: number;
let mangaEndTime: number;
@@ -31,14 +33,42 @@ let directLink = browser
: false;
let removeHeightObserver: (() => void) | undefined;
+const fetchJson = async (path: string) => {
+ try {
+ const response = await fetch(root(path));
+
+ return response.ok ? await response.json() : null;
+ } catch {
+ return null;
+ }
+};
+
onMount(async () => {
removeHeightObserver = createHeightObserver(false);
startTime = performance.now();
- novelFeed = await (await fetch(root("/api/updates/all-novels"))).json();
+
+ const allNovels = await fetchJson("/api/updates/all-novels");
+
+ if (allNovels?.data?.items)
+ for (const item of allNovels.data.items) {
+ if (item.postfix) item.postfix = sanitizeFeedHtml(item.postfix);
+ if (item.series) item.series.name = sanitizeFeedHtml(item.series.name);
+ }
+
+ novelFeed = allNovels?.data?.items ? allNovels : null;
novelEndTime = performance.now() - startTime;
startTime = performance.now();
- feed = await (await fetch(root("/api/updates/manga"))).json();
+
+ const mangaFeed = await fetchJson("/api/updates/manga");
+
+ if (mangaFeed?.items)
+ for (const item of mangaFeed.items) {
+ item.title = sanitizeFeedHtml(item.title);
+ item.content = sanitizeFeedHtml(item.content);
+ }
+
+ feed = mangaFeed?.items ? mangaFeed : null;
mangaEndTime = performance.now() - startTime;
});
diff --git a/src/routes/user/[user]/badges/+page.svelte b/src/routes/user/[user]/badges/+page.svelte
index dc43fc08..605f5675 100644
--- a/src/routes/user/[user]/badges/+page.svelte
+++ b/src/routes/user/[user]/badges/+page.svelte
@@ -40,21 +40,10 @@ $: preferences = $BadgeWallUser.fetching
: ($BadgeWallUser.data?.User?.preferences as Preferences | undefined);
$: if (browser && preferences && preferences.badge_wall_css) {
- const sanitise = (css: string) =>
- css
- .replace(/\/\*[\s\S]*?\*\//g, "")
- .replace(/<\/?[^>]+(>|$)/g, "")
- .replace(
- /(expression|javascript|vbscript|onerror|onload|onclick|onmouseover|onmouseout|onmouseup|onmousedown|onkeydown|onkeyup|onkeypress|onblur|onfocus|onsubmit|onreset|onselect|onchange|ondblclick):/gi,
- "",
- )
- .replace(/(behaviour|behavior|moz-binding|content):/gi, "")
- .replace(/\s+/g, " ")
- .trim();
const style = document.createElement("style");
style.dataset.badgeWall = "true";
- style.innerHTML = sanitise(preferences.badge_wall_css);
+ style.textContent = preferences.badge_wall_css;
document.head.appendChild(style);
}
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, ".");