Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ REDIS_PORT=6379
# Auth (all optional; sensible defaults shown)
# SESSION_EXPIRY_MS=2592000000 # login-session lifetime (cookie Max-Age + DB expiry); default 30 days
# SESSION_GC_INTERVAL_MS=3600000 # sweep expired login sessions; default 1 h, 0 disables
# TRUST_PROXY=false # trust X-Forwarded-For for the rate-limit client IP; enable ONLY behind a proxy that sets it

# Runtime settings note: the upload caps (MAX_UPLOAD_BYTES / MAX_RESUMABLE_UPLOAD_BYTES)
# above are DEFAULTS — an admin can override them at runtime via the /admin page.
10 changes: 10 additions & 0 deletions apps/api/src/env-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,16 @@ class EnvConfig {
get COOKIE_SECURE(): boolean {
return this.NODE_ENV === "production";
}

/**
* Whether to trust the `X-Forwarded-For` header for the client IP (used to key
* rate limits). OFF by default: XFF is client-spoofable, so trusting it without
* a proxy that overwrites it would let an attacker bypass the auth throttles.
* Enable ONLY when the API sits behind a reverse proxy that sets XFF.
*/
get TRUST_PROXY(): boolean {
return Bun.env.TRUST_PROXY?.trim() === "true";
}
}

export const envConfig = EnvConfig.getInstance();
7 changes: 5 additions & 2 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ import { createApp } from "./server";
const core = createCore({
databaseUrl: envConfig.DATABASE_URL,
storageRoot: envConfig.STORAGE_ROOT,
// Env values are the DEFAULTS; an admin can override the caps at runtime.
maxUploadBytes: envConfig.MAX_UPLOAD_BYTES,
maxResumableUploadBytes: envConfig.MAX_RESUMABLE_UPLOAD_BYTES,
requestBodyCeilingBytes: MAX_REQUEST_BODY_BYTES,
sessionExpiryMs: envConfig.SESSION_EXPIRY_MS,
});
const app = createApp({ core, maxUploadBytes: envConfig.MAX_UPLOAD_BYTES });
const app = createApp({ core });

app.listen(
{ port: envConfig.SERVER_PORT, maxRequestBodySize: MAX_REQUEST_BODY_BYTES },
Expand Down Expand Up @@ -100,4 +103,4 @@ const shutdown = async (): Promise<void> => {
process.once("SIGTERM", () => void shutdown());
process.once("SIGINT", () => void shutdown());

export type { App, AssetDto, TagDto, UserDto } from "./server";
export type { ApiKeyDto, App, AssetDto, TagDto, UploadLimitsDto, UserDto } from "./server";
2 changes: 2 additions & 0 deletions apps/api/src/lib/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
UnsupportedMediaError,
UploadConflictError,
UploadRangeError,
ValidationError,
} from "@bunbooru/core";

import { HttpError } from "./errors";
Expand All @@ -18,6 +19,7 @@ export function statusFor(code: string | number, error: unknown): number {
if (error instanceof HttpError) return error.status;
if (error instanceof AuthenticationError) return 401;
if (error instanceof AuthorizationError) return 403;
if (error instanceof ValidationError) return 400;
if (error instanceof UnsupportedMediaError) return 415;
if (error instanceof RegistrationConflictError) return 409;
if (error instanceof UploadConflictError) return 409;
Expand Down
74 changes: 74 additions & 0 deletions apps/api/src/lib/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* A tiny in-memory fixed-window rate limiter. Deployment is single-instance, so a
* process-local map is sufficient (a multi-instance deployment would need a
* shared store like Redis — a follow-up). Buckets expire lazily and the map is
* pruned when it grows, so spoofed keys can't leak memory unbounded.
*/
export interface RateLimiterOptions {
/** Window length in milliseconds. */
windowMs: number;
/** Max hits allowed per key within a window. */
max: number;
}

export interface RateLimiter {
/** Record a hit for `key`; returns false when the key is over its limit. */
hit(key: string): boolean;
}

/** Cap on tracked keys — prune expired buckets before exceeding it. */
const MAX_TRACKED_KEYS = 10_000;

/** Build a fixed-window {@link RateLimiter}. */
export function createRateLimiter({ windowMs, max }: RateLimiterOptions): RateLimiter {
const buckets = new Map<string, { count: number; resetAt: number }>();

function prune(now: number): void {
for (const [key, bucket] of buckets) {
if (bucket.resetAt <= now) buckets.delete(key);
}
}

return {
hit(key) {
const now = Date.now();
let bucket = buckets.get(key);
if (!bucket || bucket.resetAt <= now) {
if (buckets.size >= MAX_TRACKED_KEYS) {
prune(now);
// If pruning freed nothing (all buckets live), refuse rather than grow
// the map without bound — fail closed under a many-distinct-IP flood.
if (buckets.size >= MAX_TRACKED_KEYS) return false;
}
bucket = { count: 0, resetAt: now + windowMs };
buckets.set(key, bucket);
}
bucket.count += 1;
return bucket.count <= max;
},
};
}

/**
* The slice of Bun's `Server` we need — structural so we don't depend on the
* generic `Server<WebSocketData>` shape Elysia hands us.
*/
interface IpResolver {
requestIP(request: Request): { address: string } | null;
}

/**
* Client IP for rate-limit keying. The socket address is authoritative and
* un-spoofable; `X-Forwarded-For` is only consulted when `trustProxy` is set,
* because XFF is client-supplied and would otherwise let an attacker rotate it to
* bypass the auth throttles. Enable `trustProxy` only behind a reverse proxy that
* overwrites the header (see `TRUST_PROXY`).
*/
export function clientIp(request: Request, server: IpResolver | null, trustProxy: boolean): string {
if (trustProxy) {
const forwarded = request.headers.get("x-forwarded-for");
const first = forwarded?.split(",")[0]?.trim();
if (first) return first;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return server?.requestIP(request)?.address ?? "unknown";
}
Loading
Loading