import type { IncomingMessage } from "node:http"; import { isIP } from "node:net"; import path from "node:path"; import debug from "debug"; import { argon2Verify } from "hash-wasm"; import { RateLimiterMemory } from "rate-limiter-flexible"; import { ARGON2_HASH_PREFIX } from "../shared/argon2Parameters.ts"; import { addRejectedToken, isRejectedToken, recordRejectedTokenCacheHit, } from "./rejectedTokens.ts"; import { getSearchToken, hasSearchTokenFileChanged } from "./searchToken.ts"; import { addVerifiedToken, isVerifiedToken } from "./verifiedTokens.ts"; const fileName = path.basename(import.meta.url); const printMessage = debug(fileName); printMessage.enabled = true; export const RATE_LIMIT_POINTS = 10; export const RATE_LIMIT_DURATION_SECONDS = 10; const rateLimiter = new RateLimiterMemory({ points: RATE_LIMIT_POINTS, duration: RATE_LIMIT_DURATION_SECONDS, }); /** * A single image search fans out into up to 30 tile loads, so `/thumbnail` * draws from its own budget: sharing the search bucket would let one grid * exhaust the user's text-search and page-read budget, and the 429s would * land on tiles the browser's `` cannot retry. The token gate stays * shared, so a hostile caller still pays one verification per tile. */ export const THUMBNAIL_RATE_LIMIT_POINTS = 60; export const THUMBNAIL_RATE_LIMIT_DURATION_SECONDS = 10; export const thumbnailRateLimiter = new RateLimiterMemory({ points: THUMBNAIL_RATE_LIMIT_POINTS, duration: THUMBNAIL_RATE_LIMIT_DURATION_SECONDS, }); /** Why a request was turned away, named by the check that turned it away. */ export type RejectionReason = "rateLimited" | "missingToken" | "invalidToken"; /** * A rejection always carries its status, its message and its reason together, * so a caller cannot answer 401 without also being able to say what happened. */ export type TokenVerificationResult = | { isAuthorized: true } | { isAuthorized: false; statusCode: number; error: string; reason: RejectionReason; }; /** Whether to trust proxy-set forwarding headers. Off unless `TRUST_PROXY` is `true`/`1`. */ function isProxyTrusted(): boolean { const value = process.env.TRUST_PROXY?.trim().toLowerCase(); return value === "true" || value === "1"; } /** * Resolves the client IP used as the rate-limit key. * * `X-Forwarded-For` / `X-Real-IP` are only honored when `TRUST_PROXY` is * enabled. On a directly-exposed instance those headers are fully * client-controlled, so trusting them would let a caller forge a fresh IP per * request and evade rate limiting entirely. When `TRUST_PROXY` is off (the * default) we use the real TCP peer address, which cannot be spoofed. * * Enable `TRUST_PROXY` only when MiniSearch runs behind a reverse proxy that * sets the rightmost `X-Forwarded-For` entry (e.g. nginx's * `$proxy_add_x_forwarded_for`). */ export function getClientIp(request: IncomingMessage): string { if (isProxyTrusted()) { const forwarded = request.headers["x-forwarded-for"]; const xff = Array.isArray(forwarded) ? forwarded.join(",") : forwarded; if (typeof xff === "string" && xff.length > 0) { const parts = xff .split(",") .map((p) => p.trim()) .filter(Boolean); const ip = parts[parts.length - 1]; if (ip && isIP(ip)) { return ip; } } const realIp = request.headers["x-real-ip"]; if (typeof realIp === "string" && realIp.length > 0 && isIP(realIp)) { return realIp; } } return request.socket.remoteAddress || "unknown"; } /** * Consume one point from the shared rate limiter for a request, keyed by the * client IP. Returns `true` when the request is within budget, `false` when the * limiter refuses it. * * It reuses the same limiter instance the token-verified endpoints consume * from, so a tokenless caller cannot get a second, independent budget by * hitting a different endpoint. (The one deliberate exception is /thumbnail, * which verifies a token like the rest and carries its own budget because a * grid fans out into up to 30 tile loads.) Endpoints that pay for expensive * work without a token (access-key validation) must consume here before doing * that work. */ export async function consumeRateLimitPoint( request: IncomingMessage, ): Promise { try { await rateLimiter.consume(getClientIp(request)); return true; } catch { return false; } } let hasReportedTokenFileChange = false; /** * Says once, on the way out, why a client holding a real token is being turned * away. Without it the only trace is a 401 per request, which reads as a broken * search rather than as two processes holding different tokens. The file is * only read on a request that is already being rejected, which has just paid * for an argon2 verification, so the read costs nothing next to it. */ function reportTokenFileChangeOnce() { if (hasReportedTokenFileChange || !hasSearchTokenFileChanged()) return; hasReportedTokenFileChange = true; printMessage( "Rejected a token that does not match this server's. The token file was rewritten after startup, so a client that took its token from another process keeps being rejected here until this server restarts.", ); } export async function verifyTokenAndRateLimit( token: string | null, request?: IncomingMessage, limiter: RateLimiterMemory = rateLimiter, ): Promise { // Rate-limit before anything else. An invalid or missing token used to skip // the limiter entirely, and every rejected request still pays for a full // argon2 verification, which is expensive enough to be a DoS lever on a // publicly reachable instance: a caller could send an endless stream of // bogus tokens and pin the server's CPU without ever hitting the limiter. const rateLimitKey = request ? getClientIp(request) : (token ?? "anonymous"); try { await limiter.consume(rateLimitKey); } catch { return { isAuthorized: false, statusCode: 429, error: "Too many requests.", reason: "rateLimited", }; } if (!token) { return { isAuthorized: false, statusCode: 400, error: "Missing token.", reason: "missingToken", }; } if (!isVerifiedToken(token)) { if (isRejectedToken(token)) { // This process will never accept this token, so the refusal is final // without paying for the verification again. The file-divergence check // is skipped on purpose: this path has not paid for a verification, so // its file read would dominate, and first rejections always run it, // which is the case it was written for. recordRejectedTokenCacheHit(); return { isAuthorized: false, statusCode: 401, error: "Invalid token.", reason: "invalidToken", }; } // The client hashes with the shared parameters, so a hash carrying any // other block cannot be valid against this server. Checking before // argon2Verify refuses a mismatch for free, before any allocation or work // starts. Without it a caller could embed m=4194304,t=1000,p=1 and force a // multi-gigabyte allocation and 1000 rounds per request. if (!token.startsWith(ARGON2_HASH_PREFIX)) { return { isAuthorized: false, statusCode: 401, error: "Invalid token.", reason: "invalidToken", }; } let isValidToken = false; let didComparisonRun = false; try { isValidToken = await argon2Verify({ password: getSearchToken(), hash: token, }); didComparisonRun = true; } catch (error) { void error; } if (!isValidToken) { // Only a comparison that ran to a result proves the token is dead. A // throw means either the token file could not be read, and caching that // would refuse a valid token for the rest of the process, or the hash // is malformed, and caching junk would only spend a slot on a refusal // that costs a prefix check plus a decode-time throw, not a full // verification. if (didComparisonRun) addRejectedToken(token); reportTokenFileChangeOnce(); return { isAuthorized: false, statusCode: 401, error: "Invalid token.", reason: "invalidToken", }; } } // Records a new session or refreshes an active one's last-seen time. addVerifiedToken(token); return { isAuthorized: true }; }