fix(server): inert proxy responses, API-call header, image-only uploads; origin check by host

From the final review: proxied archived HTML could run on our origin and read a
remembered token; /api/upload accepted any Wayback URL and GIF-prefixed bytes; the
origin check broke behind a TLS proxy; one unreadable snapshot could fail a run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-15 01:49:52 +02:00
1 parent 5084f8c557
commit baa3476b73
16 files changed
+183 -26

No files matched your search

+11 -5
View File
@@ -103,11 +103,17 @@ export async function waybackHistory(source: Source, series: SourceSeries, opts:
break;
}
if (res.ok) {
const pageUrl = new URL(row.original);
for (const c of source.extractCovers(await res.text(), pageUrl)) {
const candidate: CoverCandidate = { ...c, current: false, seenAt: row.timestamp, lastSeenAt: row.timestamp };
if (c.originalUrl) candidate.archivedUrl = archivedImageUrl(row.timestamp, c.originalUrl);
found.push(candidate);
try {
const pageUrl = new URL(row.original);
for (const c of source.extractCovers(await res.text(), pageUrl)) {
const candidate: CoverCandidate = { ...c, current: false, seenAt: row.timestamp, lastSeenAt: row.timestamp };
if (c.originalUrl) candidate.archivedUrl = archivedImageUrl(row.timestamp, c.originalUrl);
found.push(candidate);
}
} catch (err) {
// One unreadable snapshot never costs the rest of the history (spec §4.3).
if (opts.signal?.aborted) break;
opts.warn(`Snapshot ${formatMonth(row.timestamp)} couldn't be read (${message(err)}).`);
}
} else {
await res.body?.cancel();
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Transport } from "../transport";
import { API_CALL_HEADER, type Transport } from "../transport";
export type HostId = "server" | "litterbox" | "catbox" | "direct";
export type LitterboxTime = "1h" | "12h" | "24h" | "72h";
@@ -55,7 +55,7 @@ export async function viaServer(
if (ctx.serverBase === null) throw new HostError("No mangabaka-seeder server is configured.");
const res = await ctx.transport.fetch(`${ctx.serverBase}/api/upload`, {
method: "POST",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", [API_CALL_HEADER]: "1" },
body: JSON.stringify({ url: image.sourceUrl, ...body }),
timeoutMs: UPLOAD_TIMEOUT_MS,
});
+5 -2
View File
@@ -5,8 +5,11 @@ const NAMED: Readonly<Record<string, string>> = { amp: "&", lt: "<", gt: ">", qu
export function decodeEntities(s: string): string {
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
const e = entity.toLowerCase();
if (e.startsWith("#x")) return String.fromCodePoint(parseInt(e.slice(2), 16));
if (e.startsWith("#")) return String.fromCodePoint(parseInt(e.slice(1), 10));
if (e.startsWith("#")) {
const code = e.startsWith("#x") ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
// Malformed pages carry entities like &#99999999;: leave them as written rather than throw.
return Number.isInteger(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
}
return NAMED[e] ?? match;
});
}
+5
View File
@@ -9,6 +9,11 @@ export function findSource(url: URL, sources: readonly Source[] = SOURCES): Sour
}
/** What `/api/fetch` may fetch: every source's hosts plus Wayback (spec §4.10). */
/** Where cover images may come from: what `/api/upload` will rehost (directly or as a Wayback copy). */
export function allowedImageHosts(sources: readonly Source[] = SOURCES): Set<string> {
return new Set(sources.flatMap((s) => s.imageHosts));
}
export function allowedFetchHosts(sources: readonly Source[] = SOURCES): Set<string> {
return new Set([...sources.flatMap((s) => s.hosts), WAYBACK_HOST]);
}
+2
View File
@@ -18,6 +18,8 @@ export interface Source {
readonly name: string;
/** Hosts this source fetches from; the server proxy allowlist is built from these. */
readonly hosts: readonly string[];
/** Hosts its cover images live on, current and historical; the only sources `/api/upload` rehosts from. */
readonly imageHosts: readonly string[];
matches(url: URL): boolean;
resolve(url: URL, ctx: SourceContext): Promise<SourceSeries>;
currentCovers(series: SourceSeries, ctx: SourceContext): Promise<CoverCandidate[]>;
+2 -1
View File
@@ -2,7 +2,7 @@ import type { CoverCandidate, SourceSeries } from "../../model";
import { SourceError, type Source, type SourceContext } from "../source";
import { extractCovers, parseSeriesPage } from "./parse";
import {
IMAGE_HOST, archivePrefixes, fetchUrlFor, isSameWebtoonsSeries, isWebtoonsSeriesUrl, mangabakaLanguage,
IMAGE_HOST, IMAGE_HOSTS, archivePrefixes, fetchUrlFor, isSameWebtoonsSeries, isWebtoonsSeriesUrl, mangabakaLanguage,
normalizeImageUrl, parseWebtoonsUrl, type WebtoonsRef,
} from "./urls";
@@ -27,6 +27,7 @@ export const webtoons: Source = {
id: "webtoons",
name: "WEBTOON",
hosts: ["www.webtoons.com", "m.webtoons.com", IMAGE_HOST],
imageHosts: [...IMAGE_HOSTS],
matches: (url) => isWebtoonsSeriesUrl(url),
+1 -1
View File
@@ -3,7 +3,7 @@ import { LinkError } from "../../links";
export const WEBTOONS_HOSTS: ReadonlySet<string> = new Set(["www.webtoons.com", "m.webtoons.com", "webtoons.com"]);
export const IMAGE_HOST = "swebtoon-phinf.pstatic.net";
/** Hosts that serve the same image paths; only IMAGE_HOST works without a Webtoons Referer (spec §2.1). */
const IMAGE_HOSTS: ReadonlySet<string> = new Set([
export const IMAGE_HOSTS: ReadonlySet<string> = new Set([
IMAGE_HOST, "webtoon-phinf.pstatic.net", "swebtoon.phinf.naver.net", "webtoon.phinf.naver.net",
]);
/** Webtoons path segment → MangaBaka language code. */
+2
View File
@@ -15,6 +15,8 @@ export interface Transport {
}
export const MANGABAKA_API_HOST = "api.mangabaka.org";
/** Our page and userscript send this on every API call; a plain navigation can't (see server app.ts). */
export const API_CALL_HEADER = "x-mbseed";
export const WAYBACK_HOST = "web.archive.org";
/** Hosts a browser page may call directly because they send CORS headers (spec §2.3, §2.4). */
export const CORS_OPEN_HOSTS: ReadonlySet<string> = new Set([MANGABAKA_API_HOST, "litterbox.catbox.moe"]);