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
93 changes: 93 additions & 0 deletions src/lib/__tests__/articles-db-sanitization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it, vi } from 'vitest';

vi.mock('../db/client', () => ({ db: {} }));
vi.mock('../memories-db', () => ({ findMemoryByUrl: vi.fn(), createMemoryRecord: vi.fn() }));

import { sanitizeArticlePayload } from '../articles-db';
import { sanitizeBrowserMemorySnapshot } from '../browser-memory-import';

const sourceUrl = 'https://source.example/articles/story?edition=1#intro';
const sanitize = (content: string, url = sourceUrl) =>
sanitizeArticlePayload({ content, url, userId: 'synthetic-user' }).content;

describe('article source URL sanitization', () => {
it.each([
['/explore/', 'https://source.example/explore/'],
['/insights/?topic=ai#latest', 'https://source.example/insights/?topic=ai#latest'],
['next?edition=2#details', 'https://source.example/articles/next?edition=2#details'],
['../archive/', 'https://source.example/archive/'],
['?edition=2#details', 'https://source.example/articles/story?edition=2#details'],
['//cdn.example/image.png', 'https://cdn.example/image.png'],
])('resolves href and src %s using the full source URL', (relative, absolute) => {
const content = sanitize(`<a href="${relative}">link</a><img src="${relative}" />`);
expect(content).toContain(`href="${absolute}"`);
expect(content).toContain(`src="${absolute}"`);
});

it('uses the source protocol and directory or trailing slash', () => {
expect(sanitize('<img src="//cdn.example/image.png" />', 'http://source.example/')).toContain(
'src="http://cdn.example/image.png"'
);
expect(sanitize('<a href="next">link</a>', 'https://source.example/articles/')).toContain(
'href="https://source.example/articles/next"'
);
});

it('keeps local fragments and safe markup unchanged', () => {
const html =
'<h2 id="details">Details</h2><p class="body"><strong>Text</strong> ' +
'<a href="#details">jump</a><a href="https://other.example/path?x=1#part">external</a>' +
'<a href="mailto:reader@example.org">email</a></p><img src="data:image/png;base64,AA==" />';
expect(sanitize(html)).toBe(html);
expect(sanitize(sanitize(html))).toBe(html);
});

it.each([
'javascript:alert(1)',
'jav&#x61;script:alert(1)',
'java&#10;script:alert(1)',
'vbscript:alert(1)',
'file:///private/file',
'blob:https://source.example/id',
'data:text/html,unsafe',
])('rejects unsafe scheme %s', (url) => {
const html = sanitize(
`<a href="${url}">link</a><img src="${url}" /><iframe src="${url}"></iframe>`
);
expect(html).not.toContain('href=');
if (!url.startsWith('data:')) expect(html).not.toContain('src=');
// Images retain the existing data: allowance; links and frames do not.
expect(html).not.toContain('<iframe src=');
});

it('retains sanitization and iframe host restrictions', () => {
const html = sanitize(
'<base href="https://untrusted.example/"><script>alert(1)</script>' +
'<p onclick="alert(1)">Text</p><iframe src="//untrusted.example/embed"></iframe>' +
'<iframe src="//www.youtube.com/embed/synthetic"></iframe>'
);
expect(html).not.toMatch(/<base|<script|onclick|untrusted\.example/);
expect(html).toContain('src="https://www.youtube.com/embed/synthetic"');
});

it('does not use an invalid or unsafe source as a base', () => {
expect(sanitize('<a href="javascript:alert(1)">link</a>', 'javascript:alert(1)')).toBe(
'<a>link</a>'
);
expect(sanitize('<p>Text</p>', 'not a URL')).toBe('<p>Text</p>');
});

it('resolves browser-memory imports through the real shared sanitizer', () => {
const result = sanitizeBrowserMemorySnapshot({
url: sourceUrl,
content: '<a href="/insights/">link</a><img src="../image.png" /><a href="#intro">jump</a>',
});
expect(result).toHaveProperty('snapshot');
if ('snapshot' in result) {
expect(result.snapshot.content).toBe(
'<a href="https://source.example/insights/">link</a>' +
'<img src="https://source.example/image.png" /><a href="#intro">jump</a>'
);
}
});
});
31 changes: 29 additions & 2 deletions src/lib/articles-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,34 @@ async function filterOwnedListIds(userId: string, listIds: string[]): Promise<st
export const sanitizePlainText = (value: unknown) =>
sanitizeHtml(String(value ?? ''), plainTextSanitizeOptions).trim();

const sanitizeHTML = (value: unknown) => sanitizeHtml(String(value ?? ''), htmlSanitizeOptions);
export function sanitizeArticleHTML(value: unknown, sourceUrl: string): string {
let baseUrl: URL | undefined;
try {
const parsed = new URL(sourceUrl);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') baseUrl = parsed;
} catch {
// Non-web article types may have no usable source URL.
}

return sanitizeHtml(String(value ?? ''), {
...htmlSanitizeOptions,
transformTags: {
'*': (tagName, attribs) => {
for (const attribute of ['href', 'src']) {
const value = attribs[attribute]?.trim();
if (!baseUrl || !value || value.startsWith('#')) continue;
try {
attribs[attribute] = new URL(value, baseUrl).href;
} catch {
delete attribs[attribute];
}
}
// sanitize-html applies its scheme and attribute checks after this transform.
return { tagName, attribs };
},
},
});
}

export const sanitizeTitle = (value: unknown, fallback = '') =>
sanitizePlainText(value ?? fallback).slice(0, 500);
Expand Down Expand Up @@ -207,7 +234,7 @@ export function sanitizeArticlePayload(payload: {
url: sanitizedUrl,
title: sanitizeTitle(payload.title, sanitizedUrl),
byline: sanitizePlainText(payload.byline || ''),
content: sanitizeHTML(payload.content || ''),
content: sanitizeArticleHTML(payload.content || '', sanitizedUrl),
projectId: sanitizePlainText(payload.projectId || defProjectId) || defProjectId,
tags: normalizeTags(payload.tags),
userId: payload.userId,
Expand Down
102 changes: 102 additions & 0 deletions src/worker/routes/__tests__/misc-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Hono } from 'hono';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({ user: vi.fn(), validate: vi.fn(), fetch: vi.fn() }));
vi.mock('../../../lib/auth-api', () => ({ getAuthenticatedUserId: mocks.user }));
vi.mock('../../../lib/db/client', () => ({ db: {}, schema: {} }));
vi.mock('../../../lib/url-validation', () => ({ validateExternalUrl: mocks.validate }));
vi.mock('../../../lib/ai-cloudflare', () => ({ getLanguageModel: vi.fn() }));

import { sanitizeArticlePayload } from '../../../lib/articles-db';
import routes from '../misc';

const app = new Hono().route('/api', routes);
const startUrl = 'https://initial.example/start';
const finalUrl = 'https://source.example/articles/story?edition=1#intro';
const html = `<html><head><title>Synthetic article</title>
<base href="https://untrusted.example/"></head><body><article><h1>Synthetic article</h1>
<p>${'This is a synthetic readable article for testing source links. '.repeat(30)}</p>
<p><a href="/explore/">Explore</a><a href="/insights/">Insights</a>
<a href="next?edition=2#details">Next</a><a href="?edition=2#details">Edition</a>
<a href="#intro">Jump</a><img src="../image.png"><img src="//cdn.example/image.png">
<a href="javascript:alert(1)">Unsafe</a></p></article></body></html>`;

beforeEach(() => {
vi.clearAllMocks();
mocks.user.mockResolvedValue('synthetic-user');
mocks.validate.mockImplementation(async (url: string) => ({ ok: true, url: new URL(url) }));
vi.stubGlobal('fetch', mocks.fetch);
});
afterEach(() => vi.unstubAllGlobals());

describe('snapshot source URL resolution', () => {
it('resolves extracted links using the validated redirect destination before storage', async () => {
mocks.fetch
.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: finalUrl },
})
)
.mockResolvedValueOnce(new Response(html));
const response = await app.request(`/api/snapshot?url=${encodeURIComponent(startUrl)}`);
expect(response.status).toBe(200);
const { snapshot } = await response.json();
expect(snapshot.url).toBe(finalUrl);
expect(mocks.validate).toHaveBeenNthCalledWith(1, startUrl);
expect(mocks.validate).toHaveBeenNthCalledWith(2, finalUrl);
expect(mocks.fetch).toHaveBeenCalledTimes(2);
expect(mocks.fetch).toHaveBeenNthCalledWith(
2,
finalUrl,
expect.objectContaining({ redirect: 'manual' })
);
for (const url of [
'https://source.example/explore/',
'https://source.example/insights/',
'https://source.example/articles/next?edition=2#details',
'https://source.example/articles/story?edition=2#details',
'https://source.example/image.png',
'https://cdn.example/image.png',
]) {
expect(snapshot.content).toContain(url);
}
expect(snapshot.content).toContain('href="#intro"');
expect(snapshot.content).not.toMatch(/javascript:|untrusted\.example/);
// Capture callers currently save the initial URL. Absolute extracted links survive that step.
expect(
sanitizeArticlePayload({ ...snapshot, url: startUrl, userId: 'synthetic-user' }).content
).toBe(snapshot.content);
});

it('resolves a non-redirected capture against its full source URL', async () => {
mocks.fetch.mockResolvedValueOnce(new Response(html));
const response = await app.request(`/api/snapshot?url=${encodeURIComponent(finalUrl)}`);
const { snapshot } = await response.json();
expect(snapshot.url).toBe(finalUrl);
expect(snapshot.content).toContain('https://source.example/articles/next?edition=2#details');
expect(mocks.fetch).toHaveBeenCalledTimes(1);
});

it('rejects unsafe initial URLs without fetching', async () => {
mocks.validate.mockResolvedValue({ ok: false, reason: 'Blocked: localhost' });
const response = await app.request('/api/snapshot?url=http://localhost/private');
expect(response.status).toBe(400);
expect(mocks.fetch).not.toHaveBeenCalled();
});

it('rejects unsafe redirects before fetching the target', async () => {
mocks.fetch.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: 'http://127.0.0.1/private' },
})
);
mocks.validate
.mockResolvedValueOnce({ ok: true, url: new URL(startUrl) })
.mockResolvedValueOnce({ ok: false, reason: 'Blocked: private or reserved IP' });
const response = await app.request(`/api/snapshot?url=${encodeURIComponent(startUrl)}`);
expect(response.status).toBe(500);
expect(mocks.fetch).toHaveBeenCalledTimes(1);
});
});
19 changes: 15 additions & 4 deletions src/worker/routes/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { Hono } from 'hono';
import { parseHTML } from 'linkedom';

import { getLanguageModel } from '../../lib/ai-cloudflare';
import { fetchAllTags, fetchArticlesForSourceMap, searchArticles } from '../../lib/articles-db';
import {
fetchAllTags,
fetchArticlesForSourceMap,
sanitizeArticleHTML,
searchArticles,
} from '../../lib/articles-db';
import { getAuthenticatedUserId } from '../../lib/auth-api';
import type { BrowserMemorySnapshotInput } from '../../lib/browser-memory-import';
import { importBrowserMemorySnapshots } from '../../lib/browser-memory-import';
Expand Down Expand Up @@ -187,7 +192,7 @@ async function fetchSnapshot(targetUrl: string): Promise<{
siteName: string | null;
url: string;
}> {
const { response } = await fetchWithValidatedRedirects(targetUrl, {
const { response, url } = await fetchWithValidatedRedirects(targetUrl, {
headers: {
'User-Agent': SNAPSHOT_USER_AGENT,
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
Expand All @@ -206,6 +211,12 @@ async function fetchSnapshot(targetUrl: string): Promise<{

const html = new TextDecoder().decode(body);
const { document } = parseHTML(html);
// Readability resolves URLs itself. Use the validated source, not an HTML base tag,
// and match documentURI so its local fragment links remain local.
Object.defineProperties(document, {
baseURI: { value: url.href },
documentURI: { value: url.href },
});

const reader = new Readability(document);
const article = reader.parse();
Expand All @@ -216,10 +227,10 @@ async function fetchSnapshot(targetUrl: string): Promise<{

return {
title: article.title ?? '',
content: article.content ?? '',
content: sanitizeArticleHTML(article.content ?? '', url.href),
byline: article.byline ?? null,
siteName: article.siteName ?? null,
url: targetUrl,
url: url.href,
};
}

Expand Down
Loading