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:
1 parent
9019d99c59
commit
4197b174b5
4 files changed
+177
No files matched your search
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { resolveAssetPath } from "../src/server/assets-path";
|
||||
import { ConfigError, clientIp, readConfig } from "../src/server/config";
|
||||
import { RateLimiter } from "../src/server/rate-limit";
|
||||
|
||||
test("RateLimiter: N per window per key", () => {
|
||||
let now = 0;
|
||||
const limiter = new RateLimiter(2, 60_000, () => now);
|
||||
expect([limiter.take("a"), limiter.take("a"), limiter.take("a"), limiter.take("b")]).toEqual([true, true, false, true]);
|
||||
now = 60_000;
|
||||
expect(limiter.take("a")).toBe(true);
|
||||
});
|
||||
|
||||
describe("readConfig", () => {
|
||||
test("defaults", () => {
|
||||
expect(readConfig({})).toEqual({
|
||||
port: 8787, hostname: "127.0.0.1", publicUrl: null, storageDir: null,
|
||||
storageMaxBytes: 1073741824, trustProxy: false, assetsDir: "dist/web",
|
||||
});
|
||||
});
|
||||
test("everything set", () => {
|
||||
expect(readConfig({
|
||||
MBSEED_PORT: "9000", MBSEED_HOST: "0.0.0.0", MBSEED_PUBLIC_URL: "https://mbseed.example.org/",
|
||||
MBSEED_STORAGE_DIR: "/srv/mbseed/covers", MBSEED_STORAGE_MAX_BYTES: "5000000", MBSEED_TRUST_PROXY: "1", MBSEED_ASSETS_DIR: "/srv/mbseed/web",
|
||||
})).toEqual({
|
||||
port: 9000, hostname: "0.0.0.0", publicUrl: "https://mbseed.example.org", storageDir: "/srv/mbseed/covers",
|
||||
storageMaxBytes: 5000000, trustProxy: true, assetsDir: "/srv/mbseed/web",
|
||||
});
|
||||
});
|
||||
test.each([
|
||||
[{ MBSEED_STORAGE_DIR: "/x" }, /MBSEED_PUBLIC_URL/],
|
||||
[{ MBSEED_PUBLIC_URL: "mbseed.example.org" }, /not a URL/],
|
||||
[{ MBSEED_PUBLIC_URL: "https://example.org/sub/path" }, /origin/],
|
||||
[{ MBSEED_PORT: "eighty" }, /whole number/],
|
||||
[{ MBSEED_STORAGE_MAX_BYTES: "1GB" }, /whole number/],
|
||||
])("%o is refused", (env, message) => {
|
||||
expect(() => readConfig(env)).toThrow(ConfigError);
|
||||
expect(() => readConfig(env)).toThrow(message);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientIp", () => {
|
||||
const req = (xff?: string) => new Request("http://x/", { headers: xff ? { "x-forwarded-for": xff } : {} });
|
||||
test("ignores X-Forwarded-For unless the proxy is trusted", () => {
|
||||
expect(clientIp(req("1.2.3.4"), false, "10.0.0.1")).toBe("10.0.0.1");
|
||||
});
|
||||
test("trusts only the last entry, the one our proxy appended", () => {
|
||||
// Review Focus 4: the client sent "1.2.3.4" itself; the proxy appended the real address.
|
||||
expect(clientIp(req("1.2.3.4, 203.0.113.9"), true, "127.0.0.1")).toBe("203.0.113.9");
|
||||
expect(clientIp(req(), true, "127.0.0.1")).toBe("127.0.0.1");
|
||||
expect(clientIp(req(), false)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAssetPath", () => {
|
||||
test.each([
|
||||
["/", "index.html"],
|
||||
["/index.html", "index.html"],
|
||||
["/main-ab12cd.js", "main-ab12cd.js"],
|
||||
["/mbseed.user.js", "mbseed.user.js"],
|
||||
["/fonts/x.woff2", "fonts/x.woff2"],
|
||||
])("%p → %p", (path, file) => {
|
||||
expect(resolveAssetPath(path)).toBe(file);
|
||||
});
|
||||
// Review Focus 5
|
||||
test.each(["/..%2f.env", "/%2e%2e/package.json", "/a/../../b", "/.env", "/%00x", "/a\\..\\b", "/%E0%A4%A"])("%p is refused", (path) => {
|
||||
expect(resolveAssetPath(path)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user