feat(server): config from env, trusted client IP, per-IP rate limiter, safe asset paths

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-14 23:39:13 +02:00
1 parent 9019d99c59
commit 4197b174b5
4 files changed
+177

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
/** Maps a URL path to a file path relative to the assets root, or null. No dot-segments, no dotfiles. */
export function resolveAssetPath(pathname: string): string | null {
let decoded: string;
try {
decoded = decodeURIComponent(pathname);
} catch {
return null;
}
if (decoded.includes("\0") || decoded.includes("\\")) return null;
const segments: string[] = [];
for (const segment of decoded.split("/")) {
if (segment === "") continue;
if (segment.startsWith(".")) return null;
segments.push(segment);
}
return segments.length ? segments.join("/") : "index.html";
}
+68
View File
@@ -0,0 +1,68 @@
// Web-standard: parses the environment; no Bun or node APIs.
export interface ServerConfig {
port: number;
hostname: string;
publicUrl: string | null;
storageDir: string | null;
storageMaxBytes: number;
trustProxy: boolean;
assetsDir: string;
}
export class ConfigError extends Error {
override name = "ConfigError";
}
export const DEFAULT_STORAGE_MAX_BYTES = 1024 ** 3;
export function readConfig(env: Record<string, string | undefined>): ServerConfig {
const publicUrl = parsePublicUrl(env.MBSEED_PUBLIC_URL);
const storageDir = env.MBSEED_STORAGE_DIR?.trim() || null;
if (storageDir && !publicUrl) {
throw new ConfigError("MBSEED_STORAGE_DIR needs MBSEED_PUBLIC_URL: MangaBaka must be able to download stored covers.");
}
return {
port: wholeNumber(env, "MBSEED_PORT", 8787),
hostname: env.MBSEED_HOST?.trim() || "127.0.0.1",
publicUrl,
storageDir,
storageMaxBytes: wholeNumber(env, "MBSEED_STORAGE_MAX_BYTES", DEFAULT_STORAGE_MAX_BYTES),
trustProxy: env.MBSEED_TRUST_PROXY === "1",
assetsDir: env.MBSEED_ASSETS_DIR?.trim() || "dist/web",
};
}
function parsePublicUrl(raw: string | undefined): string | null {
const value = raw?.trim();
if (!value) return null;
let url: URL;
try {
url = new URL(value);
} catch {
throw new ConfigError(`MBSEED_PUBLIC_URL is not a URL: ${value}`);
}
if (!["http:", "https:"].includes(url.protocol) || url.pathname !== "/" || url.search || url.hash) {
throw new ConfigError(`MBSEED_PUBLIC_URL must be an origin like https://mbseed.example.org, got ${value}`);
}
return url.origin;
}
function wholeNumber(env: Record<string, string | undefined>, name: string, fallback: number): number {
const raw = env[name]?.trim();
if (!raw) return fallback;
if (!/^\d+$/.test(raw)) throw new ConfigError(`${name} must be a whole number, got "${raw}".`);
return Number(raw);
}
/**
* The address rate limits key on. Behind our reverse proxy (MBSEED_TRUST_PROXY=1) that is the
* last X-Forwarded-For entry — the one the proxy appended; earlier entries come from the client.
*/
export function clientIp(req: Request, trustProxy: boolean, peer?: string): string {
if (trustProxy) {
const last = (req.headers.get("x-forwarded-for") ?? "").split(",").map((s) => s.trim()).filter(Boolean).at(-1);
if (last) return last;
}
return peer ?? "unknown";
}
+23
View File
@@ -0,0 +1,23 @@
/** Fixed-window counter per key (client IP). In memory: one server process. */
export class RateLimiter {
private readonly hits = new Map<string, { start: number; count: number }>();
constructor(readonly limit: number, readonly windowMs: number, private readonly now: () => number = Date.now) {}
take(key: string): boolean {
const t = this.now();
const hit = this.hits.get(key);
if (!hit || t - hit.start >= this.windowMs) {
if (this.hits.size > 10_000) this.prune(t);
this.hits.set(key, { start: t, count: 1 });
return true;
}
if (hit.count >= this.limit) return false;
hit.count++;
return true;
}
private prune(t: number): void {
for (const [key, hit] of this.hits) if (t - hit.start >= this.windowMs) this.hits.delete(key);
}
}