diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index a3bde90..ce9638c 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
-version = "0.128.10"
+version = "0.128.11"
dependencies = [
"anyhow",
"argon2",
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index 7c651bd..c9c8c5c 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mangalord"
-version = "0.128.10"
+version = "0.128.11"
edition = "2021"
default-run = "mangalord"
diff --git a/frontend/package.json b/frontend/package.json
index 8049c19..ed2aae8 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
- "version": "0.128.10",
+ "version": "0.128.11",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/lib/components/crawler/CrawlerHistoryTable.svelte b/frontend/src/lib/components/crawler/CrawlerHistoryTable.svelte
index 501d78e..cd5a6a7 100644
--- a/frontend/src/lib/components/crawler/CrawlerHistoryTable.svelte
+++ b/frontend/src/lib/components/crawler/CrawlerHistoryTable.svelte
@@ -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 @@
{r.kind ?? '—'} |
{#if !r.manga_title && r.payload_title && r.source_url}
- {target(r)}
+ {@const href = safeExternalHref(r.source_url)}
+ {#if href}
+ {target(r)}
+ {:else}
+ {target(r)}
+ {/if}
{:else}
{target(r)}
{/if}
diff --git a/frontend/src/lib/components/crawler/DeadJobsTable.svelte b/frontend/src/lib/components/crawler/DeadJobsTable.svelte
index 2d5db14..19a0704 100644
--- a/frontend/src/lib/components/crawler/DeadJobsTable.svelte
+++ b/frontend/src/lib/components/crawler/DeadJobsTable.svelte
@@ -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}
- {j.payload_title}
+ {@const href = safeExternalHref(j.source_url)}
+ {#if href}
+ {j.payload_title}
+ {:else}
+ {j.payload_title}
+ {/if}
{:else}
{j.payload_title}
{/if}
diff --git a/frontend/src/lib/safeHref.test.ts b/frontend/src/lib/safeHref.test.ts
new file mode 100644
index 0000000..718b3c0
--- /dev/null
+++ b/frontend/src/lib/safeHref.test.ts
@@ -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,')).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();
+ });
+});
diff --git a/frontend/src/lib/safeHref.ts b/frontend/src/lib/safeHref.ts
new file mode 100644
index 0000000..285ae6e
--- /dev/null
+++ b/frontend/src/lib/safeHref.ts
@@ -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;
+}
|