Skip to content

Commit c99b276

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 openclaw#140 and openclaw#142 which fixed related XSS vectors. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent 241a6bf commit c99b276

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
@@ -7,6 +7,7 @@ import communityBuilds from '../data/community-builds.json';
77
import pressArticles from '../data/press.json';
88
import { getPublishedBlogPosts } from '../lib/blog';
99
import { getCachedXAvatarSrc, getInitialsAvatarSrc } from '../lib/avatars';
10+
import { sanitizeUrl } from '../lib/sanitize-url';
1011
1112
// Get latest blog post
1213
const [latestPost] = await getPublishedBlogPosts();
@@ -120,7 +121,7 @@ const featuredPress = pressArticles.slice(0, 4);
120121
<div class="testimonials-track">
121122
<div class="testimonials-row row-1" style={`--duration: ${duration1}s`}>
122123
{row1.map((t) => (
123-
<a href={t.url} target="_blank" rel="noopener" class="testimonial-card" tabindex="-1">
124+
<a href={sanitizeUrl(t.url)} target="_blank" rel="noopener" class="testimonial-card" tabindex="-1">
124125
<img
125126
src={getCachedXAvatarSrc(t.author, t.author, 88)}
126127
alt={t.author}
@@ -137,7 +138,7 @@ const featuredPress = pressArticles.slice(0, 4);
137138
</div>
138139
<div class="testimonials-row row-2" style={`--duration: ${duration2}s`}>
139140
{row2.map((t) => (
140-
<a href={t.url} target="_blank" rel="noopener" class="testimonial-card">
141+
<a href={sanitizeUrl(t.url)} target="_blank" rel="noopener" class="testimonial-card">
141142
<img
142143
src={getCachedXAvatarSrc(t.author, t.author, 88)}
143144
alt={t.author}
@@ -632,7 +633,7 @@ const featuredPress = pressArticles.slice(0, 4);
632633
/>
633634
<div class="builds-grid">
634635
{featuredBuilds.map((build) => (
635-
<a href={build.href} target="_blank" rel="noopener" class="build-card">
636+
<a href={sanitizeUrl(build.href)} target="_blank" rel="noopener" class="build-card">
636637
<div class="build-card-header">
637638
<span class="build-source">{build.source}</span>
638639
<span class="build-open">Open →</span>
@@ -660,7 +661,7 @@ const featuredPress = pressArticles.slice(0, 4);
660661
<div class="press-grid">
661662
{featuredPress.map((article) => (
662663
<a
663-
href={article.url}
664+
href={sanitizeUrl(article.url)}
664665
target="_blank"
665666
rel="noopener"
666667
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
@@ -3,6 +3,7 @@ import Layout from '../layouts/Layout.astro';
33
import testimonials from '../data/testimonials.json';
44
import extraTestimonials from '../data/testimonials-extra.json';
55
import { getCachedXAvatarSrc, getInitialsAvatarSrc } from '../lib/avatars';
6+
import { sanitizeUrl } from '../lib/sanitize-url';
67
78
// Combine all testimonials - strongest first, then extras (no randomization)
89
const allTestimonials = [...testimonials, ...extraTestimonials];
@@ -23,7 +24,7 @@ const allTestimonials = [...testimonials, ...extraTestimonials];
2324

2425
<div class="shoutouts-grid">
2526
{allTestimonials.map((t) => (
26-
<a href={t.url} target="_blank" rel="noopener" class="shoutout-card">
27+
<a href={sanitizeUrl(t.url)} target="_blank" rel="noopener" class="shoutout-card">
2728
<img
2829
src={t.avatar || getCachedXAvatarSrc(t.author, t.author, 96)}
2930
alt={t.author}

src/pages/showcase.astro

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

0 commit comments

Comments
 (0)