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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.128.10"
|
||||
version = "0.128.11"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.128.10"
|
||||
version = "0.128.11"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.128.10",
|
||||
"version": "0.128.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,9 +176,14 @@
|
||||
<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"
|
||||
>{target(r)}</a
|
||||
>
|
||||
{@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}
|
||||
|
||||
@@ -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,9 +61,14 @@
|
||||
{j.manga_title}
|
||||
{:else if j.payload_title}
|
||||
{#if j.source_url}
|
||||
<a href={j.source_url} target="_blank" rel="noreferrer noopener"
|
||||
>{j.payload_title}</a
|
||||
>
|
||||
{@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}
|
||||
|
||||
29
frontend/src/lib/safeHref.test.ts
Normal file
29
frontend/src/lib/safeHref.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
21
frontend/src/lib/safeHref.ts
Normal file
21
frontend/src/lib/safeHref.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user