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:
1 parent
5084f8c557
commit
baa3476b73
16 files changed
+183
-26
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 �: leave them as written rather than throw.
|
||||
return Number.isInteger(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
||||
}
|
||||
return NAMED[e] ?? match;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,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),
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
+51
-10
@@ -1,8 +1,8 @@
|
||||
// The portable request handler (spec §4.10): web-standard APIs only, so a Workers entry can wrap it later.
|
||||
import { uploadToCatbox } from "../core/hosting/catbox";
|
||||
import { ImageError, MAX_IMAGE_BYTES, probeImage, readCapped, sha256Hex } from "../core/images";
|
||||
import { allowedFetchHosts } from "../core/sources/registry";
|
||||
import { TOOL_USER_AGENT, userAgentFor, type Transport } from "../core/transport";
|
||||
import { allowedFetchHosts, allowedImageHosts } from "../core/sources/registry";
|
||||
import { API_CALL_HEADER, TOOL_USER_AGENT, WAYBACK_HOST, userAgentFor, type Transport } from "../core/transport";
|
||||
import { resolveAssetPath } from "./assets-path";
|
||||
import { RateLimiter } from "./rate-limit";
|
||||
import { CONTENT_TYPES, COVER_NAME, StorageFullError, type Storage } from "./storage";
|
||||
@@ -24,6 +24,16 @@ export const MAX_REDIRECTS = 5;
|
||||
export const UPSTREAM_TIMEOUT_MS = 30_000;
|
||||
/** Pages whose scripts may call our API besides our own (the userscript runs on mangabaka.org). */
|
||||
const EXTRA_ORIGINS = new Set(["https://mangabaka.org"]);
|
||||
/** Covers are a few hundred pixels to ~2000 wide; anything outside this is not one (or not an image at all). */
|
||||
const MAX_IMAGE_SIDE = 10_000;
|
||||
/** Proxied bodies are data for our scripts, never a document: they must not render or run on our origin. */
|
||||
const INERT_HEADERS = {
|
||||
"content-type": "application/octet-stream",
|
||||
"x-content-type-options": "nosniff",
|
||||
"content-security-policy": "sandbox; default-src 'none'",
|
||||
"content-disposition": "attachment",
|
||||
"cache-control": "no-store",
|
||||
} as const;
|
||||
|
||||
const error = (status: number, message: string) =>
|
||||
Response.json({ error: message }, { status, headers: { "x-mbseed-error": "1", "cache-control": "no-store" } });
|
||||
@@ -33,7 +43,30 @@ export function createApp(env: ServerEnv): (req: Request) => Promise<Response> {
|
||||
// A run is ~60–100 proxied requests over several minutes (spec §5).
|
||||
const limits = env.limits ?? { fetch: new RateLimiter(240, 60_000), upload: new RateLimiter(60, 60_000) };
|
||||
const upstream: Upstream = env.upstream ?? ((url, init) => fetch(url, init));
|
||||
const ownOrigin = (req: Request) => env.publicUrl ?? new URL(req.url).origin;
|
||||
const imageHosts = allowedImageHosts();
|
||||
/** Behind a TLS proxy Bun sees http://host while the browser says https://host, so compare hosts. */
|
||||
const isOwnOrigin = (req: Request, origin: string) => {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(origin).host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const own = [new URL(req.url).host, req.headers.get("host") ?? ""];
|
||||
if (env.publicUrl) own.push(new URL(env.publicUrl).host);
|
||||
return own.includes(host);
|
||||
};
|
||||
/** `/api/upload` rehosts cover images only: a source image host, or Wayback's copy of one. */
|
||||
const isImageSource = (u: URL) => {
|
||||
if (imageHosts.has(u.hostname)) return true;
|
||||
if (u.hostname !== WAYBACK_HOST) return false;
|
||||
const inner = /^\/web\/\d{1,14}(?:id_|im_)?\/(.+)$/.exec(u.pathname + u.search)?.[1];
|
||||
try {
|
||||
return inner !== undefined && imageHosts.has(new URL(inner).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const allowed = (u: URL) =>
|
||||
(u.protocol === "https:" || u.protocol === "http:") && hosts.has(u.hostname) && ["", "80", "443"].includes(u.port);
|
||||
@@ -74,11 +107,7 @@ export function createApp(env: ServerEnv): (req: Request) => Promise<Response> {
|
||||
}
|
||||
return new Response(body as Uint8Array<ArrayBuffer>, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"content-type": res.headers.get("content-type") ?? "application/octet-stream",
|
||||
"cache-control": "no-store",
|
||||
"x-mbseed-final-url": url.toString(),
|
||||
},
|
||||
headers: { ...INERT_HEADERS, "x-mbseed-final-url": url.toString() },
|
||||
});
|
||||
}
|
||||
return error(502, "Too many redirects.");
|
||||
@@ -107,6 +136,13 @@ export function createApp(env: ServerEnv): (req: Request) => Promise<Response> {
|
||||
return error(400, "Bad userhash.");
|
||||
}
|
||||
if (body.host === "store" && !env.storage) return error(404, "This server has no storage.");
|
||||
let source: URL;
|
||||
try {
|
||||
source = new URL(body.url);
|
||||
} catch {
|
||||
return error(400, "Not a URL.");
|
||||
}
|
||||
if (!isImageSource(source)) return error(403, "Only cover images from a supported source (or their Wayback copies) can be rehosted.");
|
||||
|
||||
// The client sends the source URL, never bytes, so this can't host arbitrary files (spec §4.6).
|
||||
const fetched = await proxyGet(body.url);
|
||||
@@ -114,7 +150,9 @@ export function createApp(env: ServerEnv): (req: Request) => Promise<Response> {
|
||||
if (!fetched.ok) return error(502, `Could not fetch the image (HTTP ${fetched.status}).`);
|
||||
const bytes = new Uint8Array(await fetched.arrayBuffer());
|
||||
const info = probeImage(bytes);
|
||||
if (!info) return error(415, "That URL is not an image.");
|
||||
if (!info || info.width < 1 || info.height < 1 || info.width > MAX_IMAGE_SIDE || info.height > MAX_IMAGE_SIDE) {
|
||||
return error(415, "That URL is not a cover image.");
|
||||
}
|
||||
const sha256 = await sha256Hex(bytes);
|
||||
|
||||
if (body.host === "store") {
|
||||
@@ -156,7 +194,10 @@ export function createApp(env: ServerEnv): (req: Request) => Promise<Response> {
|
||||
|
||||
if (path.startsWith("/api/")) {
|
||||
const origin = req.headers.get("origin");
|
||||
if (origin && origin !== ownOrigin(req) && !EXTRA_ORIGINS.has(origin)) return error(403, "Cross-origin requests are not allowed.");
|
||||
if (origin && !isOwnOrigin(req, origin) && !EXTRA_ORIGINS.has(origin)) return error(403, "Cross-origin requests are not allowed.");
|
||||
// Only our scripts send this header; a link or a form can't, so a navigation can't make us fetch.
|
||||
const fromOurScript = req.headers.get(API_CALL_HEADER) === "1";
|
||||
if ((path === "/api/fetch" || path === "/api/upload") && !fromOurScript) return error(403, "API calls only.");
|
||||
if (path === "/api/config" && isRead) {
|
||||
return Response.json({ storage: env.storage !== null, storageFull: env.storage?.isFull() ?? false }, { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CORS_OPEN_HOSTS, TransportError, type Transport } from "../core/transport";
|
||||
import { API_CALL_HEADER, CORS_OPEN_HOSTS, TransportError, type Transport } from "../core/transport";
|
||||
|
||||
type RawFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
@@ -15,7 +15,10 @@ export function pageTransport(origin: string, rawFetch: RawFetch = (url, init) =
|
||||
if ((init.method ?? "GET").toUpperCase() !== "GET") {
|
||||
throw new TransportError(`The page can't ${init.method} to ${target.hostname} (no CORS).`, url);
|
||||
}
|
||||
const res = await rawFetch(`${origin}/api/fetch?url=${encodeURIComponent(target.toString())}`, { signal: init.signal });
|
||||
const res = await rawFetch(`${origin}/api/fetch?url=${encodeURIComponent(target.toString())}`, {
|
||||
signal: init.signal,
|
||||
headers: { [API_CALL_HEADER]: "1" },
|
||||
});
|
||||
if (res.headers.get("x-mbseed-error")) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new TransportError(`Server proxy: ${body.error ?? `HTTP ${res.status}`}`, url, res.status);
|
||||
|
||||
Reference in new issue
Block a user