fix: scheme-validate crawler source_url before rendering as a link

Harvested source_url (external content) rendered as an admin <a href> without
scheme validation — a javascript:/data: link would execute on click (audit M2).
safeExternalHref() gates to http(s); CrawlerHistoryTable/DeadJobsTable fall back
to plain text otherwise. Tested in safeHref.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 21:16:08 +02:00
parent f2c9cfe162
commit 958d5bd713
7 changed files with 71 additions and 9 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.128.10"
version = "0.128.11"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.128.10"
version = "0.128.11"
edition = "2021"
default-run = "mangalord"

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.128.10",
"version": "0.128.11",
"private": true,
"type": "module",
"scripts": {

View File

@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import Pager from '$lib/components/Pager.svelte';
import { fmtDuration } from '$lib/format';
import { safeExternalHref } from '$lib/safeHref';
import SearchBar from './SearchBar.svelte';
import {
listCrawlerJobHistory,
@@ -175,12 +176,17 @@
<td class="kind">{r.kind ?? '—'}</td>
<td>
{#if !r.manga_title && r.payload_title && r.source_url}
<a href={r.source_url} target="_blank" rel="noreferrer noopener"
{@const href = safeExternalHref(r.source_url)}
{#if href}
<a {href} target="_blank" rel="noreferrer noopener"
>{target(r)}</a
>
{:else}
{target(r)}
{/if}
{:else}
{target(r)}
{/if}
</td>
<td class="num">{fmtDuration(r.duration_ms)}</td>
<td>{r.attempts}/{r.max_attempts}</td>

View File

@@ -2,6 +2,7 @@
import Pager from '$lib/components/Pager.svelte';
import type { DeadJob, RequeueScope } from '$lib/api/admin';
import SearchBar from './SearchBar.svelte';
import { safeExternalHref } from '$lib/safeHref';
let {
jobs,
@@ -60,12 +61,17 @@
{j.manga_title}
{:else if j.payload_title}
{#if j.source_url}
<a href={j.source_url} target="_blank" rel="noreferrer noopener"
{@const href = safeExternalHref(j.source_url)}
{#if href}
<a {href} target="_blank" rel="noreferrer noopener"
>{j.payload_title}</a
>
{:else}
{j.payload_title}
{/if}
{:else}
{j.payload_title}
{/if}
{:else}
{j.source_key ?? '(unknown)'}
{/if}

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { safeExternalHref } from './safeHref';
describe('safeExternalHref', () => {
it('passes through http and https URLs', () => {
expect(safeExternalHref('http://example.com/manga/1')).toBe(
'http://example.com/manga/1'
);
expect(safeExternalHref('https://example.com/x?y=1#z')).toBe(
'https://example.com/x?y=1#z'
);
});
it('rejects dangerous schemes', () => {
expect(safeExternalHref('javascript:alert(document.cookie)')).toBeNull();
expect(safeExternalHref('JavaScript:alert(1)')).toBeNull();
expect(safeExternalHref('data:text/html,<script>alert(1)</script>')).toBeNull();
expect(safeExternalHref('vbscript:msgbox(1)')).toBeNull();
expect(safeExternalHref('file:///etc/passwd')).toBeNull();
});
it('rejects relative, malformed, empty, and nullish input', () => {
expect(safeExternalHref('/relative/path')).toBeNull();
expect(safeExternalHref('not a url')).toBeNull();
expect(safeExternalHref('')).toBeNull();
expect(safeExternalHref(null)).toBeNull();
expect(safeExternalHref(undefined)).toBeNull();
});
});

View File

@@ -0,0 +1,21 @@
/**
* Return `url` only if it is a well-formed `http(s)` URL that is safe to place
* in an `href`, otherwise `null`.
*
* Crawler `source_url` values are external/untrusted content harvested from
* scraped source sites. Svelte does NOT sanitize URL schemes in `href`
* bindings, so a harvested `javascript:`/`data:`/`vbscript:` link would execute
* (or phish) in the admin origin on click. Callers render the title as plain
* text when this returns `null`.
*/
export function safeExternalHref(url: string | null | undefined): string | null {
if (!url) return null;
let parsed: URL;
try {
parsed = new URL(url);
} catch {
// Relative or malformed — not a safe external link.
return null;
}
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? url : null;
}