diff --git a/src/lib/components/BaseModal.svelte b/src/lib/components/BaseModal.svelte index 22c69cd..9d27a4b 100644 --- a/src/lib/components/BaseModal.svelte +++ b/src/lib/components/BaseModal.svelte @@ -26,9 +26,34 @@ } = $props(); let portalElement = $state(null); + let dialogElement = $state(null); + onMount(() => { portalElement = document.body; }); + + // The dialog is rendered with the `open` attribute rather than + // `showModal()`, so the browser gives us neither Escape-to-close nor + // initial focus. Provide both, and restore focus to whatever opened it. + $effect(() => { + if (!open || !dialogElement) return; + + const previouslyFocused = document.activeElement as HTMLElement | null; + dialogElement.focus(); + + const handleKeydown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + } + }; + document.addEventListener('keydown', handleKeydown); + + return () => { + document.removeEventListener('keydown', handleKeydown); + previouslyFocused?.focus?.(); + }; + }); {#if open && portalElement} @@ -36,7 +61,9 @@
(copied = false), 2000); + // navigator.clipboard is undefined outside secure contexts, and + // writeText() rejects when permission is denied. + try { + await navigator.clipboard?.writeText(url); + copied = true; + setTimeout(() => (copied = false), 2000); + } catch { + copied = false; + } } // Construct structured email template diff --git a/src/lib/components/VerifierCard.svelte b/src/lib/components/VerifierCard.svelte index 551c86e..386df47 100644 --- a/src/lib/components/VerifierCard.svelte +++ b/src/lib/components/VerifierCard.svelte @@ -13,7 +13,15 @@
- {name} + +
{name} diff --git a/src/lib/components/leaflet/LeafletBlocks.svelte b/src/lib/components/leaflet/LeafletBlocks.svelte index d3e9e61..aac883f 100644 --- a/src/lib/components/leaflet/LeafletBlocks.svelte +++ b/src/lib/components/leaflet/LeafletBlocks.svelte @@ -23,6 +23,7 @@ import type { FacetSchema } from "$lib/providers/facets"; import type { SerialisedBlock } from "$lib/providers/serialise"; + import { safeLinkUrl } from "$lib/utils/url"; const NS = "pub.leaflet.richtext.facet"; const SCHEMA: FacetSchema = { @@ -108,9 +109,15 @@ {@const level = Math.min(Math.max((inner.level as number) ?? 1, 1), 6)} {@const hid = headingId(getPlaintext(inner))} {#if level === 1} -

+ +

-

+ {:else if level === 2}

@@ -217,8 +224,13 @@
Signup form
{:else if $type === B("standardSitePost")} + {@const linkedPostHref = safeLinkUrl(inner.uri)}
- Linked post + {#if linkedPostHref} + Linked post + {:else} + Linked post + {/if}
{:else} @@ -229,7 +241,12 @@ {#snippet listItem(item: Record, ordered: boolean)}
  • {#if item.checked !== undefined} - + {/if} {#if item.content} {@const content = item.content as Record} diff --git a/src/lib/components/leaflet/LeafletButton.svelte b/src/lib/components/leaflet/LeafletButton.svelte index 47e55ed..5590f76 100644 --- a/src/lib/components/leaflet/LeafletButton.svelte +++ b/src/lib/components/leaflet/LeafletButton.svelte @@ -3,9 +3,16 @@ * LeafletButton — rendered button block from a Leaflet document. * Opens the linked URL in a new tab with rel=noopener. */ + import { safeLinkUrl } from "$lib/utils/url"; + let { text, url }: { text: string; url: string } = $props(); + + /** Remote block data is untrusted — reject javascript:/data: hrefs. */ + let href = $derived(safeLinkUrl(url)); -
    - {text} -
    +{#if href} +
    + {text} +
    +{/if} diff --git a/src/lib/components/leaflet/LeafletEmbed.svelte b/src/lib/components/leaflet/LeafletEmbed.svelte index 89249d8..9b107dd 100644 --- a/src/lib/components/leaflet/LeafletEmbed.svelte +++ b/src/lib/components/leaflet/LeafletEmbed.svelte @@ -5,6 +5,7 @@ * a 16:9 fallback for unmeasured embeds. */ import LoadingSkeleton from "$lib/components/LoadingSkeleton.svelte"; + import { safeResourceUrl } from "$lib/utils/url"; let { url, height, aspectRatio }: { url: string; @@ -18,20 +19,25 @@ : "16 / 9" ); let loaded = $state(false); + + /** Remote block data is untrusted — only allow absolute http(s) embeds. */ + let embedSrc = $derived(safeResourceUrl(url)); -
    -
    - +{#if embedSrc} +
    +
    + +
    +
    - -
    +{/if} diff --git a/src/lib/components/leaflet/LeafletFacets.svelte b/src/lib/components/leaflet/LeafletFacets.svelte index 70f4336..c3fa9fa 100644 --- a/src/lib/components/leaflet/LeafletFacets.svelte +++ b/src/lib/components/leaflet/LeafletFacets.svelte @@ -7,6 +7,7 @@ */ import type { FacetSchema } from "$lib/providers/facets"; + import { safeLinkUrl } from "$lib/utils/url"; /** Loosely-typed facet shape from serialised block data. */ interface SerialisedFacet { @@ -152,8 +153,11 @@ if (seg.marks.italic) inner = `${inner}`; if (seg.marks.bold) inner = `${inner}`; } - if (seg.marks.link) { - inner = `${inner}`; + // Remote facets are untrusted: drop anything that is not an + // http(s)/mailto/at link rather than emitting a javascript: href. + const href = safeLinkUrl(seg.marks.link); + if (href) { + inner = `${inner}`; } return inner; } diff --git a/src/lib/components/leaflet/LeafletImage.svelte b/src/lib/components/leaflet/LeafletImage.svelte index 82f5b63..576894c 100644 --- a/src/lib/components/leaflet/LeafletImage.svelte +++ b/src/lib/components/leaflet/LeafletImage.svelte @@ -4,6 +4,8 @@ * The `src` is a pre-resolved PDS blob URL (set during serialisation). */ + import { safeResourceUrl } from "$lib/utils/url"; + let { src, alt, width, height, fullBleed }: { src: string; alt?: string; @@ -17,11 +19,15 @@ ? `${width} / ${height}` : undefined ); + + /** Blob URLs are resolved server-side, but the record is still untrusted. */ + let imageSrc = $derived(safeResourceUrl(src)); +{#if imageSrc}
    {alt{alt} {/if}
    +{/if} diff --git a/src/lib/components/leaflet/LeafletWebsiteCard.svelte b/src/lib/components/leaflet/LeafletWebsiteCard.svelte index ffd88b3..cb80a2f 100644 --- a/src/lib/components/leaflet/LeafletWebsiteCard.svelte +++ b/src/lib/components/leaflet/LeafletWebsiteCard.svelte @@ -5,6 +5,7 @@ * from the destination URL. */ import { ExternalLink } from "@lucide/svelte"; + import { safeHostname, safeLinkUrl, safeResourceUrl } from "$lib/utils/url"; let { src, title, description, previewImageSrc }: { src: string; @@ -12,24 +13,34 @@ description?: string; previewImageSrc?: string; } = $props(); + + // Remote block data is untrusted: reject unsafe schemes, and never let a + // malformed URL throw out of `new URL()` and take down the whole render. + let href = $derived(safeLinkUrl(src)); + let hostname = $derived(safeHostname(src)); + let previewSrc = $derived(safeResourceUrl(previewImageSrc)); - - {#if previewImageSrc} -
    - -
    - {/if} -
    - +
    + {#if title} + {title} + {/if} + {#if description} +

    {description}

    + {/if} + {#if hostname} + + + {/if} +
    + +{/if} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts deleted file mode 100644 index 954af72..0000000 --- a/src/lib/markdown.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { marked } from "marked"; -import DOMPurify from "dompurify"; - -marked.setOptions({ - gfm: true, - breaks: true, - async: false, -}); - -export function renderMarkdown(rawText: string | undefined | null): string { - if (!rawText) return ""; - const rawHtml = marked.parse(rawText) as string; - - if (typeof window !== "undefined") { - return DOMPurify.sanitize(rawHtml, { - USE_PROFILES: { html: true }, - ALLOWED_TAGS: [ - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "p", - "br", - "strong", - "em", - "code", - "pre", - "ul", - "ol", - "li", - "a", - "blockquote", - "img", - "table", - "thead", - "tbody", - "tr", - "th", - "td", - ], - ALLOWED_ATTR: ["href", "src", "alt", "title", "target", "class"], - }); - } - return rawHtml; -} diff --git a/src/lib/providers/markdown.ts b/src/lib/providers/markdown.ts deleted file mode 100644 index b170f0c..0000000 --- a/src/lib/providers/markdown.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Client-side markdown renderer. - * - * Used in the browser for Live Markdown preview rather than the - * server-side remark pipeline. Converts raw text to sanitised HTML - * using marked + DOMPurify. Falls back to unsanitised output during - * SSR (hydration catches up). - */ - -import { marked } from "marked"; -import DOMPurify from "dompurify"; - -// Configure marked options according to best practices -marked.setOptions({ - gfm: true, // Enable GitHub Flavored Markdown (tables, autolinks, strikethrough) - breaks: true, // Convert \n in paragraphs to
    (great for Sequoia micro-writing) - async: false, // Keep parsing synchronous for instant UI updates -}); - -/** - * Converts a raw Markdown string into sanitized, safe HTML. - * Safe to use directly inside Svelte's {@html} tag. - */ -export function renderMarkdown(rawText: string | undefined | null): string { - if (!rawText) return ""; - - // 1. Convert Markdown to raw HTML string - const rawHtml = marked.parse(rawText) as string; - - // 2. Sanitize HTML to prevent XSS vulnerability - // If we are running on the server (SSR), DOMPurify needs a hook, - // otherwise it can safely sanitize directly on the browser client. - if (typeof window !== "undefined") { - return DOMPurify.sanitize(rawHtml, { - USE_PROFILES: { html: true }, // Allow standard structural HTML elements - ALLOWED_TAGS: [ - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "p", - "br", - "strong", - "em", - "code", - "pre", - "ul", - "ol", - "li", - "a", - "blockquote", - "img", - "table", - "thead", - "tbody", - "tr", - "th", - "td", - ], - ALLOWED_ATTR: ["href", "src", "alt", "title", "target", "class"], - }); - } - - // Fallback for SSR if you don't use 'jsdom' on the server; - // It returns the markup directly, which will then be sanitized immediately upon hydration. - return rawHtml; -} diff --git a/src/lib/utils/url.ts b/src/lib/utils/url.ts new file mode 100644 index 0000000..16784a0 --- /dev/null +++ b/src/lib/utils/url.ts @@ -0,0 +1,62 @@ +/** + * URL safety helpers for untrusted remote content. + * + * Leaflet blocks, facets, publication records, and profile data all come + * from remote AT Protocol repositories and must be treated as hostile. + * Interpolating an unvalidated string into `href`/`src` allows + * `javascript:`, `data:`, and `vbscript:` URLs to execute in the page. + */ + +/** Schemes we are willing to put in a link or embed. */ +const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:", "at:"]); + +/** Schemes we are willing to load as a subresource (image/iframe). */ +const SAFE_RESOURCE_PROTOCOLS = new Set(["http:", "https:"]); + +function parse(raw: unknown): URL | null { + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + try { + // Relative URLs are resolved against a throwaway base so that + // site-relative hrefs (e.g. "/blog") stay usable. + return new URL(trimmed, "https://invalid.example"); + } catch { + return null; + } +} + +/** + * Return `url` when it is safe to use as a link target, otherwise + * `undefined`. Relative URLs are preserved verbatim. + */ +export function safeLinkUrl(url: unknown): string | undefined { + const parsed = parse(url); + if (!parsed || !SAFE_LINK_PROTOCOLS.has(parsed.protocol)) return undefined; + return typeof url === "string" ? url.trim() : undefined; +} + +/** + * Return `url` when it is safe to load as an image or iframe source, + * otherwise `undefined`. Only absolute http(s) URLs are accepted. + */ +export function safeResourceUrl(url: unknown): string | undefined { + const parsed = parse(url); + if (!parsed || !SAFE_RESOURCE_PROTOCOLS.has(parsed.protocol)) { + return undefined; + } + return typeof url === "string" ? url.trim() : undefined; +} + +/** + * Human-readable hostname for a URL, or `undefined` when the URL cannot + * be parsed. Never throws — remote data routinely contains junk. + */ +export function safeHostname(url: unknown): string | undefined { + if (typeof url !== "string") return undefined; + try { + return new URL(url.trim()).hostname; + } catch { + return undefined; + } +} diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte index 6475be0..2ed3c3f 100644 --- a/src/routes/+error.svelte +++ b/src/routes/+error.svelte @@ -1,34 +1,44 @@ + + {page.status} — {heading} — {SITE.title} + + + +
    - {#if $page.status === 404} + {#if page.status === 404} {:else}
    - STATUS {$page.status} + STATUS {page.status}
    - +
    -

    - {$page.status === 404 ? 'Route Not Found' : ($page.error?.message ?? 'An Unexpected Error Occurred')} -

    - +

    {heading}

    +

    - {$page.status === 404 - ? 'The requested page could not be located. It may have been moved, deleted, or the URL slug might contain a typo.' - : 'The system encountered an unhandled exception while attempting to build this view.'} + The system encountered an unhandled exception while attempting to build this view.

    - {#if ($page.error as any)?.stack} + {#if stack}
    EXCEPTION_TRACE -
    {($page.error as any).stack}
    +
    {stack}
    {/if} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 06c8987..a6244f6 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -66,7 +66,7 @@
    {#if profile.avatar} - + {/if}

    @@ -102,6 +102,8 @@ src={musicStatus.artworkUrl} alt="" class="now-playing-art" + width="24" + height="24" loading="lazy" decoding="async" /> diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index 55c92f6..748d031 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -57,9 +57,15 @@ let copiedIndex = $state<'did' | null>(null); async function copyToClipboard(text: string, id: 'did') { - await navigator.clipboard.writeText(text); - copiedIndex = id; - setTimeout(() => (copiedIndex = null), 2000); + // navigator.clipboard is undefined outside secure contexts, and + // writeText() rejects when permission is denied. + try { + await navigator.clipboard?.writeText(text); + copiedIndex = id; + setTimeout(() => (copiedIndex = null), 2000); + } catch { + copiedIndex = null; + } } @@ -68,7 +74,15 @@
    {#if profile.avatar} - {profile.displayName}'s avatar + + {/if}

    {profile.displayName ?? profile.handle}

    diff --git a/src/routes/api/blog/posts/+server.ts b/src/routes/api/blog/posts/+server.ts index bfdb913..4197cad 100644 --- a/src/routes/api/blog/posts/+server.ts +++ b/src/routes/api/blog/posts/+server.ts @@ -7,14 +7,14 @@ import type { Config } from "@sveltejs/adapter-vercel"; export const config: Config = { maxDuration: 30 }; export const GET: RequestHandler = async ({ url }) => { - const offset = Math.max( - 0, - parseInt(url.searchParams.get("offset") ?? "0", 10), - ); - const limit = Math.min( - 100, - Math.max(1, parseInt(url.searchParams.get("limit") ?? "20", 10)), - ); + /** parseInt returns NaN for junk input, which silently slices to nothing. */ + const intParam = (name: string, fallback: number) => { + const parsed = parseInt(url.searchParams.get(name) ?? "", 10); + return Number.isFinite(parsed) ? parsed : fallback; + }; + + const offset = Math.max(0, intParam("offset", 0)); + const limit = Math.min(100, Math.max(1, intParam("limit", 20))); const { posts } = await fetchBlogPosts(); const publicationPosts = posts diff --git a/src/routes/blog/[year]/[month]/[day]/[slug]/+page.server.ts b/src/routes/blog/[year]/[month]/[day]/[slug]/+page.server.ts index e113987..b7e0a46 100644 --- a/src/routes/blog/[year]/[month]/[day]/[slug]/+page.server.ts +++ b/src/routes/blog/[year]/[month]/[day]/[slug]/+page.server.ts @@ -8,6 +8,7 @@ import { } from "$env/static/public"; import { error } from "@sveltejs/kit"; import { normalizeSlug } from "$lib/utils/slugify"; +import { safeResourceUrl } from "$lib/utils/url"; import { renderMarkdown } from "$lib/utils/markdown"; import { leafletProvider, @@ -91,6 +92,7 @@ export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => { const comments = await fetchComments(post.uri, fetch); const { content: _content, ...serialisable } = post; + const publicationUrl = safeResourceUrl(blogPublication?.url); return { post: { @@ -104,8 +106,12 @@ export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => { ? { title: blogPublication.name, description: blogPublication.description ?? "", - url: blogPublication.url, - rss: `${blogPublication.url}/rss`, + // The publication URL comes from a remote record; only emit it as + // a link target when it is a real http(s) URL. + url: publicationUrl, + rss: publicationUrl + ? `${publicationUrl.replace(/\/+$/, "")}/rss` + : undefined, } : null, comments, diff --git a/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte b/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte index 2572fc7..654393b 100644 --- a/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte +++ b/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte @@ -94,7 +94,11 @@ {#if data.blog}
    {/if} diff --git a/src/routes/site/design/+page.svelte b/src/routes/site/design/+page.svelte index a23ec68..35e4583 100644 --- a/src/routes/site/design/+page.svelte +++ b/src/routes/site/design/+page.svelte @@ -27,10 +27,16 @@ let { data } = $props(); let copiedIndex = $state(null); - function copyCode(code: string, id: string) { - navigator.clipboard.writeText(code); - copiedIndex = id; - setTimeout(() => (copiedIndex = null), 2000); + async function copyCode(code: string, id: string) { + // navigator.clipboard is undefined outside secure contexts, and + // writeText() rejects when permission is denied. + try { + await navigator.clipboard?.writeText(code); + copiedIndex = id; + setTimeout(() => (copiedIndex = null), 2000); + } catch { + copiedIndex = null; + } } const colorPalettes = [ diff --git a/src/routes/support/+page.svelte b/src/routes/support/+page.svelte index defa50f..e764b74 100644 --- a/src/routes/support/+page.svelte +++ b/src/routes/support/+page.svelte @@ -29,9 +29,15 @@ let copiedIndex = $state(null); async function copyAddress(address: string, index: number) { - await navigator.clipboard.writeText(address); - copiedIndex = index; - setTimeout(() => (copiedIndex = null), 2000); + // navigator.clipboard is undefined outside secure contexts, and + // writeText() rejects when permission is denied. + try { + await navigator.clipboard?.writeText(address); + copiedIndex = index; + setTimeout(() => (copiedIndex = null), 2000); + } catch { + copiedIndex = null; + } }