diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/Error/rateLimit.ts | 64 | ||||
| -rw-r--r-- | src/lib/Settings/Categories/RSSFeeds.svelte | 6 | ||||
| -rw-r--r-- | src/lib/User/BadgeWall/Badges.svelte | 28 | ||||
| -rw-r--r-- | src/lib/User/BadgeWall/badges.css | 9 | ||||
| -rw-r--r-- | src/lib/Utility/anilistOauth.ts | 27 | ||||
| -rw-r--r-- | src/lib/Utility/feedToken.test.ts | 44 | ||||
| -rw-r--r-- | src/lib/Utility/feedToken.ts | 96 | ||||
| -rw-r--r-- | src/routes/api/badges/+server.ts | 9 | ||||
| -rw-r--r-- | src/routes/api/oauth/refresh/+server.ts | 29 | ||||
| -rw-r--r-- | src/routes/feeds/activity-notifications/+server.ts | 64 | ||||
| -rw-r--r-- | src/routes/settings/+page.server.ts | 14 | ||||
| -rw-r--r-- | src/routes/settings/+page.svelte | 2 |
12 files changed, 328 insertions, 64 deletions
diff --git a/src/lib/Error/rateLimit.ts b/src/lib/Error/rateLimit.ts index 636bdd5d..d3508aba 100644 --- a/src/lib/Error/rateLimit.ts +++ b/src/lib/Error/rateLimit.ts @@ -1,12 +1,62 @@ import type { RequestEvent } from "@sveltejs/kit"; -import { RateLimiter } from "sveltekit-rate-limiter/server"; +import { + RateLimiter, + type RateLimiterStore, +} from "sveltekit-rate-limiter/server"; -export const checkRateLimit = async (event: RequestEvent) => { - const limiter = new RateLimiter({ rates: { IP: [5, "s"] } }); +// Storage. The library's default store is in-memory and PER INSTANCE. On +// Vercel's serverless runtime, counters do not aggregate across lambda +// instances or regions, so these limiters are best-effort backstops there. +// Provision a shared store (Upstash Redis / Vercel KV) implementing +// RateLimiterStore and return it here for durable limits. Coarse per-IP abuse +// protection also runs at the edge via the Vercel WAF (see +// docs/vercel-firewall.md); these app-level limiters add what the WAF cannot +// express — per-user windows and the per-(IP, badge) click counter. +const createStore = (): RateLimiterStore | undefined => { + // e.g. `if (env.UPSTASH_REDIS_REST_URL) return new UpstashStore(...)` + return undefined; +}; - await limiter.cookieLimiter?.preflight(event); +const store = createStore(); - if (await limiter.isLimited(event)) return new Response("rate-limited"); +// Module-level singletons: the counters must persist across requests. (The +// previous implementation built a new RateLimiter on every call, so its store +// was always empty and it never actually limited anything.) +const limiters = { + auth: new RateLimiter({ IP: [10, "m"], store }), + mutation: new RateLimiter({ IP: [30, "m"], store }), + read: new RateLimiter({ IP: [60, "m"], store }), +} satisfies Record<string, RateLimiter>; - return null; -}; +export type RateLimitClass = keyof typeof limiters; + +const tooManyRequests = () => + new Response("Too Many Requests", { status: 429 }); + +export const checkRateLimit = async ( + event: RequestEvent, + limit: RateLimitClass = "mutation", +): Promise<Response | null> => + (await limiters[limit].isLimited(event)) ? tooManyRequests() : null; + +// Click counter: bound inflation of a single badge without throttling a viewer +// browsing many DIFFERENT badges. Keyed on (IP, badge) — which the edge WAF +// can't express, since it keys on IP alone. +const clickCounterLimiter = new RateLimiter({ + store, + plugins: [ + { + rate: [2, "m"], + hash: async (event) => { + const badgeId = event.url.searchParams.get("incrementClickCount"); + + return badgeId ? `click:${event.getClientAddress()}:${badgeId}` : true; + }, + }, + ], +}); + +export const checkClickCounterLimit = async ( + event: RequestEvent, +): Promise<Response | null> => + (await clickCounterLimiter.isLimited(event)) ? tooManyRequests() : null; diff --git a/src/lib/Settings/Categories/RSSFeeds.svelte b/src/lib/Settings/Categories/RSSFeeds.svelte index 49a6eb5a..303db699 100644 --- a/src/lib/Settings/Categories/RSSFeeds.svelte +++ b/src/lib/Settings/Categories/RSSFeeds.svelte @@ -7,19 +7,21 @@ import { appOrigin } from "$lib/Utility/appOrigin"; import locale from "$stores/locale"; import SettingHint from "../SettingHint.svelte"; -export let user: { accessToken: string; refreshToken: string }; +export let feedToken: string | undefined; </script> <button data-umami-event="Copy RSS Feed URL" onclick={() => { + if (!feedToken) return; + addNotification( options({ heading: get(locale)().notifications?.rssCopied ?? 'RSS feed URL copied to clipboard' }) ); navigator.clipboard.writeText( - `${appOrigin()}/feeds/activity-notifications?token=${user.accessToken}&refresh=${user.refreshToken}` + `${appOrigin()}/feeds/activity-notifications?feed=${feedToken}` ); }} > diff --git a/src/lib/User/BadgeWall/Badges.svelte b/src/lib/User/BadgeWall/Badges.svelte index d2c56392..a77aebd6 100644 --- a/src/lib/User/BadgeWall/Badges.svelte +++ b/src/lib/User/BadgeWall/Badges.svelte @@ -1,4 +1,6 @@ <script lang="ts"> +import { onDestroy } from "svelte"; +import { browser } from "$app/environment"; import FallbackImage from "$lib/Image/FallbackImage.svelte"; import Spacer from "$lib/Layout/Spacer.svelte"; import LinkedTooltip from "$lib/Tooltip/LinkedTooltip.svelte"; @@ -16,6 +18,30 @@ export let categoryFilter: string | null; export let editMode: boolean; export let preferences: Preferences | undefined; export let selectedBadge: IndexedBadge | undefined = undefined; + +const skipObserver = + browser && typeof IntersectionObserver !== "undefined" + ? new IntersectionObserver( + (entries) => { + for (const entry of entries) + entry.target.classList.toggle( + "is-offscreen", + !entry.isIntersecting, + ); + }, + { rootMargin: "600px" }, + ) + : null; + +const skipWhenOffscreen = (node: HTMLElement) => { + skipObserver?.observe(node); + + return { + destroy: () => skipObserver?.unobserve(node), + }; +}; + +onDestroy(() => skipObserver?.disconnect()); </script> {#if ungroupedBadges.length === 0} @@ -42,7 +68,7 @@ export let selectedBadge: IndexedBadge | undefined = undefined; <div class="badges"> {#each badges as badge} - <div id={`badge-${badge.id}`}> + <div id={`badge-${badge.id}`} class="is-offscreen" use:skipWhenOffscreen> {#if editMode} <LinkedTooltip content={`${ diff --git a/src/lib/User/BadgeWall/badges.css b/src/lib/User/BadgeWall/badges.css index 19f8996f..f2e03988 100644 --- a/src/lib/User/BadgeWall/badges.css +++ b/src/lib/User/BadgeWall/badges.css @@ -11,6 +11,15 @@ gap: 0.25rem; } +/* Off-screen cells skip layout + paint. skipWhenOffscreen toggles this class + via IntersectionObserver so the containment — and the clipping it does to + the hover tilt, scale, and tooltip — is never active on an on-screen cell, + and the auto<->visible flip (which flashes) only ever happens off-screen. */ +.badges > .is-offscreen { + content-visibility: auto; + contain-intrinsic-size: auto 8rem; +} + .edit-row-2 { margin-top: -1.25rem; } diff --git a/src/lib/Utility/anilistOauth.ts b/src/lib/Utility/anilistOauth.ts new file mode 100644 index 00000000..9bb570fb --- /dev/null +++ b/src/lib/Utility/anilistOauth.ts @@ -0,0 +1,27 @@ +import { env } from "$env/dynamic/private"; +import { env as publicEnv } from "$env/dynamic/public"; + +// Exchange a refresh token for a fresh access token WITHOUT touching the auth +// cookie — used by the RSS feed, which is polled by an unattended reader that +// has no session, so there is no cookie to re-set. +export const refreshAniListToken = async ( + refreshToken: string, +): Promise<string | null> => { + const formData = new FormData(); + + formData.append("grant_type", "refresh_token"); + formData.append("client_id", publicEnv.PUBLIC_ANILIST_CLIENT_ID as string); + formData.append("client_secret", env.ANILIST_CLIENT_SECRET as string); + formData.append("refresh_token", refreshToken); + + const response = await fetch("https://anilist.co/api/v2/oauth/token", { + method: "POST", + body: formData, + }); + + if (!response.ok) return null; + + const payload = (await response.json()) as { access_token?: string }; + + return payload.access_token ?? null; +}; diff --git a/src/lib/Utility/feedToken.test.ts b/src/lib/Utility/feedToken.test.ts new file mode 100644 index 00000000..ffcb5fd7 --- /dev/null +++ b/src/lib/Utility/feedToken.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { decryptFeedToken, encryptFeedToken } from "./feedToken"; + +// Fixed 32-byte keys so the cipher is exercised without the FEED_TOKEN_KEY env +// var (production omits the key argument and reads it from env). +const key = new Uint8Array(32).fill(7); +const otherKey = new Uint8Array(32).fill(9); + +describe("feed token", () => { + // Behaviour gate: the feed must still resolve to the same refresh token it + // was minted from, and the cleartext must never appear in the URL blob. + it("round-trips the refresh token without leaking it", async () => { + const refreshToken = "anilist-refresh-token-abc123"; + const sealed = await encryptFeedToken(refreshToken, key); + + expect(sealed).not.toContain(refreshToken); + expect(await decryptFeedToken(sealed, key)).toBe(refreshToken); + }); + + it("produces a fresh ciphertext each time (random IV)", async () => { + expect(await encryptFeedToken("same-token", key)).not.toBe( + await encryptFeedToken("same-token", key), + ); + }); + + it("rejects a token sealed with a different key", async () => { + const sealed = await encryptFeedToken("secret", otherKey); + + expect(await decryptFeedToken(sealed, key)).toBeNull(); + }); + + it("rejects a tampered ciphertext", async () => { + const sealed = await encryptFeedToken("secret", key); + const tampered = `${sealed[0] === "A" ? "B" : "A"}${sealed.slice(1)}`; + + expect(await decryptFeedToken(tampered, key)).toBeNull(); + }); + + it("rejects malformed input", async () => { + expect(await decryptFeedToken("", key)).toBeNull(); + expect(await decryptFeedToken("short", key)).toBeNull(); + expect(await decryptFeedToken("not valid base64url!!", key)).toBeNull(); + }); +}); diff --git a/src/lib/Utility/feedToken.ts b/src/lib/Utility/feedToken.ts new file mode 100644 index 00000000..1d26a443 --- /dev/null +++ b/src/lib/Utility/feedToken.ts @@ -0,0 +1,96 @@ +import { env } from "$env/dynamic/private"; + +// Authenticated encryption (AES-256-GCM) for the RSS feed token. The plaintext +// is the user's long-lived AniList refresh token; the ciphertext is what travels +// in the public feed URL (?feed=). Without FEED_TOKEN_KEY (server-only) the blob +// is opaque and tamper-evident, so a leaked feed URL exposes no usable +// credential — unlike the old ?token=&refresh= form that carried them in clear. +// +// WebCrypto (not node:crypto) so the byte types stay Uint8Array — the repo's +// bun-types make node's Buffer structurally incompatible with crypto params. + +const IV_BYTES = 12; +const TAG_BYTES = 16; +const KEY_BYTES = 32; + +// WebCrypto's BufferSource requires the non-shared ArrayBuffer variant; the bare +// Uint8Array generic defaults to ArrayBufferLike, which the crypto types reject. +type KeyBytes = Uint8Array<ArrayBuffer>; + +const bytesFromBase64 = (text: string) => + Uint8Array.from(atob(text), (character) => character.charCodeAt(0)); + +const base64UrlFromBytes = (bytes: Uint8Array): string => + btoa(String.fromCharCode(...bytes)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); + +const bytesFromBase64Url = (text: string) => + bytesFromBase64(text.replaceAll("-", "+").replaceAll("_", "/")); + +const requireKeyBytes = (): KeyBytes => { + const encoded = env.FEED_TOKEN_KEY; + + if (!encoded) throw new Error("FEED_TOKEN_KEY is not set"); + + const bytes = bytesFromBase64(encoded); + + if (bytes.length !== KEY_BYTES) + throw new Error(`FEED_TOKEN_KEY must decode to ${KEY_BYTES} bytes`); + + return bytes; +}; + +const importKey = (keyBytes: KeyBytes): Promise<CryptoKey> => + crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, [ + "encrypt", + "decrypt", + ]); + +// The optional keyBytes parameter lets the unit test exercise the cipher with a +// fixed key; production callers omit it and the env key is loaded. +export const encryptFeedToken = async ( + refreshToken: string, + keyBytes: KeyBytes = requireKeyBytes(), +): Promise<string> => { + const key = await importKey(keyBytes); + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + new TextEncoder().encode(refreshToken), + ), + ); + const framed = new Uint8Array(iv.length + ciphertext.length); + + framed.set(iv, 0); + framed.set(ciphertext, iv.length); + + return base64UrlFromBytes(framed); +}; + +export const decryptFeedToken = async ( + blob: string, + keyBytes: KeyBytes = requireKeyBytes(), +): Promise<string | null> => { + try { + const framed = bytesFromBase64Url(blob); + + if (framed.length < IV_BYTES + TAG_BYTES) return null; + + const key = await importKey(keyBytes); + const iv = framed.subarray(0, IV_BYTES); + const ciphertext = framed.subarray(IV_BYTES); + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + key, + ciphertext, + ); + + return new TextDecoder().decode(plaintext); + } catch { + return null; + } +}; diff --git a/src/routes/api/badges/+server.ts b/src/routes/api/badges/+server.ts index 10b63125..a4212f40 100644 --- a/src/routes/api/badges/+server.ts +++ b/src/routes/api/badges/+server.ts @@ -15,6 +15,7 @@ import { } from "$lib/Database/SB/User/badges"; import { decodeAuthCookieOrNull } from "$lib/Effect/authCookie"; import { decodeRequestJsonOrThrow } from "$lib/Effect/requestBody"; +import { checkClickCounterLimit } from "$lib/Error/rateLimit"; import { appOrigin, appOriginHeaders } from "$lib/Utility/appOrigin"; import { isOwnerOrPrivileged } from "$lib/Utility/authorisation"; import privilegedUser from "$lib/Utility/privilegedUser"; @@ -53,8 +54,14 @@ export const DELETE = async ({ url, cookies }) => { return await badges(identity.id); }; -export const PUT = async ({ cookies, url, request }) => { +export const PUT = async (event) => { + const { cookies, url, request } = event; + if (url.searchParams.get("incrementClickCount") || undefined) { + const limited = await checkClickCounterLimit(event); + + if (limited) return limited; + if (request.headers.get("origin") !== appOrigin()) return unauthorised(); await incrementClickCount( diff --git a/src/routes/api/oauth/refresh/+server.ts b/src/routes/api/oauth/refresh/+server.ts deleted file mode 100644 index 49306076..00000000 --- a/src/routes/api/oauth/refresh/+server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { redirect } from "@sveltejs/kit"; -import { env } from "$env/dynamic/private"; -import { env as env2 } from "$env/dynamic/public"; - -export const GET = async ({ url, cookies }) => { - const formData = new FormData(); - - formData.append("grant_type", "refresh_token"); - formData.append("client_id", env2.PUBLIC_ANILIST_CLIENT_ID as string); - formData.append("client_secret", env.ANILIST_CLIENT_SECRET as string); - formData.append("refresh_token", url.searchParams.get("token") || ""); - - const newUser = await ( - await fetch("https://anilist.co/api/v2/oauth/token", { - method: "POST", - body: formData, - }) - ).json(); - - cookies.set("user", JSON.stringify(newUser), { - path: "/", - maxAge: 60 * 60 * 24 * 7, - httpOnly: false, - sameSite: "lax", - }); - - if (url.searchParams.get("redirect")) redirect(303, "/"); - else return Response.json(newUser); -}; diff --git a/src/routes/feeds/activity-notifications/+server.ts b/src/routes/feeds/activity-notifications/+server.ts index 64ba3fdc..145e236e 100644 --- a/src/routes/feeds/activity-notifications/+server.ts +++ b/src/routes/feeds/activity-notifications/+server.ts @@ -2,16 +2,15 @@ import { type Notification, notifications, } from "$lib/Data/AniList/notifications"; +import { refreshAniListToken } from "$lib/Utility/anilistOauth"; import { siteUrl } from "$lib/Utility/appOrigin"; -import root from "$lib/Utility/root"; +import { decryptFeedToken } from "$lib/Utility/feedToken"; const htmlEncode = (input: string) => { return input.replace(/[\u00A0-\u9999<>&]/g, (i) => `&#${i.charCodeAt(0)};`); }; -const render = ( - posts: Notification[] = [], -) => `<?xml version="1.0" encoding="UTF-8" ?> +const channel = (items: string) => `<?xml version="1.0" encoding="UTF-8" ?> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" @@ -27,7 +26,14 @@ xmlns:media="http://search.yahoo.com/mrss/"> <lastBuildDate>${new Date().toUTCString()}</lastBuildDate> <language>en-US</language> <snf:logo><url>${siteUrl("/favicon-196x196.png")}</url></snf:logo> - ${posts + ${items} + </channel> +</rss> +`; + +const render = (posts: Notification[] = []) => + channel( + posts .filter((notification: Notification) => notification.type !== undefined) .map((notification: Notification) => { let title = `${notification.user.name}${notification.context}`; @@ -66,28 +72,40 @@ xmlns:media="http://search.yahoo.com/mrss/"> <pubDate>${new Date(notification.createdAt * 1000).toUTCString()}</pubDate> </item>`; }) - .join("")} - </channel> -</rss> -`; - -export const GET = async ({ url }) => { - let token = url.searchParams.get("token"); - const refresh = url.searchParams.get("refresh"); - let notification = await notifications(token || ""); + .join(""), + ); - if (notification === null) { - token = ( - await (await fetch(root(`/api/oauth/refresh?token=${refresh}`))).json() - ).access_token; +// Old feed URLs carried the tokens in clear (?token=&refresh=); they no longer +// resolve, so degrade to a single item nudging the user to re-copy the link +// instead of silently going empty. +const staleNotice = () => + channel(`<item> +<guid isPermaLink="false">due-moe-feed-url-outdated</guid> +<title>${htmlEncode("Your due.moe RSS feed URL is outdated — re-copy it from Settings")}</title> +<link>${siteUrl("/settings#feeds")}</link> +<description>${htmlEncode("This feed link no longer works. Open due.moe → Settings → RSS Feeds and copy the new URL into your reader.")}</description> +<pubDate>${new Date().toUTCString()}</pubDate> +</item>`); - notification = await notifications(token as string); - } - - return new Response(token ? render(notification || []) : render(), { +const feed = (body: string) => + new Response(body, { headers: { - "Cache-Control": `max-age=0`, + "Cache-Control": "max-age=0", "Content-Type": "application/xml", }, }); + +export const GET = async ({ url }) => { + const sealed = url.searchParams.get("feed"); + const refreshToken = sealed ? await decryptFeedToken(sealed) : null; + + if (!refreshToken) return feed(staleNotice()); + + const accessToken = await refreshAniListToken(refreshToken); + + if (!accessToken) return feed(render()); + + const notification = await notifications(accessToken); + + return feed(render(notification || [])); }; diff --git a/src/routes/settings/+page.server.ts b/src/routes/settings/+page.server.ts new file mode 100644 index 00000000..321e5cfd --- /dev/null +++ b/src/routes/settings/+page.server.ts @@ -0,0 +1,14 @@ +import { decodeAuthCookieOrNull } from "$lib/Effect/authCookie"; +import { encryptFeedToken } from "$lib/Utility/feedToken"; + +// Mint the RSS feed token server-side: the encryption key never reaches the +// client, so the URL is built here from the refresh token already in the cookie +// rather than from tokens handed to the browser. +export const load = async ({ cookies }) => { + const cookie = cookies.get("user"); + const user = cookie ? decodeAuthCookieOrNull(cookie) : null; + + return { + feedToken: user ? await encryptFeedToken(user.refreshToken) : undefined, + }; +}; diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 79642944..9a3bf990 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -55,7 +55,7 @@ export let data: PageData; <SettingSync /> </Category> <Category title={$locale().settings.rssFeeds.title} id="feeds" newLine={false}> - <RssFeeds user={data.user} /> + <RssFeeds feedToken={data.feedToken} /> </Category> </div> |