Skip to content

Commit 64efda4

Browse files
committed
fix: validate URL protocols in data-driven href attributes to prevent XSS
URL fields from JSON data files (testimonials.json, testimonials-extra.json, community-builds.json, press.json) are rendered directly in <a href={...}> attributes. Astro auto-escapes HTML entities but does not block dangerous protocols like javascript:, data:, or vbscript:. If a crafted URL like javascript:alert(document.cookie) were merged via a community PR, it would render as a clickable XSS link on the homepage, shoutouts, showcase, and press pages. This adds a sanitizeUrl() utility that validates URL protocols at build time, allowing only http:, https:, and mailto:. Dangerous protocols are replaced with #. All existing URLs are https:// so there is no behavioral change. Follows up on #140 and #142 which fixed related XSS vectors. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent 90d9d50 commit 64efda4

5 files changed

Lines changed: 26 additions & 7 deletions

File tree

src/lib/sanitize-url.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
const allowedProtocols = new Set(['http:', 'https:', 'mailto:']);
2+
3+
/**
4+
* Validate that a URL string uses an allowed protocol (http, https, mailto).
5+
* Returns the URL unchanged when valid, or "#" for dangerous protocols
6+
* like javascript: or data: that could enable XSS via crafted data files.
7+
*/
8+
export function sanitizeUrl(url: string): string {
9+
try {
10+
const parsed = new URL(url, 'https://placeholder.invalid');
11+
return allowedProtocols.has(parsed.protocol) ? url : '#';
12+
} catch {
13+
return '#';
14+
}
15+
}

src/pages/index.astro

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import testimonials from '../data/testimonials.json';
66
import communityBuilds from '../data/community-builds.json';
77
import pressArticles from '../data/press.json';
88
import { getPublishedBlogPosts } from '../lib/blog';
9+
import { sanitizeUrl } from '../lib/sanitize-url';
910
1011
// Get latest blog post
1112
const [latestPost] = await getPublishedBlogPosts();
@@ -119,7 +120,7 @@ const featuredPress = pressArticles.slice(0, 4);
119120
<div class="testimonials-track">
120121
<div class="testimonials-row row-1" style={`--duration: ${duration1}s`}>
121122
{row1.map((t) => (
122-
<a href={t.url} target="_blank" rel="noopener" class="testimonial-card" tabindex="-1">
123+
<a href={sanitizeUrl(t.url)} target="_blank" rel="noopener" class="testimonial-card" tabindex="-1">
123124
<img
124125
src={`https://unavatar.io/x/${t.author}`}
125126
alt={t.author}
@@ -136,7 +137,7 @@ const featuredPress = pressArticles.slice(0, 4);
136137
</div>
137138
<div class="testimonials-row row-2" style={`--duration: ${duration2}s`}>
138139
{row2.map((t) => (
139-
<a href={t.url} target="_blank" rel="noopener" class="testimonial-card">
140+
<a href={sanitizeUrl(t.url)} target="_blank" rel="noopener" class="testimonial-card">
140141
<img
141142
src={`https://unavatar.io/x/${t.author}`}
142143
alt={t.author}
@@ -631,7 +632,7 @@ const featuredPress = pressArticles.slice(0, 4);
631632
/>
632633
<div class="builds-grid">
633634
{featuredBuilds.map((build) => (
634-
<a href={build.href} target="_blank" rel="noopener" class="build-card">
635+
<a href={sanitizeUrl(build.href)} target="_blank" rel="noopener" class="build-card">
635636
<div class="build-card-header">
636637
<span class="build-source">{build.source}</span>
637638
<span class="build-open">Open →</span>
@@ -659,7 +660,7 @@ const featuredPress = pressArticles.slice(0, 4);
659660
<div class="press-grid">
660661
{featuredPress.map((article) => (
661662
<a
662-
href={article.url}
663+
href={sanitizeUrl(article.url)}
663664
target="_blank"
664665
rel="noopener"
665666
class:list={['press-card', article.featured && 'press-featured']}

src/pages/press.astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
import Layout from '../layouts/Layout.astro';
33
import pressArticles from '../data/press.json';
4+
import { sanitizeUrl } from '../lib/sanitize-url';
45
---
56

67
<Layout
@@ -26,7 +27,7 @@ import pressArticles from '../data/press.json';
2627
<div class="press-grid">
2728
{pressArticles.map((article) => (
2829
<a
29-
href={article.url}
30+
href={sanitizeUrl(article.url)}
3031
target="_blank"
3132
rel="noopener"
3233
class:list={['press-card', article.featured && 'press-featured']}

src/pages/shoutouts.astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import Layout from '../layouts/Layout.astro';
33
import testimonials from '../data/testimonials.json';
44
import extraTestimonials from '../data/testimonials-extra.json';
5+
import { sanitizeUrl } from '../lib/sanitize-url';
56
67
// Combine all testimonials - strongest first, then extras (no randomization)
78
const allTestimonials = [...testimonials, ...extraTestimonials];
@@ -22,7 +23,7 @@ const allTestimonials = [...testimonials, ...extraTestimonials];
2223

2324
<div class="shoutouts-grid">
2425
{allTestimonials.map((t) => (
25-
<a href={t.url} target="_blank" rel="noopener" class="shoutout-card">
26+
<a href={sanitizeUrl(t.url)} target="_blank" rel="noopener" class="shoutout-card">
2627
<img
2728
src={t.avatar || `https://unavatar.io/x/${t.author}`}
2829
alt={t.author}

src/pages/showcase.astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import Layout from '../layouts/Layout.astro';
33
import showcaseData from '../data/showcase.json';
44
import featuredBuilds from '../data/community-builds.json';
5+
import { sanitizeUrl } from '../lib/sanitize-url';
56
67
const featuredIds = new Set(featuredBuilds.map((item) => item.id));
78
const sortedShowcase = showcaseData.filter((item) => !featuredIds.has(item.id));
@@ -40,7 +41,7 @@ const categoryLabels: Record<string, string> = {
4041
</div>
4142
<div class="featured-grid">
4243
{featuredBuilds.map((build) => (
43-
<a href={build.href} target="_blank" rel="noopener" class="featured-card">
44+
<a href={sanitizeUrl(build.href)} target="_blank" rel="noopener" class="featured-card">
4445
<div class="featured-topline">
4546
<span class="featured-source">{build.source}</span>
4647
<span class="featured-likes">❤️ {build.likes}</span>

0 commit comments

Comments
 (0)