Skip to content

Commit dfb9be6

Browse files
steipeteSebTardif
andcommitted
fix: sanitize data-driven href URLs
Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent bc41081 commit dfb9be6

9 files changed

Lines changed: 66 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Ecosystem: promote clawpdf into the TypeScript libraries section with its canonical site link.
6+
- Website: sanitize data-driven external links before rendering them into `href` attributes (#143, thanks @SebTardif).
67
- Website: move the Discord shortcut into Vercel routing so `/discord` redirects correctly (#147, thanks @SebTardif).
78
- Ecosystem: tune project card banner art visibility.
89
- Ecosystem: quiet the final contribution CTA button colors.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"scripts": {
66
"dev": "astro dev",
77
"build": "astro build",
8+
"test": "bun test",
89
"avatars:cache": "node scripts/cache-avatars.mjs",
910
"preview": "astro preview"
1011
},

src/lib/sanitize-url.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const allowedProtocols = new Set(['http:', 'https:', 'mailto:']);
2+
3+
function normalizeForProtocolCheck(url: string): string {
4+
let decoded = url;
5+
try {
6+
decoded = decodeURIComponent(url);
7+
} catch {
8+
// Malformed escapes still flow through URL parsing below.
9+
}
10+
11+
return decoded.replace(/[\x00-\x1f\x7f]/g, '');
12+
}
13+
14+
export function sanitizeUrl(url: string): string {
15+
try {
16+
const parsed = new URL(normalizeForProtocolCheck(url), 'https://openclaw.ai');
17+
return allowedProtocols.has(parsed.protocol) ? url : '#';
18+
} catch {
19+
return '#';
20+
}
21+
}

src/pages/blog/[...slug].astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import SiteTopbar from '../../components/SiteTopbar.astro';
44
import { render } from 'astro:content';
55
import { getPublishedBlogPosts } from '../../lib/blog';
66
import { getCachedXAvatarSrc, getInitialsAvatarSrc } from '../../lib/avatars';
7+
import { sanitizeUrl } from '../../lib/sanitize-url';
78
89
export async function getStaticPaths() {
910
const posts = await getPublishedBlogPosts();
@@ -127,7 +128,7 @@ const postUrl = `https://openclaw.ai/blog/${post.id}`;
127128
{getAuthorLinks(author).length > 0 && (
128129
<div class="author-links">
129130
{getAuthorLinks(author).map((link) => (
130-
<a href={link.url} class="author-handle" target="_blank" rel="noopener">{link.label}</a>
131+
<a href={sanitizeUrl(link.url)} class="author-handle" target="_blank" rel="noopener">{link.label}</a>
131132
))}
132133
</div>
133134
)}

src/pages/index.astro

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import communityBuilds from '../data/community-builds.json';
88
import pressArticles from '../data/press.json';
99
import { getPublishedBlogPosts } from '../lib/blog';
1010
import { getCachedXAvatarSrc, getInitialsAvatarSrc } from '../lib/avatars';
11+
import { sanitizeUrl } from '../lib/sanitize-url';
1112
1213
const blogPosts = await getPublishedBlogPosts();
1314
const [latestPost] = blogPosts;
@@ -534,7 +535,7 @@ function formatBlogDate(date: Date): string {
534535
<div class="testimonials-track">
535536
<div class="testimonials-row row-1" style={`--duration: ${duration1}s`}>
536537
{row1.map((t) => (
537-
<a href={t.url} target="_blank" rel="noopener" class="testimonial-card" tabindex="-1">
538+
<a href={sanitizeUrl(t.url)} target="_blank" rel="noopener" class="testimonial-card" tabindex="-1">
538539
<img
539540
src={getCachedXAvatarSrc(t.author, t.author, 88)}
540541
alt={t.author}
@@ -551,7 +552,7 @@ function formatBlogDate(date: Date): string {
551552
</div>
552553
<div class="testimonials-row row-2" style={`--duration: ${duration2}s`}>
553554
{row2.map((t) => (
554-
<a href={t.url} target="_blank" rel="noopener" class="testimonial-card">
555+
<a href={sanitizeUrl(t.url)} target="_blank" rel="noopener" class="testimonial-card">
555556
<img
556557
src={getCachedXAvatarSrc(t.author, t.author, 88)}
557558
alt={t.author}
@@ -666,7 +667,7 @@ function formatBlogDate(date: Date): string {
666667
/>
667668
<div class="builds-grid">
668669
{featuredBuilds.map((build) => (
669-
<a href={build.href} target="_blank" rel="noopener" class="build-card">
670+
<a href={sanitizeUrl(build.href)} target="_blank" rel="noopener" class="build-card">
670671
<div class="build-card-header">
671672
<span class="build-source">{build.source}</span>
672673
<span class="build-open">Open →</span>
@@ -694,7 +695,7 @@ function formatBlogDate(date: Date): string {
694695
<div class="press-grid">
695696
{featuredPress.map((article) => (
696697
<a
697-
href={article.url}
698+
href={sanitizeUrl(article.url)}
698699
target="_blank"
699700
rel="noopener"
700701
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>

tests/sanitize-url.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { sanitizeUrl } from '../src/lib/sanitize-url';
3+
4+
describe('sanitizeUrl', () => {
5+
test('keeps allowed absolute and relative URLs unchanged', () => {
6+
expect(sanitizeUrl('https://x.com/openclaw')).toBe('https://x.com/openclaw');
7+
expect(sanitizeUrl('http://example.com/path')).toBe('http://example.com/path');
8+
expect(sanitizeUrl('mailto:security@openclaw.ai')).toBe('mailto:security@openclaw.ai');
9+
expect(sanitizeUrl('/blog#security')).toBe('/blog#security');
10+
});
11+
12+
test('blocks dangerous protocols', () => {
13+
expect(sanitizeUrl('javascript:alert(1)')).toBe('#');
14+
expect(sanitizeUrl('JaVaScRiPt:alert(1)')).toBe('#');
15+
expect(sanitizeUrl('data:text/html,<script>alert(1)</script>')).toBe('#');
16+
expect(sanitizeUrl('vbscript:msgbox(1)')).toBe('#');
17+
expect(sanitizeUrl('ftp://example.com/file')).toBe('#');
18+
});
19+
20+
test('blocks encoded and control-character protocol bypasses', () => {
21+
expect(sanitizeUrl('%6a%61%76%61%73%63%72%69%70%74:alert(1)')).toBe('#');
22+
expect(sanitizeUrl('java\u0000script:alert(1)')).toBe('#');
23+
expect(sanitizeUrl('java\nscript:alert(1)')).toBe('#');
24+
});
25+
26+
test('blocks unparsable input', () => {
27+
expect(sanitizeUrl('http://[::1')).toBe('#');
28+
expect(sanitizeUrl('javascript:%E0%A4%A')).toBe('#');
29+
});
30+
});

0 commit comments

Comments
 (0)