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]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.10"
|
version = "0.128.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.10"
|
version = "0.128.11"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.128.10",
|
"version": "0.128.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Pager from '$lib/components/Pager.svelte';
|
import Pager from '$lib/components/Pager.svelte';
|
||||||
import { fmtDuration } from '$lib/format';
|
import { fmtDuration } from '$lib/format';
|
||||||
|
import { safeExternalHref } from '$lib/safeHref';
|
||||||
import SearchBar from './SearchBar.svelte';
|
import SearchBar from './SearchBar.svelte';
|
||||||
import {
|
import {
|
||||||
listCrawlerJobHistory,
|
listCrawlerJobHistory,
|
||||||
@@ -175,9 +176,14 @@
|
|||||||
<td class="kind">{r.kind ?? '—'}</td>
|
<td class="kind">{r.kind ?? '—'}</td>
|
||||||
<td>
|
<td>
|
||||||
{#if !r.manga_title && r.payload_title && r.source_url}
|
{#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)}
|
||||||
>{target(r)}</a
|
{#if href}
|
||||||
>
|
<a {href} target="_blank" rel="noreferrer noopener"
|
||||||
|
>{target(r)}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
{target(r)}
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{target(r)}
|
{target(r)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import Pager from '$lib/components/Pager.svelte';
|
import Pager from '$lib/components/Pager.svelte';
|
||||||
import type { DeadJob, RequeueScope } from '$lib/api/admin';
|
import type { DeadJob, RequeueScope } from '$lib/api/admin';
|
||||||
import SearchBar from './SearchBar.svelte';
|
import SearchBar from './SearchBar.svelte';
|
||||||
|
import { safeExternalHref } from '$lib/safeHref';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
jobs,
|
jobs,
|
||||||
@@ -60,9 +61,14 @@
|
|||||||
{j.manga_title}
|
{j.manga_title}
|
||||||
{:else if j.payload_title}
|
{:else if j.payload_title}
|
||||||
{#if j.source_url}
|
{#if j.source_url}
|
||||||
<a href={j.source_url} target="_blank" rel="noreferrer noopener"
|
{@const href = safeExternalHref(j.source_url)}
|
||||||
>{j.payload_title}</a
|
{#if href}
|
||||||
>
|
<a {href} target="_blank" rel="noreferrer noopener"
|
||||||
|
>{j.payload_title}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
{j.payload_title}
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{j.payload_title}
|
{j.payload_title}
|
||||||
{/if}
|
{/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