From b4cebe9891eee673fb68e62e61850ea16e74a68f Mon Sep 17 00:00:00 2001 From: Essential Randomness Date: Mon, 20 Jul 2026 02:10:36 -0700 Subject: [PATCH] Guard external content link schemes Validate external content URLs before rendering them as links. Permit supported web schemes while rejecting unsafe or malformed destinations. Render every guarded link through a shared SafeExternalLink component and keep link behavior consistent across posts, events, and shared content. --- src/components/SafeExternalLink.astro | 39 ++++++ src/components/content/PostCard.astro | 5 +- .../content/ShareableContentList.astro | 17 ++- src/components/events/EventCard.astro | 17 ++- src/lib/community/shared-content.ts | 82 +++++++++--- src/lib/shared-content.test.ts | 119 ++++++++++++++++++ 6 files changed, 248 insertions(+), 31 deletions(-) create mode 100644 src/components/SafeExternalLink.astro create mode 100644 src/lib/shared-content.test.ts diff --git a/src/components/SafeExternalLink.astro b/src/components/SafeExternalLink.astro new file mode 100644 index 0000000..06d3eb7 --- /dev/null +++ b/src/components/SafeExternalLink.astro @@ -0,0 +1,39 @@ +--- +import { toSafeExternalHref } from "../lib/community/shared-content"; + +interface Props { + href: string | null | undefined; + /** Tried under the same safety rules when href is rejected. */ + fallbackHref?: string | null; + /** What to render when no safe destination exists. */ + fallbackTag?: "span" | "div" | "none"; + /** Announce the new tab to screen readers inside the link. */ + announceNewTab?: boolean; + class?: string; +} + +const { + href, + fallbackHref, + fallbackTag = "span", + announceNewTab = false, + class: className, +} = Astro.props; + +const safeHref = toSafeExternalHref(href, { fallback: fallbackHref }); +const Tag = safeHref ? "a" : fallbackTag === "none" ? null : fallbackTag; +const linkAttributes = safeHref + ? { href: safeHref, rel: "noopener noreferrer", target: "_blank" } + : {}; +--- + +{ + Tag && ( + + + {safeHref && announceNewTab && ( + (opens in new tab) + )} + + ) +} diff --git a/src/components/content/PostCard.astro b/src/components/content/PostCard.astro index b65e55c..19f7242 100644 --- a/src/components/content/PostCard.astro +++ b/src/components/content/PostCard.astro @@ -1,5 +1,6 @@ --- import Avatar, { type AvatarSource } from "../Avatar.astro"; +import SafeExternalLink from "../SafeExternalLink.astro"; interface Props { title: string; @@ -47,7 +48,7 @@ const communityHue = hueFromString(source); ---
- + #{tag}} - + {hasActions && (
diff --git a/src/components/content/ShareableContentList.astro b/src/components/content/ShareableContentList.astro index fa49690..1c2f13b 100644 --- a/src/components/content/ShareableContentList.astro +++ b/src/components/content/ShareableContentList.astro @@ -1,6 +1,7 @@ --- import type { ShareCandidateList } from '../../lib/community/share-candidates'; import { actions } from 'astro:actions'; +import SafeExternalLink from '../SafeExternalLink.astro'; import ShareableContentIcon from './ShareableContentIcon.astro'; interface Props { @@ -77,10 +78,9 @@ function formatDate(date: Date | undefined): string {