diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/graphql/user/resolvers.ts | 3 | ||||
| -rw-r--r-- | src/lib/Database/SB/User/preferences.ts | 3 | ||||
| -rw-r--r-- | src/lib/Utility/authorisation.test.ts | 20 | ||||
| -rw-r--r-- | src/lib/Utility/authorisation.ts | 9 | ||||
| -rw-r--r-- | src/lib/Utility/pushEndpoint.test.ts | 49 | ||||
| -rw-r--r-- | src/lib/Utility/pushEndpoint.ts | 26 | ||||
| -rw-r--r-- | src/lib/Utility/sanitizeCss.test.ts | 76 | ||||
| -rw-r--r-- | src/lib/Utility/sanitizeCss.ts | 57 | ||||
| -rw-r--r-- | src/lib/Utility/sanitizeHtml.test.ts | 57 | ||||
| -rw-r--r-- | src/lib/Utility/sanitizeHtml.ts | 32 | ||||
| -rw-r--r-- | src/routes/api/badges/+server.ts | 14 | ||||
| -rw-r--r-- | src/routes/api/notifications/subscribe/+server.ts | 17 | ||||
| -rw-r--r-- | src/routes/updates/+page.svelte | 34 | ||||
| -rw-r--r-- | src/routes/user/[user]/badges/+page.svelte | 13 | ||||
| -rw-r--r-- | src/trigger/notifications.ts | 9 |
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 & Vegetables")).toBe( + "Fruits & 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, "."); |