Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

- Ecosystem: promote clawpdf into the TypeScript libraries section with its canonical site link.
- Website: sanitize data-driven external links before rendering them into `href` attributes (#143, thanks @SebTardif).
- Website: move the Discord shortcut into Vercel routing so `/discord` redirects correctly (#147, thanks @SebTardif).
- Ecosystem: tune project card banner art visibility.
- Ecosystem: quiet the final contribution CTA button colors.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"scripts": {
"dev": "astro dev",
"build": "astro build",
"test": "bun test",
"avatars:cache": "node scripts/cache-avatars.mjs",
"preview": "astro preview"
},
Expand Down
21 changes: 21 additions & 0 deletions src/lib/sanitize-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const allowedProtocols = new Set(['http:', 'https:', 'mailto:']);

function normalizeForProtocolCheck(url: string): string {
let decoded = url;
try {
decoded = decodeURIComponent(url);
} catch {
// Malformed escapes still flow through URL parsing below.
}

return decoded.replace(/[\x00-\x1f\x7f]/g, '');
}

export function sanitizeUrl(url: string): string {
try {
const parsed = new URL(normalizeForProtocolCheck(url), 'https://openclaw.ai');
return allowedProtocols.has(parsed.protocol) ? url : '#';
} catch {
return '#';
}
}
3 changes: 2 additions & 1 deletion src/pages/blog/[...slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import SiteTopbar from '../../components/SiteTopbar.astro';
import { render } from 'astro:content';
import { getPublishedBlogPosts } from '../../lib/blog';
import { getCachedXAvatarSrc, getInitialsAvatarSrc } from '../../lib/avatars';
import { sanitizeUrl } from '../../lib/sanitize-url';

export async function getStaticPaths() {
const posts = await getPublishedBlogPosts();
Expand Down Expand Up @@ -127,7 +128,7 @@ const postUrl = `https://openclaw.ai/blog/${post.id}`;
{getAuthorLinks(author).length > 0 && (
<div class="author-links">
{getAuthorLinks(author).map((link) => (
<a href={link.url} class="author-handle" target="_blank" rel="noopener">{link.label}</a>
<a href={sanitizeUrl(link.url)} class="author-handle" target="_blank" rel="noopener">{link.label}</a>
))}
</div>
)}
Expand Down
9 changes: 5 additions & 4 deletions src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import communityBuilds from '../data/community-builds.json';
import pressArticles from '../data/press.json';
import { getPublishedBlogPosts } from '../lib/blog';
import { getCachedXAvatarSrc, getInitialsAvatarSrc } from '../lib/avatars';
import { sanitizeUrl } from '../lib/sanitize-url';

const blogPosts = await getPublishedBlogPosts();
const [latestPost] = blogPosts;
Expand Down Expand Up @@ -534,7 +535,7 @@ function formatBlogDate(date: Date): string {
<div class="testimonials-track">
<div class="testimonials-row row-1" style={`--duration: ${duration1}s`}>
{row1.map((t) => (
<a href={t.url} target="_blank" rel="noopener" class="testimonial-card" tabindex="-1">
<a href={sanitizeUrl(t.url)} target="_blank" rel="noopener" class="testimonial-card" tabindex="-1">
<img
src={getCachedXAvatarSrc(t.author, t.author, 88)}
alt={t.author}
Expand All @@ -551,7 +552,7 @@ function formatBlogDate(date: Date): string {
</div>
<div class="testimonials-row row-2" style={`--duration: ${duration2}s`}>
{row2.map((t) => (
<a href={t.url} target="_blank" rel="noopener" class="testimonial-card">
<a href={sanitizeUrl(t.url)} target="_blank" rel="noopener" class="testimonial-card">
<img
src={getCachedXAvatarSrc(t.author, t.author, 88)}
alt={t.author}
Expand Down Expand Up @@ -666,7 +667,7 @@ function formatBlogDate(date: Date): string {
/>
<div class="builds-grid">
{featuredBuilds.map((build) => (
<a href={build.href} target="_blank" rel="noopener" class="build-card">
<a href={sanitizeUrl(build.href)} target="_blank" rel="noopener" class="build-card">
<div class="build-card-header">
<span class="build-source">{build.source}</span>
<span class="build-open">Open →</span>
Expand Down Expand Up @@ -694,7 +695,7 @@ function formatBlogDate(date: Date): string {
<div class="press-grid">
{featuredPress.map((article) => (
<a
href={article.url}
href={sanitizeUrl(article.url)}
target="_blank"
rel="noopener"
class:list={['press-card', article.featured && 'press-featured']}
Expand Down
3 changes: 2 additions & 1 deletion src/pages/press.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
import Layout from '../layouts/Layout.astro';
import pressArticles from '../data/press.json';
import { sanitizeUrl } from '../lib/sanitize-url';
---

<Layout
Expand All @@ -26,7 +27,7 @@ import pressArticles from '../data/press.json';
<div class="press-grid">
{pressArticles.map((article) => (
<a
href={article.url}
href={sanitizeUrl(article.url)}
target="_blank"
rel="noopener"
class:list={['press-card', article.featured && 'press-featured']}
Expand Down
3 changes: 2 additions & 1 deletion src/pages/shoutouts.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Layout from '../layouts/Layout.astro';
import testimonials from '../data/testimonials.json';
import extraTestimonials from '../data/testimonials-extra.json';
import { getCachedXAvatarSrc, getInitialsAvatarSrc } from '../lib/avatars';
import { sanitizeUrl } from '../lib/sanitize-url';

// Combine all testimonials - strongest first, then extras (no randomization)
const allTestimonials = [...testimonials, ...extraTestimonials];
Expand All @@ -23,7 +24,7 @@ const allTestimonials = [...testimonials, ...extraTestimonials];

<div class="shoutouts-grid">
{allTestimonials.map((t) => (
<a href={t.url} target="_blank" rel="noopener" class="shoutout-card">
<a href={sanitizeUrl(t.url)} target="_blank" rel="noopener" class="shoutout-card">
<img
src={t.avatar || getCachedXAvatarSrc(t.author, t.author, 96)}
alt={t.author}
Expand Down
3 changes: 2 additions & 1 deletion src/pages/showcase.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Layout from '../layouts/Layout.astro';
import showcaseData from '../data/showcase.json';
import featuredBuilds from '../data/community-builds.json';
import { getCachedXAvatarSrc, getInitialsAvatarSrc } from '../lib/avatars';
import { sanitizeUrl } from '../lib/sanitize-url';

const featuredIds = new Set(featuredBuilds.map((item) => item.id));
const sortedShowcase = showcaseData.filter((item) => !featuredIds.has(item.id));
Expand Down Expand Up @@ -41,7 +42,7 @@ const categoryLabels: Record<string, string> = {
</div>
<div class="featured-grid">
{featuredBuilds.map((build) => (
<a href={build.href} target="_blank" rel="noopener" class="featured-card">
<a href={sanitizeUrl(build.href)} target="_blank" rel="noopener" class="featured-card">
<div class="featured-topline">
<span class="featured-source">{build.source}</span>
<span class="featured-likes">❤️ {build.likes}</span>
Expand Down
30 changes: 30 additions & 0 deletions tests/sanitize-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, test } from 'bun:test';
import { sanitizeUrl } from '../src/lib/sanitize-url';

describe('sanitizeUrl', () => {
test('keeps allowed absolute and relative URLs unchanged', () => {
expect(sanitizeUrl('https://x.com/openclaw')).toBe('https://x.com/openclaw');
expect(sanitizeUrl('http://example.com/path')).toBe('http://example.com/path');
expect(sanitizeUrl('mailto:security@openclaw.ai')).toBe('mailto:security@openclaw.ai');
expect(sanitizeUrl('/blog#security')).toBe('/blog#security');
});

test('blocks dangerous protocols', () => {
expect(sanitizeUrl('javascript:alert(1)')).toBe('#');
expect(sanitizeUrl('JaVaScRiPt:alert(1)')).toBe('#');
expect(sanitizeUrl('data:text/html,<script>alert(1)</script>')).toBe('#');
expect(sanitizeUrl('vbscript:msgbox(1)')).toBe('#');
expect(sanitizeUrl('ftp://example.com/file')).toBe('#');
});

test('blocks encoded and control-character protocol bypasses', () => {
expect(sanitizeUrl('%6a%61%76%61%73%63%72%69%70%74:alert(1)')).toBe('#');
expect(sanitizeUrl('java\u0000script:alert(1)')).toBe('#');
expect(sanitizeUrl('java\nscript:alert(1)')).toBe('#');
});

test('blocks unparsable input', () => {
expect(sanitizeUrl('http://[::1')).toBe('#');
expect(sanitizeUrl('javascript:%E0%A4%A')).toBe('#');
});
});
Loading