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; +}