From 78781aa3af81d3d1d40a5c54ccc455dd98d9d186 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 12 Oct 2026 14:31:39 +0800 Subject: [PATCH 1/7] refactor(evault): share the Registry JWKS verifier --- .../src/core/auth/registry-token.spec.ts | 136 ++++++++++++ .../src/core/auth/registry-token.ts | 198 ++++++++++++++++++ .../src/core/protocol/vault-access-guard.ts | 41 +--- 3 files changed, 336 insertions(+), 39 deletions(-) create mode 100644 infrastructure/evault-core/src/core/auth/registry-token.spec.ts create mode 100644 infrastructure/evault-core/src/core/auth/registry-token.ts diff --git a/infrastructure/evault-core/src/core/auth/registry-token.spec.ts b/infrastructure/evault-core/src/core/auth/registry-token.spec.ts new file mode 100644 index 000000000..1d3c5d68a --- /dev/null +++ b/infrastructure/evault-core/src/core/auth/registry-token.spec.ts @@ -0,0 +1,136 @@ +import axios from "axios"; +import { SignJWT, generateKeyPair } from "jose"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { + getSharedTestKeyPair, + getSharedTestPublicJWK, +} from "../../test-utils/shared-test-keys"; +import { + cachedPlatformVerdicts, + resetRegistryTokenCaches, + verifyPlatform, + verifyRegistryToken, +} from "./registry-token"; + +vi.mock("axios"); +const mockedAxios = axios as any; + +describe("registry-token", () => { + const previousRegistryUrl = process.env.PUBLIC_REGISTRY_URL; + let privateKey: any; + + async function sign( + payload: Record, + options: { key?: any; expiresIn?: string | number } = {}, + ) { + return new SignJWT(payload) + .setProtectedHeader({ alg: "ES256", kid: "entropy-key-1" }) + .setIssuedAt() + .setExpirationTime(options.expiresIn ?? "1h") + .sign(options.key ?? privateKey); + } + + beforeAll(async () => { + privateKey = (await getSharedTestKeyPair()).privateKey; + process.env.PUBLIC_REGISTRY_URL = "http://registry.test"; + }); + + afterAll(() => { + if (previousRegistryUrl === undefined) { + delete process.env.PUBLIC_REGISTRY_URL; + } else { + process.env.PUBLIC_REGISTRY_URL = previousRegistryUrl; + } + }); + + beforeEach(async () => { + vi.clearAllMocks(); + resetRegistryTokenCaches(); + mockedAxios.get.mockResolvedValue({ + data: { keys: [await getSharedTestPublicJWK()] }, + }); + }); + + it("returns the payload of a Registry-signed token", async () => { + const token = await sign({ platform: "http://pictique.test" }); + const payload = await verifyRegistryToken(token); + expect(payload.platform).toBe("http://pictique.test"); + expect(mockedAxios.get).toHaveBeenCalledWith( + "http://registry.test/.well-known/jwks.json", + expect.anything(), + ); + }); + + it("fetches the key set once for many tokens", async () => { + await verifyRegistryToken(await sign({ platform: "a" })); + await verifyRegistryToken(await sign({ platform: "b" })); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + }); + + it("proves the platform of a valid token", async () => { + const token = await sign({ platform: " http://pictique.test " }); + expect(await verifyPlatform(token)).toBe("http://pictique.test"); + }); + + it("gives no platform for a token signed by another key", async () => { + const other = await generateKeyPair("ES256"); + const token = await sign( + { platform: "http://pictique.test" }, + { key: other.privateKey }, + ); + expect(await verifyPlatform(token)).toBeNull(); + }); + + it("gives no platform for an expired token", async () => { + const token = await sign( + { platform: "http://pictique.test" }, + { expiresIn: Math.floor(Date.now() / 1000) - 60 }, + ); + expect(await verifyPlatform(token)).toBeNull(); + }); + + it("gives no platform when the claim is absent or unusable", async () => { + expect(await verifyPlatform(await sign({}))).toBeNull(); + expect(await verifyPlatform(await sign({ platform: 7 }))).toBeNull(); + expect( + await verifyPlatform(await sign({ platform: "x".repeat(257) })), + ).toBeNull(); + expect(await verifyPlatform("garbage")).toBeNull(); + expect(await verifyPlatform(null)).toBeNull(); + }); + + it("asks an unreachable Registry once, then backs off", async () => { + mockedAxios.get.mockRejectedValue(new Error("registry down")); + const first = await sign({ platform: "a" }); + const second = await sign({ platform: "b" }); + expect(await verifyPlatform(first)).toBeNull(); + expect(await verifyPlatform(second)).toBeNull(); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + }); + + it("does not pin a good token to no platform during an outage", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const token = await sign({ platform: "http://pictique.test" }); + mockedAxios.get.mockRejectedValueOnce(new Error("registry down")); + expect(await verifyPlatform(token)).toBeNull(); + vi.advanceTimersByTime(31_000); + expect(await verifyPlatform(token)).toBe("http://pictique.test"); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the verdict cache bounded", async () => { + for (let i = 0; i < 5010; i++) await verifyPlatform(`junk-${i}`); + expect(cachedPlatformVerdicts()).toBe(5000); + }); +}); diff --git a/infrastructure/evault-core/src/core/auth/registry-token.ts b/infrastructure/evault-core/src/core/auth/registry-token.ts new file mode 100644 index 000000000..7b89e2068 --- /dev/null +++ b/infrastructure/evault-core/src/core/auth/registry-token.ts @@ -0,0 +1,198 @@ +import { createHash } from "node:crypto"; +import axios from "axios"; +import * as jose from "jose"; +import { timed } from "../utils/timing"; + +type JwksResolver = ReturnType; + +type CachedJwks = { + jwks: JwksResolver; + fetchedAt: number; + expiresAt: number; +}; + +const JWKS_TTL_MS = 24 * 60 * 60 * 1000; +const JWKS_FETCH_TIMEOUT_MS = 5000; +// After a failed fetch, wait this long before asking the Registry again so an +// outage costs one timeout rather than one per request. +const JWKS_FAILURE_BACKOFF_MS = 30_000; +// An unknown `kid` may mean the Registry rotated its key; refetch at most +// this often. +const JWKS_ROTATION_REFETCH_MS = 60_000; + +const PLATFORM_CACHE_MAX = 5000; +const PLATFORM_CACHE_TTL_MS = 10 * 60 * 1000; +const PLATFORM_NEGATIVE_TTL_MS = 60_000; +const PLATFORM_MAX_LENGTH = 256; + +const jwksCache = new Map(); +const jwksInFlight = new Map>(); +const jwksRetryAt = new Map(); +const platformCache = new Map< + string, + { platform: string | null; expiresAt: number } +>(); + +function registryJwksUrl(): string { + const registryUrl = + process.env.PUBLIC_REGISTRY_URL || process.env.REGISTRY_URL; + if (!registryUrl) { + throw new Error("PUBLIC_REGISTRY_URL or REGISTRY_URL is not set"); + } + return new URL("/.well-known/jwks.json", registryUrl).toString(); +} + +function fetchJwks(jwksUrl: string): Promise { + let pending = jwksInFlight.get(jwksUrl); + if (!pending) { + pending = timed("registryToken.fetchJWKS", () => + axios.get(jwksUrl, { timeout: JWKS_FETCH_TIMEOUT_MS }), + ) + .then((response) => { + const now = Date.now(); + const cached = { + jwks: jose.createLocalJWKSet(response.data), + fetchedAt: now, + expiresAt: now + JWKS_TTL_MS, + }; + jwksCache.set(jwksUrl, cached); + jwksRetryAt.delete(jwksUrl); + return cached; + }) + .catch((error) => { + jwksRetryAt.set(jwksUrl, Date.now() + JWKS_FAILURE_BACKOFF_MS); + throw error; + }) + .finally(() => { + jwksInFlight.delete(jwksUrl); + }); + jwksInFlight.set(jwksUrl, pending); + } + return pending; +} + +/** + * The Registry's signing keys, cached for a day. A stale set keeps being + * served while the Registry is unreachable. + */ +async function getRegistryJwks(jwksUrl: string): Promise { + const now = Date.now(); + const cached = jwksCache.get(jwksUrl); + if (cached && cached.expiresAt > now) return cached; + + if ((jwksRetryAt.get(jwksUrl) ?? 0) > now) { + if (cached) return cached; + throw new Error("Registry JWKS is unavailable"); + } + + try { + return await fetchJwks(jwksUrl); + } catch (error) { + if (cached) return cached; + throw error; + } +} + +/** + * Verifies a Registry-issued token and returns its payload. Throws when the + * token is invalid or the Registry's keys cannot be obtained. + */ +export async function verifyRegistryToken( + token: string, + options?: jose.JWTVerifyOptions, +): Promise { + const jwksUrl = registryJwksUrl(); + const cached = await getRegistryJwks(jwksUrl); + try { + const { payload } = await jose.jwtVerify(token, cached.jwks, options); + return payload; + } catch (error) { + const rotated = + error instanceof jose.errors.JWKSNoMatchingKey && + Date.now() - cached.fetchedAt > JWKS_ROTATION_REFETCH_MS && + (jwksRetryAt.get(jwksUrl) ?? 0) <= Date.now(); + if (!rotated) throw error; + const refreshed = await fetchJwks(jwksUrl); + const { payload } = await jose.jwtVerify( + token, + refreshed.jwks, + options, + ); + return payload; + } +} + +function rememberPlatform( + key: string, + platform: string | null, + expiresAt: number, +): void { + if (platformCache.size >= PLATFORM_CACHE_MAX) { + const oldest = platformCache.keys().next().value; + if (oldest !== undefined) platformCache.delete(oldest); + } + platformCache.set(key, { platform, expiresAt }); +} + +/** + * The calling platform a token proves, or null when the token is missing, + * invalid or carries no platform. Results are cached by token hash so the + * signature is checked once per token rather than once per request. + * + * The Registry signs any platform name it is asked for, so this proves the + * token is genuine, not who the caller is. + */ +export async function verifyPlatform( + token: string | null | undefined, +): Promise { + if (!token) return null; + + const key = createHash("sha256").update(token).digest("base64"); + const now = Date.now(); + const hit = platformCache.get(key); + if (hit && hit.expiresAt > now) return hit.platform; + platformCache.delete(key); + + try { + const payload = await verifyRegistryToken(token, { + algorithms: ["ES256"], + }); + const claim = payload.platform; + const platform = + typeof claim === "string" && + claim.trim().length > 0 && + claim.trim().length <= PLATFORM_MAX_LENGTH + ? claim.trim() + : null; + const tokenExpiry = + typeof payload.exp === "number" + ? payload.exp * 1000 + : Number.POSITIVE_INFINITY; + rememberPlatform( + key, + platform, + Math.min(tokenExpiry, now + PLATFORM_CACHE_TTL_MS), + ); + return platform; + } catch (error) { + // Only a verdict on the token itself is remembered; a Registry + // outage must not pin a good token to "no platform". + if (error instanceof jose.errors.JOSEError) { + rememberPlatform(key, null, now + PLATFORM_NEGATIVE_TTL_MS); + } + return null; + } +} + +/** Test hook: how many token verdicts are cached. */ +export function cachedPlatformVerdicts(): number { + return platformCache.size; +} + +/** Test hook: forget every cached key set and verdict. */ +export function resetRegistryTokenCaches(): void { + jwksCache.clear(); + jwksInFlight.clear(); + jwksRetryAt.clear(); + platformCache.clear(); +} diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts index 476775b42..97c054ee4 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts @@ -1,6 +1,5 @@ import axios from "axios"; import type { YogaInitialContext } from "graphql-yoga"; -import * as jose from "jose"; import { type ConditionEvaluator, evaluate, @@ -10,6 +9,7 @@ import { type Principal, resolveAclBlock, } from "../acl"; +import { verifyRegistryToken } from "../auth/registry-token"; import type { DbService } from "../db/db.service"; import type { MetaEnvelope } from "../db/types"; import { timed } from "../utils/timing"; @@ -34,12 +34,6 @@ export function isEName(value: unknown): value is string { return typeof value === "string" && value.startsWith("@") && value.length > 1; } -type CachedJWKS = { - jwks: ReturnType; - expiresAt: number; -}; -const jwksCache = new Map(); -const JWKS_TTL_MS = 24 * 60 * 60 * 1000; const JWKS_FETCH_TIMEOUT_MS = 5000; const PLATFORM_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; @@ -178,38 +172,7 @@ export class VaultAccessGuard { const token = authHeader.substring(7); // Remove 'Bearer ' prefix try { - const registryUrl = - process.env.PUBLIC_REGISTRY_URL || process.env.REGISTRY_URL; - if (!registryUrl) { - console.error("PUBLIC_REGISTRY_URL or REGISTRY_URL is not set"); - return null; - } - - const jwksUrl = new URL( - `/.well-known/jwks.json`, - registryUrl, - ).toString(); - - const now = Date.now(); - let cached = jwksCache.get(jwksUrl); - if (!cached || cached.expiresAt <= now) { - const jwksResponse = await timed( - "guard.validateToken.fetchJWKS", - () => - axios.get(jwksUrl, { - timeout: JWKS_FETCH_TIMEOUT_MS, - }), - ); - cached = { - jwks: jose.createLocalJWKSet(jwksResponse.data), - expiresAt: now + JWKS_TTL_MS, - }; - jwksCache.set(jwksUrl, cached); - } - - const { payload } = await jose.jwtVerify(token, cached.jwks); - - return payload; + return await verifyRegistryToken(token); } catch (error) { console.error("Token validation failed:", error); return null; From f5529c61457650b34362a114c1f65b0fb10500a3 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 12 Oct 2026 14:34:41 +0800 Subject: [PATCH 2/7] feat(evault): rate limit rule table and config snapshot --- infrastructure/evault-core/package.json | 1 + .../evault-core/src/config/database.ts | 9 +- .../src/core/http/ip-rules.spec.ts | 96 +++++++ .../evault-core/src/core/http/ip-rules.ts | 112 ++++++++ .../evault-core/src/entities/RateLimitRule.ts | 63 ++++ .../1791786717906-EvaultRateLimitRule.ts | 14 + .../services/RateLimitConfigService.spec.ts | 268 ++++++++++++++++++ .../src/services/RateLimitConfigService.ts | 267 +++++++++++++++++ pnpm-lock.yaml | 3 + 9 files changed, 832 insertions(+), 1 deletion(-) create mode 100644 infrastructure/evault-core/src/core/http/ip-rules.spec.ts create mode 100644 infrastructure/evault-core/src/core/http/ip-rules.ts create mode 100644 infrastructure/evault-core/src/entities/RateLimitRule.ts create mode 100644 infrastructure/evault-core/src/migrations/1791786717906-EvaultRateLimitRule.ts create mode 100644 infrastructure/evault-core/src/services/RateLimitConfigService.spec.ts create mode 100644 infrastructure/evault-core/src/services/RateLimitConfigService.ts diff --git a/infrastructure/evault-core/package.json b/infrastructure/evault-core/package.json index 1e4c80989..0a567bdaa 100644 --- a/infrastructure/evault-core/package.json +++ b/infrastructure/evault-core/package.json @@ -33,6 +33,7 @@ "graphql-type-json": "^0.3.2", "graphql-voyager": "^2.1.0", "graphql-yoga": "^5.13.4", + "ipaddr.js": "^2.3.0", "jose": "^5.2.2", "json-schema": "^0.4.0", "multer": "1.4.5-lts.2", diff --git a/infrastructure/evault-core/src/config/database.ts b/infrastructure/evault-core/src/config/database.ts index be3988fac..09c6942e8 100644 --- a/infrastructure/evault-core/src/config/database.ts +++ b/infrastructure/evault-core/src/config/database.ts @@ -3,6 +3,7 @@ import * as dotenv from "dotenv"; import { DataSource } from "typeorm"; import { DeviceToken } from "../entities/DeviceToken"; import { Notification } from "../entities/Notification"; +import { RateLimitRule } from "../entities/RateLimitRule"; import { SecurityAnswerAttempt } from "../entities/SecurityAnswerAttempt"; import { Verification } from "../entities/Verification"; @@ -16,7 +17,13 @@ export const AppDataSource = new DataSource({ process.env.PROVISIONER_DATABASE_URL || "postgresql://postgres:postgres@localhost:5432/registry", logging: process.env.NODE_ENV !== "production", - entities: [Verification, Notification, DeviceToken, SecurityAnswerAttempt], + entities: [ + Verification, + Notification, + DeviceToken, + SecurityAnswerAttempt, + RateLimitRule, + ], migrations: [join(__dirname, "../migrations/*.{ts,js}")], migrationsTableName: "migrations", subscribers: [], diff --git a/infrastructure/evault-core/src/core/http/ip-rules.spec.ts b/infrastructure/evault-core/src/core/http/ip-rules.spec.ts new file mode 100644 index 000000000..78d8ca470 --- /dev/null +++ b/infrastructure/evault-core/src/core/http/ip-rules.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalizeSubject, + compileIpRules, + hostBits, + isTooBroad, + normalizeIp, +} from "./ip-rules"; + +describe("ip-rules", () => { + describe("normalizeIp", () => { + it("unwraps an IPv4-mapped IPv6 address", () => { + expect(normalizeIp("::ffff:203.0.113.7")).toBe("203.0.113.7"); + }); + + it("compresses IPv6 to one spelling", () => { + expect(normalizeIp("2001:0db8:0000:0000:0000:0000:0000:0001")).toBe( + "2001:db8::1", + ); + }); + + it("returns unparseable input unchanged", () => { + expect(normalizeIp("unknown")).toBe("unknown"); + }); + }); + + describe("canonicalizeSubject", () => { + it("turns a bare address into a single-address range", () => { + expect(canonicalizeSubject(" 203.0.113.7 ")).toBe("203.0.113.7/32"); + expect(canonicalizeSubject("2001:db8::1")).toBe("2001:db8::1/128"); + }); + + it("zeroes host bits", () => { + expect(canonicalizeSubject("203.0.113.7/24")).toBe("203.0.113.0/24"); + expect(canonicalizeSubject("2001:db8::1/32")).toBe("2001:db8::/32"); + }); + + it("stores a mapped IPv4 range as IPv4", () => { + expect(canonicalizeSubject("::ffff:203.0.113.7")).toBe( + "203.0.113.7/32", + ); + expect(canonicalizeSubject("::ffff:203.0.113.7/120")).toBe( + "203.0.113.0/24", + ); + }); + + it("rejects invalid input", () => { + for (const bad of ["", "nope", "10", "1.2.3.4/33", "1.2.3/24", "*"]) { + expect(() => canonicalizeSubject(bad)).toThrow(); + } + }); + }); + + it("flags ranges too broad to block or exempt", () => { + expect(isTooBroad("10.0.0.0/8")).toBe(false); + expect(isTooBroad("0.0.0.0/0")).toBe(true); + expect(isTooBroad("8.0.0.0/7")).toBe(true); + expect(isTooBroad("2001:db8::/32")).toBe(false); + expect(isTooBroad("2000::/3")).toBe(true); + }); + + it("counts the host bits of a range", () => { + expect(hostBits("203.0.113.0/24")).toBe(8); + expect(hostBits("203.0.113.7/32")).toBe(0); + }); + + describe("compileIpRules", () => { + const match = compileIpRules([ + { subject: "10.0.0.0/8", name: "wide" }, + { subject: "10.1.2.3/32", name: "host" }, + { subject: "10.1.0.0/16", name: "mid" }, + { subject: "2001:db8::/32", name: "v6" }, + { subject: "garbage", name: "bad" }, + ]); + + it("picks the most specific range", () => { + expect(match("10.1.2.3")?.name).toBe("host"); + expect(match("10.1.9.9")?.name).toBe("mid"); + expect(match("10.200.0.1")?.name).toBe("wide"); + }); + + it("matches a mapped IPv4 client against IPv4 rules", () => { + expect(match("::ffff:10.1.2.3")?.name).toBe("host"); + }); + + it("matches IPv6 and keeps the families apart", () => { + expect(match("2001:db8::42")?.name).toBe("v6"); + expect(match("2001:db9::1")).toBeNull(); + }); + + it("matches nothing for an uncovered or invalid address", () => { + expect(match("192.0.2.1")).toBeNull(); + expect(match("unknown")).toBeNull(); + }); + }); +}); diff --git a/infrastructure/evault-core/src/core/http/ip-rules.ts b/infrastructure/evault-core/src/core/http/ip-rules.ts new file mode 100644 index 000000000..d3a613384 --- /dev/null +++ b/infrastructure/evault-core/src/core/http/ip-rules.ts @@ -0,0 +1,112 @@ +import ipaddr from "ipaddr.js"; + +type Address = ipaddr.IPv4 | ipaddr.IPv6; + +// Block and exempt rules broader than this are refused: one typo should not +// cut off, or wave through, a large share of the internet. +const MIN_PREFIX = { ipv4: 8, ipv6: 32 } as const; + +function parseAddress(raw: string): Address | null { + try { + // process() unwraps IPv4-mapped IPv6 (::ffff:1.2.3.4), which is how a + // dual-stack socket reports an IPv4 client. + return ipaddr.process(raw.trim()); + } catch { + return null; + } +} + +/** + * The form of a client address used as its counter key and matched against + * rules. An address that cannot be parsed is returned unchanged. + */ +export function normalizeIp(raw: string): string { + return parseAddress(raw)?.toString() ?? raw; +} + +/** + * The canonical CIDR for an admin-entered IP or range: a bare address becomes + * a single-address range and host bits are zeroed. Throws on invalid input. + */ +export function canonicalizeSubject(input: string): string { + const value = input.trim(); + // The parser also takes shorthand such as "10" (0.0.0.10) or "1.2.3"; + // an admin typing that almost certainly meant something else. + const host = value.split("/")[0]; + if (!host.includes(":") && !ipaddr.IPv4.isValidFourPartDecimal(host)) { + throw new Error("Not a valid IP address or CIDR range"); + } + let address: Address; + let prefix: number; + if (value.includes("/")) { + try { + [address, prefix] = ipaddr.parseCIDR(value); + } catch { + throw new Error("Not a valid IP address or CIDR range"); + } + if ( + address.kind() === "ipv6" && + (address as ipaddr.IPv6).isIPv4MappedAddress() && + prefix >= 96 + ) { + address = (address as ipaddr.IPv6).toIPv4Address(); + prefix -= 96; + } + } else { + const parsed = parseAddress(value); + if (!parsed) throw new Error("Not a valid IP address or CIDR range"); + address = parsed; + prefix = parsed.kind() === "ipv4" ? 32 : 128; + } + + const cidr = `${address.toString()}/${prefix}`; + const network = + address.kind() === "ipv4" + ? ipaddr.IPv4.networkAddressFromCIDR(cidr) + : ipaddr.IPv6.networkAddressFromCIDR(cidr); + return `${network.toString()}/${prefix}`; +} + +/** Whether a canonical CIDR covers more than a block or exempt rule may. */ +export function isTooBroad(subject: string): boolean { + const [address, prefix] = ipaddr.parseCIDR(subject); + return prefix < MIN_PREFIX[address.kind() as "ipv4" | "ipv6"]; +} + +/** How many addresses a canonical CIDR covers, as a power of two exponent. */ +export function hostBits(subject: string): number { + const [address, prefix] = ipaddr.parseCIDR(subject); + return (address.kind() === "ipv4" ? 32 : 128) - prefix; +} + +export type IpRuleMatcher = (ip: string) => T | null; + +/** + * Builds a matcher returning the most specific rule covering an address. + * Rules whose subject is not a valid CIDR are skipped. + */ +export function compileIpRules( + rules: T[], +): IpRuleMatcher { + const compiled: { range: [Address, number]; rule: T }[] = []; + for (const rule of rules) { + try { + compiled.push({ range: ipaddr.parseCIDR(rule.subject), rule }); + } catch { + console.warn( + `[rate-limit] ignoring rule with invalid range "${rule.subject}"`, + ); + } + } + compiled.sort((a, b) => b.range[1] - a.range[1]); + + return (ip) => { + const address = parseAddress(ip); + if (!address) return null; + for (const { range, rule } of compiled) { + if (address.kind() !== range[0].kind()) continue; + if (address.match(range)) return rule; + } + return null; + }; +} diff --git a/infrastructure/evault-core/src/entities/RateLimitRule.ts b/infrastructure/evault-core/src/entities/RateLimitRule.ts new file mode 100644 index 000000000..105f3078f --- /dev/null +++ b/infrastructure/evault-core/src/entities/RateLimitRule.ts @@ -0,0 +1,63 @@ +import { + Check, + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from "typeorm"; + +export type RateLimitKind = "platform" | "ip"; +export type RateLimitMode = "limit" | "blocked" | "exempt"; + +/** The subject of the rule that sets a kind's default limit. */ +export const DEFAULT_SUBJECT = "*"; + +/** + * One admin-set rate limit rule. `subject` is a platform name, a canonical + * CIDR, or `*` for the default applied to every other subject of that kind. + */ +@Entity("evault_rate_limit_rule") +@Unique("UQ_evault_rate_limit_rule_subject", ["kind", "subject"]) +@Check("CHK_evault_rate_limit_rule_kind", `"kind" IN ('platform', 'ip')`) +@Check( + "CHK_evault_rate_limit_rule_mode", + `"mode" IN ('limit', 'blocked', 'exempt')`, +) +@Check( + "CHK_evault_rate_limit_rule_limit", + `("mode" = 'limit') = ("limitPerMinute" IS NOT NULL)`, +) +@Check( + "CHK_evault_rate_limit_rule_default", + `"subject" <> '*' OR "mode" = 'limit'`, +) +export class RateLimitRule { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "varchar" }) + kind!: RateLimitKind; + + @Column({ type: "varchar", length: 256 }) + subject!: string; + + @Column({ type: "varchar" }) + mode!: RateLimitMode; + + @Column({ type: "integer", nullable: true }) + limitPerMinute!: number | null; + + @Column({ type: "varchar", length: 500, nullable: true }) + note!: string | null; + + @Column({ type: "varchar", nullable: true }) + updatedBy!: string | null; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt!: Date; + + @UpdateDateColumn({ type: "timestamptz" }) + updatedAt!: Date; +} diff --git a/infrastructure/evault-core/src/migrations/1791786717906-EvaultRateLimitRule.ts b/infrastructure/evault-core/src/migrations/1791786717906-EvaultRateLimitRule.ts new file mode 100644 index 000000000..c23d2bb3c --- /dev/null +++ b/infrastructure/evault-core/src/migrations/1791786717906-EvaultRateLimitRule.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class EvaultRateLimitRule1791786717906 implements MigrationInterface { + name = 'EvaultRateLimitRule1791786717906' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "evault_rate_limit_rule" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "kind" character varying NOT NULL, "subject" character varying(256) NOT NULL, "mode" character varying NOT NULL, "limitPerMinute" integer, "note" character varying(500), "updatedBy" character varying, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_evault_rate_limit_rule_subject" UNIQUE ("kind", "subject"), CONSTRAINT "CHK_evault_rate_limit_rule_kind" CHECK ("kind" IN ('platform', 'ip')), CONSTRAINT "CHK_evault_rate_limit_rule_mode" CHECK ("mode" IN ('limit', 'blocked', 'exempt')), CONSTRAINT "CHK_evault_rate_limit_rule_limit" CHECK (("mode" = 'limit') = ("limitPerMinute" IS NOT NULL)), CONSTRAINT "CHK_evault_rate_limit_rule_default" CHECK ("subject" <> '*' OR "mode" = 'limit'), CONSTRAINT "PK_evault_rate_limit_rule" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "evault_rate_limit_rule"`); + } + +} diff --git a/infrastructure/evault-core/src/services/RateLimitConfigService.spec.ts b/infrastructure/evault-core/src/services/RateLimitConfigService.spec.ts new file mode 100644 index 000000000..9557a90d9 --- /dev/null +++ b/infrastructure/evault-core/src/services/RateLimitConfigService.spec.ts @@ -0,0 +1,268 @@ +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RateLimitRule } from "../entities/RateLimitRule"; +import { + RateLimitConfigService, + RuleValidationError, + validateRuleInput, +} from "./RateLimitConfigService"; + +function makeRepoStub(initial: Partial[] = []) { + const rows = new Map(); + let nextId = 1; + const add = (partial: Partial) => { + const row = { + id: partial.id ?? `id-${nextId++}`, + limitPerMinute: null, + note: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + ...partial, + } as RateLimitRule; + rows.set(row.id, row); + return row; + }; + initial.forEach(add); + const stub = { + failing: false, + async find() { + if (stub.failing) throw new Error("db down"); + return [...rows.values()]; + }, + async findOne({ where }: { where: Partial }) { + return ( + [...rows.values()].find( + (r) => + r.kind === where.kind && r.subject === where.subject, + ) ?? null + ); + }, + create(partial: Partial) { + return partial as RateLimitRule; + }, + async save(row: RateLimitRule) { + return add(row); + }, + async delete({ id }: { id: string }) { + return { affected: rows.delete(id) ? 1 : 0 }; + }, + }; + return stub as any; +} + +describe("RateLimitConfigService", () => { + const saved = { ...process.env }; + + beforeEach(() => { + delete process.env.RATE_LIMIT_PER_PLATFORM; + delete process.env.RATE_LIMIT_PER_IP; + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = { ...saved }; + vi.restoreAllMocks(); + }); + + it("uses the built-in defaults before anything is loaded", () => { + const service = new RateLimitConfigService(makeRepoStub()); + expect(service.snapshot.source).toBe("env"); + expect(service.snapshot.platformDefault).toEqual({ + limit: 250, + source: "env", + }); + expect(service.snapshot.ipDefault).toEqual({ + limit: 500, + source: "env", + }); + }); + + it("takes defaults from env when the table has none", async () => { + process.env.RATE_LIMIT_PER_PLATFORM = "40"; + process.env.RATE_LIMIT_PER_IP = "nonsense"; + const service = new RateLimitConfigService(makeRepoStub()); + await service.reload(); + expect(service.snapshot.source).toBe("db"); + expect(service.snapshot.platformDefault.limit).toBe(40); + expect(service.snapshot.ipDefault.limit).toBe(500); + }); + + it("lets a stored default override env", async () => { + process.env.RATE_LIMIT_PER_IP = "40"; + const service = new RateLimitConfigService( + makeRepoStub([ + { kind: "ip", subject: "*", mode: "limit", limitPerMinute: 9 }, + ]), + ); + await service.reload(); + expect(service.snapshot.ipDefault).toEqual({ limit: 9, source: "db" }); + expect(service.snapshot.matchIp("203.0.113.7")).toBeNull(); + }); + + it("indexes platform and IP rules", async () => { + const service = new RateLimitConfigService( + makeRepoStub([ + { + kind: "platform", + subject: "http://pictique.test", + mode: "limit", + limitPerMinute: 1000, + }, + { kind: "ip", subject: "203.0.113.0/24", mode: "blocked" }, + ]), + ); + await service.reload(); + expect( + service.snapshot.platformRules.get("http://pictique.test") + ?.limitPerMinute, + ).toBe(1000); + expect(service.snapshot.matchIp("203.0.113.7")?.mode).toBe("blocked"); + }); + + it("skips a malformed row and keeps the rest", async () => { + const service = new RateLimitConfigService( + makeRepoStub([ + { kind: "ip", subject: "203.0.113.7/32", mode: "limit" }, + { kind: "ip", subject: "198.51.100.1/32", mode: "exempt" }, + ]), + ); + await service.reload(); + expect(service.snapshot.rules).toHaveLength(1); + expect(service.snapshot.matchIp("198.51.100.1")?.mode).toBe("exempt"); + }); + + it("falls back to env defaults when the first load fails", async () => { + const repo = makeRepoStub(); + repo.failing = true; + const service = new RateLimitConfigService(repo); + await service.reload(); + expect(service.snapshot.source).toBe("env"); + expect(service.snapshot.ipDefault.limit).toBe(500); + }); + + it("keeps the last loaded rules when a later load fails", async () => { + const repo = makeRepoStub([ + { kind: "ip", subject: "203.0.113.7/32", mode: "blocked" }, + ]); + const service = new RateLimitConfigService(repo); + await service.reload(); + repo.failing = true; + await service.reload(); + expect(service.snapshot.matchIp("203.0.113.7")?.mode).toBe("blocked"); + }); + + it("applies a write immediately and replaces a subject's rule", async () => { + const service = new RateLimitConfigService(makeRepoStub()); + await service.upsert({ + kind: "ip", + subject: "203.0.113.7", + mode: "limit", + limitPerMinute: 5, + }); + expect(service.snapshot.matchIp("203.0.113.7")?.limitPerMinute).toBe(5); + + const replaced = await service.upsert({ + kind: "ip", + subject: "203.0.113.7/32", + mode: "exempt", + }); + expect(service.snapshot.rules).toHaveLength(1); + expect(service.snapshot.matchIp("203.0.113.7")?.mode).toBe("exempt"); + expect(replaced.limitPerMinute).toBeNull(); + + expect(await service.remove(replaced.id)).toBe(true); + expect(service.snapshot.matchIp("203.0.113.7")).toBeNull(); + expect(await service.remove("missing")).toBe(false); + }); + + describe("validateRuleInput", () => { + const field = (input: any) => { + try { + validateRuleInput(input); + return null; + } catch (error) { + expect(error).toBeInstanceOf(RuleValidationError); + return (error as RuleValidationError).field; + } + }; + + it("accepts and canonicalises a valid rule", () => { + expect( + validateRuleInput({ + kind: "ip", + subject: "203.0.113.7/24", + mode: "limit", + limitPerMinute: 10, + note: "office", + }), + ).toEqual({ + kind: "ip", + subject: "203.0.113.0/24", + mode: "limit", + limitPerMinute: 10, + note: "office", + updatedBy: null, + }); + }); + + it("drops the limit of a block or exempt rule", () => { + expect( + validateRuleInput({ + kind: "platform", + subject: " http://pictique.test ", + mode: "blocked", + limitPerMinute: 10, + }), + ).toMatchObject({ + subject: "http://pictique.test", + limitPerMinute: null, + }); + }); + + it("names the field that is wrong", () => { + const ok = { + kind: "ip", + subject: "203.0.113.7", + mode: "limit", + limitPerMinute: 10, + }; + expect(field({ ...ok, kind: "user" })).toBe("kind"); + expect(field({ ...ok, mode: "allow" })).toBe("mode"); + expect(field({ ...ok, subject: "" })).toBe("subject"); + expect(field({ ...ok, subject: "nope" })).toBe("subject"); + expect(field({ ...ok, limitPerMinute: 0 })).toBe("limitPerMinute"); + expect(field({ ...ok, limitPerMinute: 1.5 })).toBe( + "limitPerMinute", + ); + expect(field({ ...ok, limitPerMinute: "10" })).toBe( + "limitPerMinute", + ); + expect(field({ ...ok, limitPerMinute: 1_000_001 })).toBe( + "limitPerMinute", + ); + expect(field({ ...ok, note: "x".repeat(501) })).toBe("note"); + expect( + field({ ...ok, kind: "platform", subject: "x".repeat(257) }), + ).toBe("subject"); + }); + + it("refuses to block or exempt a default or a huge range", () => { + expect(field({ kind: "ip", subject: "*", mode: "blocked" })).toBe( + "mode", + ); + expect( + field({ kind: "ip", subject: "0.0.0.0/0", mode: "exempt" }), + ).toBe("subject"); + expect( + field({ + kind: "ip", + subject: "0.0.0.0/0", + mode: "limit", + limitPerMinute: 5, + }), + ).toBeNull(); + }); + }); +}); diff --git a/infrastructure/evault-core/src/services/RateLimitConfigService.ts b/infrastructure/evault-core/src/services/RateLimitConfigService.ts new file mode 100644 index 000000000..5201d4f4d --- /dev/null +++ b/infrastructure/evault-core/src/services/RateLimitConfigService.ts @@ -0,0 +1,267 @@ +import type { Repository } from "typeorm"; +import { + type IpRuleMatcher, + canonicalizeSubject, + compileIpRules, + isTooBroad, +} from "../core/http/ip-rules"; +import { + DEFAULT_SUBJECT, + type RateLimitKind, + type RateLimitMode, + type RateLimitRule, +} from "../entities/RateLimitRule"; + +const POLL_MS = 30_000; +const MAX_LIMIT = 1_000_000; +const KINDS: RateLimitKind[] = ["platform", "ip"]; +const MODES: RateLimitMode[] = ["limit", "blocked", "exempt"]; + +export type LimitSource = "db" | "env"; + +export interface RateLimitSnapshot { + /** Where the rules came from: the table, or env defaults alone. */ + source: LimitSource; + loadedAt: Date; + platformDefault: { limit: number; source: LimitSource }; + ipDefault: { limit: number; source: LimitSource }; + /** Platform rules by exact platform name, defaults excluded. */ + platformRules: Map; + /** The most specific IP rule covering an address, defaults excluded. */ + matchIp: IpRuleMatcher; + /** Every usable rule, defaults included. */ + rules: RateLimitRule[]; +} + +export interface RuleInput { + kind?: unknown; + subject?: unknown; + mode?: unknown; + limitPerMinute?: unknown; + note?: unknown; + updatedBy?: unknown; +} + +export class RuleValidationError extends Error { + constructor( + message: string, + readonly field: string, + ) { + super(message); + } +} + +function envLimit(name: string, fallback: number): number { + const value = Number(process.env[name]); + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +function isUsable(rule: RateLimitRule): boolean { + if (!KINDS.includes(rule.kind) || !MODES.includes(rule.mode)) return false; + if (rule.mode === "limit") { + return Number.isInteger(rule.limitPerMinute) && rule.limitPerMinute! > 0; + } + return rule.subject !== DEFAULT_SUBJECT; +} + +function buildSnapshot( + rows: RateLimitRule[], + source: LimitSource, +): RateLimitSnapshot { + const rules: RateLimitRule[] = []; + for (const row of rows) { + if (isUsable(row)) rules.push(row); + else console.warn(`[rate-limit] ignoring malformed rule ${row.id}`); + } + + const defaultFor = (kind: RateLimitKind, env: string, fallback: number) => { + const row = rules.find( + (r) => r.kind === kind && r.subject === DEFAULT_SUBJECT, + ); + return row + ? { limit: row.limitPerMinute!, source: "db" as const } + : { limit: envLimit(env, fallback), source: "env" as const }; + }; + + const specific = rules.filter((r) => r.subject !== DEFAULT_SUBJECT); + return { + source, + loadedAt: new Date(), + platformDefault: defaultFor("platform", "RATE_LIMIT_PER_PLATFORM", 250), + ipDefault: defaultFor("ip", "RATE_LIMIT_PER_IP", 500), + platformRules: new Map( + specific + .filter((r) => r.kind === "platform") + .map((r) => [r.subject, r]), + ), + matchIp: compileIpRules(specific.filter((r) => r.kind === "ip")), + rules, + }; +} + +/** + * Checks an admin-supplied rule and returns it in stored form. IP subjects + * are canonicalised so the same range cannot be entered twice. + */ +export function validateRuleInput(input: RuleInput): { + kind: RateLimitKind; + subject: string; + mode: RateLimitMode; + limitPerMinute: number | null; + note: string | null; + updatedBy: string | null; +} { + const kind = input.kind as RateLimitKind; + if (!KINDS.includes(kind)) { + throw new RuleValidationError("kind must be platform or ip", "kind"); + } + const mode = input.mode as RateLimitMode; + if (!MODES.includes(mode)) { + throw new RuleValidationError( + "mode must be limit, blocked or exempt", + "mode", + ); + } + + if (typeof input.subject !== "string" || !input.subject.trim()) { + throw new RuleValidationError("subject is required", "subject"); + } + let subject = input.subject.trim(); + if (subject === DEFAULT_SUBJECT) { + if (mode !== "limit") { + throw new RuleValidationError( + "The default can only be a limit", + "mode", + ); + } + } else if (kind === "ip") { + try { + subject = canonicalizeSubject(subject); + } catch (error) { + throw new RuleValidationError((error as Error).message, "subject"); + } + if (mode !== "limit" && isTooBroad(subject)) { + throw new RuleValidationError( + "Range is too broad to block or exempt (minimum /8 for IPv4, /32 for IPv6)", + "subject", + ); + } + } else if (subject.length > 256) { + throw new RuleValidationError( + "subject must be at most 256 characters", + "subject", + ); + } + + let limitPerMinute: number | null = null; + if (mode === "limit") { + const limit = input.limitPerMinute; + if ( + typeof limit !== "number" || + !Number.isInteger(limit) || + limit < 1 || + limit > MAX_LIMIT + ) { + throw new RuleValidationError( + `limitPerMinute must be a whole number from 1 to ${MAX_LIMIT}`, + "limitPerMinute", + ); + } + limitPerMinute = limit; + } + + const text = (value: unknown, field: string, max: number) => { + if (value === undefined || value === null || value === "") return null; + if (typeof value !== "string" || value.length > max) { + throw new RuleValidationError( + `${field} must be text of at most ${max} characters`, + field, + ); + } + return value; + }; + + return { + kind, + subject, + mode, + limitPerMinute, + note: text(input.note, "note", 500), + updatedBy: text(input.updatedBy, "updatedBy", 256), + }; +} + +/** + * Holds the rate limit rules in memory so the request path never touches the + * database. The snapshot is rebuilt after every admin write and on a poll, so + * other instances pick a change up within {@link POLL_MS}. + * + * A failed load keeps the last good snapshot; with none, the env defaults + * apply. Rate limiting never fails closed on a database outage. + */ +export class RateLimitConfigService { + private current: RateLimitSnapshot = buildSnapshot([], "env"); + private healthy = true; + private timer?: NodeJS.Timeout; + + constructor( + private readonly repository: Pick< + Repository, + "find" | "findOne" | "save" | "create" | "delete" + >, + ) {} + + get snapshot(): RateLimitSnapshot { + return this.current; + } + + async reload(): Promise { + try { + const rows = await this.repository.find(); + this.current = buildSnapshot(rows, "db"); + if (!this.healthy) console.log("[rate-limit] rules reloaded"); + this.healthy = true; + } catch (error) { + if (this.healthy) { + console.error( + `[rate-limit] could not load rules, keeping ${this.current.source === "db" ? "the last loaded rules" : "env defaults"}:`, + error, + ); + } + this.healthy = false; + } + } + + async start(): Promise { + await this.reload(); + this.timer = setInterval(() => void this.reload(), POLL_MS); + this.timer.unref(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = undefined; + } + + /** Creates the rule for a subject, or replaces the one it already has. */ + async upsert(input: RuleInput): Promise { + const rule = validateRuleInput(input); + const existing = await this.repository.findOne({ + where: { kind: rule.kind, subject: rule.subject }, + }); + const saved = await this.repository.save( + existing + ? Object.assign(existing, rule) + : this.repository.create(rule), + ); + await this.reload(); + return saved; + } + + /** Removes a rule. Returns false when there was none with that id. */ + async remove(id: string): Promise { + const result = await this.repository.delete({ id }); + await this.reload(); + return Boolean(result.affected); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4319b624..66f56a6aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -569,6 +569,9 @@ importers: graphql-yoga: specifier: ^5.13.4 version: 5.18.1(graphql@16.13.1) + ipaddr.js: + specifier: ^2.3.0 + version: 2.3.0 jose: specifier: ^5.2.2 version: 5.10.0 From 96b929186d2e1b89bf1f86499e7b6c5baf5eb3ba Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 12 Oct 2026 14:36:22 +0800 Subject: [PATCH 3/7] feat(evault): per-platform and per-IP rate limit rules --- .../src/core/http/global-rate-limiter.spec.ts | 379 ++++++++++++++++++ .../src/core/http/global-rate-limiter.ts | 295 +++++++++++--- infrastructure/evault-core/src/index.ts | 49 +-- 3 files changed, 647 insertions(+), 76 deletions(-) create mode 100644 infrastructure/evault-core/src/core/http/global-rate-limiter.spec.ts diff --git a/infrastructure/evault-core/src/core/http/global-rate-limiter.spec.ts b/infrastructure/evault-core/src/core/http/global-rate-limiter.spec.ts new file mode 100644 index 000000000..f202d8790 --- /dev/null +++ b/infrastructure/evault-core/src/core/http/global-rate-limiter.spec.ts @@ -0,0 +1,379 @@ +import { describe, expect, it, vi } from "vitest"; +import type { RateLimitRule } from "../../entities/RateLimitRule"; +import type { RateLimitSnapshot } from "../../services/RateLimitConfigService"; +import { + GlobalRateLimiter, + createRateLimitHook, + parseTrustProxy, +} from "./global-rate-limiter"; +import { compileIpRules } from "./ip-rules"; + +type RuleSpec = Pick & { + limitPerMinute?: number; +}; + +function makeSnapshot( + specs: RuleSpec[] = [], + defaults = { platform: 3, ip: 5 }, +): RateLimitSnapshot { + const rules = specs.map( + (spec) => ({ limitPerMinute: null, ...spec }) as RateLimitRule, + ); + return { + source: "db", + loadedAt: new Date(), + platformDefault: { limit: defaults.platform, source: "env" }, + ipDefault: { limit: defaults.ip, source: "env" }, + platformRules: new Map( + rules.filter((r) => r.kind === "platform").map((r) => [r.subject, r]), + ), + matchIp: compileIpRules(rules.filter((r) => r.kind === "ip")), + rules, + }; +} + +// A token is its own platform name here; "bad" stands for an invalid one. +const platformOf = async (token: string | null) => + token && token !== "bad" ? token : null; + +function makeLimiter(specs: RuleSpec[] = [], defaults?: any) { + const state = { snapshot: makeSnapshot(specs, defaults), now: 1_000_000 }; + const verify = vi.fn(platformOf); + const limiter = new GlobalRateLimiter( + () => state.snapshot, + verify, + () => state.now, + ); + const hit = (ip = "203.0.113.7", token: string | null = null) => + limiter.check("POST", ip, token); + const hits = async (n: number, ip?: string, token?: string | null) => { + let last = await hit(ip, token); + for (let i = 1; i < n; i++) last = await hit(ip, token); + return last; + }; + return { limiter, state, verify, hit, hits }; +} + +describe("GlobalRateLimiter", () => { + it("allows requests up to the IP default, then rejects", async () => { + const { hits, hit } = makeLimiter(); + expect(await hits(5)).toEqual({ allowed: true }); + expect(await hit()).toEqual({ + allowed: false, + status: 429, + scope: "ip", + retryAfterSeconds: 60, + }); + }); + + it("counts each IP separately and a mapped address as its IPv4", async () => { + const { hits, hit } = makeLimiter(); + await hits(5, "203.0.113.7"); + expect((await hit("198.51.100.1")).allowed).toBe(true); + expect((await hit("::ffff:203.0.113.7")).allowed).toBe(false); + }); + + it("starts a new window after a minute", async () => { + const { hits, hit, state } = makeLimiter(); + await hits(6); + state.now += 60_001; + expect((await hit()).allowed).toBe(true); + }); + + it("never counts a preflight", async () => { + const { limiter, hits } = makeLimiter(); + for (let i = 0; i < 20; i++) { + await limiter.check("OPTIONS", "203.0.113.7", null); + } + expect(await hits(5)).toEqual({ allowed: true }); + }); + + it("applies a per-IP limit, raised or lowered", async () => { + const { hits, hit } = makeLimiter([ + { + kind: "ip", + subject: "203.0.113.7/32", + mode: "limit", + limitPerMinute: 8, + }, + { + kind: "ip", + subject: "198.51.100.0/24", + mode: "limit", + limitPerMinute: 1, + }, + ]); + expect((await hits(8)).allowed).toBe(true); + expect((await hit()).allowed).toBe(false); + expect((await hit("198.51.100.1")).allowed).toBe(true); + expect((await hit("198.51.100.1")).allowed).toBe(false); + // A range limit is per address, not one bucket for the range. + expect((await hit("198.51.100.2")).allowed).toBe(true); + }); + + it("blocks an IP without counting it or reading its token", async () => { + const { limiter, hit, verify } = makeLimiter([ + { kind: "ip", subject: "203.0.113.0/24", mode: "blocked" }, + ]); + expect(await hit("203.0.113.7", "pictique")).toEqual({ + allowed: false, + status: 403, + }); + expect(verify).not.toHaveBeenCalled(); + expect(limiter.getUsage().ips).toEqual([]); + }); + + it("lets an exempt IP through everything", async () => { + const { hits, verify } = makeLimiter([ + { kind: "ip", subject: "203.0.113.7/32", mode: "exempt" }, + { kind: "platform", subject: "pictique", mode: "blocked" }, + ]); + expect(await hits(50, "203.0.113.7", "pictique")).toEqual({ + allowed: true, + }); + expect(verify).not.toHaveBeenCalled(); + }); + + it("prefers the most specific IP rule", async () => { + const { hit } = makeLimiter([ + { kind: "ip", subject: "203.0.113.0/24", mode: "blocked" }, + { kind: "ip", subject: "203.0.113.7/32", mode: "exempt" }, + ]); + expect((await hit("203.0.113.7")).allowed).toBe(true); + expect((await hit("203.0.113.8")).allowed).toBe(false); + }); + + it("holds a platform to its default across IPs", async () => { + const { hit } = makeLimiter(); + for (let i = 1; i <= 3; i++) { + expect((await hit(`198.51.100.${i}`, "pictique")).allowed).toBe( + true, + ); + } + expect(await hit("198.51.100.4", "pictique")).toEqual({ + allowed: false, + status: 429, + scope: "platform", + retryAfterSeconds: 60, + }); + expect((await hit("198.51.100.5", "blabsy")).allowed).toBe(true); + }); + + it("applies a per-platform limit", async () => { + const { hit } = makeLimiter( + [ + { + kind: "platform", + subject: "pictique", + mode: "limit", + limitPerMinute: 4, + }, + ], + { platform: 1, ip: 100 }, + ); + for (let i = 0; i < 4; i++) { + expect((await hit("203.0.113.7", "pictique")).allowed).toBe(true); + } + expect((await hit("203.0.113.7", "pictique")).allowed).toBe(false); + }); + + it("keeps the IP limit as the ceiling over any platform rule", async () => { + const { hits, hit } = makeLimiter([ + { + kind: "platform", + subject: "pictique", + mode: "limit", + limitPerMinute: 1000, + }, + { kind: "platform", subject: "blabsy", mode: "exempt" }, + ]); + expect((await hits(5, "203.0.113.7", "pictique")).allowed).toBe(true); + expect(await hit("203.0.113.7", "pictique")).toMatchObject({ + status: 429, + scope: "ip", + }); + expect((await hits(5, "198.51.100.1", "blabsy")).allowed).toBe(true); + expect(await hit("198.51.100.1", "blabsy")).toMatchObject({ + status: 429, + scope: "ip", + }); + }); + + it("exempts a platform from its own bucket only", async () => { + const { limiter, hits } = makeLimiter( + [{ kind: "platform", subject: "pictique", mode: "exempt" }], + { platform: 1, ip: 100 }, + ); + expect((await hits(50, "203.0.113.7", "pictique")).allowed).toBe(true); + expect(limiter.getUsage().platforms).toEqual([]); + }); + + it("blocks a platform", async () => { + const { hit } = makeLimiter([ + { kind: "platform", subject: "pictique", mode: "blocked" }, + ]); + expect(await hit("203.0.113.7", "pictique")).toEqual({ + allowed: false, + status: 403, + }); + expect((await hit("203.0.113.7", "blabsy")).allowed).toBe(true); + }); + + it("treats an unverifiable token as no platform", async () => { + const { limiter, hits } = makeLimiter([], { platform: 1, ip: 100 }); + expect((await hits(10, "203.0.113.7", "bad")).allowed).toBe(true); + expect(limiter.getUsage().platforms).toEqual([]); + }); + + it("does not verify a token once the IP is over its limit", async () => { + const { hits, hit, verify } = makeLimiter(); + await hits(5, "203.0.113.7", "pictique"); + verify.mockClear(); + await hit("203.0.113.7", "pictique"); + expect(verify).not.toHaveBeenCalled(); + }); + + it("lifts a throttled caller as soon as its limit is raised", async () => { + const { hits, hit, state } = makeLimiter(); + await hits(6); + expect((await hit()).allowed).toBe(false); + state.snapshot = makeSnapshot([ + { + kind: "ip", + subject: "203.0.113.7/32", + mode: "limit", + limitPerMinute: 100, + }, + ]); + expect((await hit()).allowed).toBe(true); + }); + + it("reports live usage, busiest first", async () => { + const { limiter, hits, state } = makeLimiter([ + { + kind: "ip", + subject: "198.51.100.0/24", + mode: "limit", + limitPerMinute: 100, + }, + ]); + await hits(7, "203.0.113.7", "pictique"); + await hits(2, "198.51.100.1"); + state.now += 15_000; + + const usage = limiter.getUsage(); + expect(usage.windowSeconds).toBe(60); + expect(usage.truncated).toBe(false); + expect(usage.ips).toEqual([ + { + subject: "203.0.113.7", + count: 7, + rejected: 2, + limit: 5, + mode: "limit", + matchedRule: null, + resetsInSeconds: 45, + }, + { + subject: "198.51.100.1", + count: 2, + rejected: 0, + limit: 100, + mode: "limit", + matchedRule: "198.51.100.0/24", + resetsInSeconds: 45, + }, + ]); + expect(usage.platforms).toEqual([ + { + subject: "pictique", + count: 5, + rejected: 2, + limit: 3, + mode: "limit", + matchedRule: null, + resetsInSeconds: 45, + }, + ]); + + state.now += 60_000; + expect(limiter.getUsage().ips).toEqual([]); + }); +}); + +describe("createRateLimitHook", () => { + function makeReply() { + const reply: any = { headers: {} }; + reply.code = vi.fn((status: number) => { + reply.status = status; + return reply; + }); + reply.header = vi.fn((name: string, value: string) => { + reply.headers[name] = value; + return reply; + }); + reply.send = vi.fn((body: unknown) => { + reply.body = body; + return reply; + }); + return reply; + } + + const request = (authorization?: string) => + ({ + method: "POST", + ip: "203.0.113.7", + headers: { authorization }, + }) as any; + + it("passes an allowed request through untouched", async () => { + const { limiter } = makeLimiter(); + const reply = makeReply(); + const result = await createRateLimitHook(limiter)(request(), reply); + expect(result).toBeUndefined(); + expect(reply.send).not.toHaveBeenCalled(); + }); + + it("answers 429 with Retry-After and the scope", async () => { + const { limiter } = makeLimiter([], { platform: 1, ip: 100 }); + const hook = createRateLimitHook(limiter); + await hook(request("Bearer pictique"), makeReply()); + const reply = makeReply(); + expect(await hook(request("Bearer pictique"), reply)).toBe(reply); + expect(reply.status).toBe(429); + expect(reply.headers["Retry-After"]).toBe("60"); + expect(reply.body).toEqual({ + error: "Too Many Requests", + retryAfterSeconds: 60, + scope: "platform", + }); + }); + + it("answers 403 for a blocked caller", async () => { + const { limiter } = makeLimiter([ + { kind: "ip", subject: "203.0.113.7/32", mode: "blocked" }, + ]); + const reply = makeReply(); + expect(await createRateLimitHook(limiter)(request(), reply)).toBe( + reply, + ); + expect(reply.status).toBe(403); + expect(reply.body).toEqual({ error: "Forbidden" }); + }); +}); + +describe("parseTrustProxy", () => { + it("trusts nothing unless told to", () => { + expect(parseTrustProxy(undefined)).toBe(false); + expect(parseTrustProxy("")).toBe(false); + expect(parseTrustProxy("false")).toBe(false); + }); + + it("reads true, a hop count and an address list", () => { + expect(parseTrustProxy("TRUE")).toBe(true); + expect(parseTrustProxy(" 2 ")).toBe(2); + expect(parseTrustProxy("10.0.0.0/8, 127.0.0.1")).toBe( + "10.0.0.0/8, 127.0.0.1", + ); + }); +}); diff --git a/infrastructure/evault-core/src/core/http/global-rate-limiter.ts b/infrastructure/evault-core/src/core/http/global-rate-limiter.ts index 4d7b51f11..64098ba6f 100644 --- a/infrastructure/evault-core/src/core/http/global-rate-limiter.ts +++ b/infrastructure/evault-core/src/core/http/global-rate-limiter.ts @@ -1,78 +1,267 @@ -import { decodeJwt } from "jose"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import type { RateLimitMode } from "../../entities/RateLimitRule"; +import type { RateLimitSnapshot } from "../../services/RateLimitConfigService"; +import { verifyPlatform } from "../auth/registry-token"; +import { normalizeIp } from "./ip-rules"; const WINDOW_MS = 60_000; // 1-minute window -const MAX_REQUESTS_PER_PLATFORM = - Number(process.env.RATE_LIMIT_PER_PLATFORM) || 250; -const MAX_REQUESTS_PER_IP = Number(process.env.RATE_LIMIT_PER_IP) || 500; +const USAGE_ROWS = 200; interface RateRecord { count: number; + rejected: number; windowStart: number; } -const platformRecords = new Map(); -const ipRecords = new Map(); +export type RateLimitDecision = + | { allowed: true } + | { allowed: false; status: 403 } + | { + allowed: false; + status: 429; + scope: "ip" | "platform"; + retryAfterSeconds: number; + }; -function check( - map: Map, - key: string, - limit: number, -): { allowed: boolean; retryAfterSeconds: number } { - const now = Date.now(); - let rec = map.get(key); +export interface UsageRow { + subject: string; + /** Requests seen this window, rejected ones included. */ + count: number; + rejected: number; + limit: number; + mode: RateLimitMode; + /** The rule that set the limit, or null when the default applies. */ + matchedRule: string | null; + resetsInSeconds: number; +} + +export interface RateLimitUsage { + generatedAt: Date; + windowSeconds: number; + platforms: UsageRow[]; + ips: UsageRow[]; + /** True when either list was cut to the busiest {@link USAGE_ROWS}. */ + truncated: boolean; +} + +/** + * Fixed-window request limiter for the public eVault API. + * + * Every request is held to its IP's limit first. A request carrying a valid + * Registry token is then also held to its platform's limit. The Registry + * signs any platform name, so a platform rule can only narrow what a caller + * may do: nothing a token claims lifts the IP ceiling. + * + * Counters live in this process. Several instances each count separately. + */ +export class GlobalRateLimiter { + private readonly platformRecords = new Map(); + private readonly ipRecords = new Map(); + private timer?: NodeJS.Timeout; - if (!rec || now - rec.windowStart > WINDOW_MS) { - rec = { count: 0, windowStart: now }; - map.set(key, rec); + constructor( + private readonly getSnapshot: () => RateLimitSnapshot, + private readonly platformOf: ( + token: string | null, + ) => Promise = verifyPlatform, + private readonly now: () => number = Date.now, + ) {} + + /** Starts the sweep that drops counters whose window has passed. */ + start(): void { + this.timer = setInterval(() => this.sweep(), WINDOW_MS); + this.timer.unref(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = undefined; } - rec.count++; + async check( + method: string, + rawIp: string, + token: string | null, + ): Promise { + // A CORS preflight carries no credentials and does no work. + if (method === "OPTIONS") return { allowed: true }; + + const snapshot = this.getSnapshot(); + const ip = normalizeIp(rawIp); + const ipRule = snapshot.matchIp(ip); + if (ipRule?.mode === "blocked") return { allowed: false, status: 403 }; + if (ipRule?.mode === "exempt") return { allowed: true }; - if (rec.count > limit) { - const retryAfterSeconds = Math.ceil( - (rec.windowStart + WINDOW_MS - now) / 1000, + // The IP is counted before the token is looked at, so an abusive + // caller is turned away without the cost of verifying a signature. + const ipLimit = ipRule?.limitPerMinute ?? snapshot.ipDefault.limit; + const ipResult = this.count(this.ipRecords, ip, ipLimit); + if (ipResult) return { ...ipResult, scope: "ip" }; + + const platform = await this.platformOf(token); + if (!platform) return { allowed: true }; + + // Rules may have been reloaded while the token was being verified. + const current = this.getSnapshot(); + const platformRule = current.platformRules.get(platform); + if (platformRule?.mode === "blocked") { + return { allowed: false, status: 403 }; + } + if (platformRule?.mode === "exempt") return { allowed: true }; + + const platformLimit = + platformRule?.limitPerMinute ?? current.platformDefault.limit; + const platformResult = this.count( + this.platformRecords, + platform, + platformLimit, ); - return { allowed: false, retryAfterSeconds }; + if (platformResult) return { ...platformResult, scope: "platform" }; + return { allowed: true }; } - return { allowed: true, retryAfterSeconds: 0 }; -} + /** Live counters, busiest relative to their limit first. */ + getUsage(): RateLimitUsage { + const snapshot = this.getSnapshot(); + const now = this.now(); + + const rows = ( + records: Map, + resolve: (subject: string) => { + limit: number; + mode: RateLimitMode; + matchedRule: string | null; + }, + ): UsageRow[] => { + const result: UsageRow[] = []; + for (const [subject, rec] of records) { + if (now - rec.windowStart > WINDOW_MS) continue; + result.push({ + subject, + count: rec.count, + rejected: rec.rejected, + ...resolve(subject), + resetsInSeconds: Math.ceil( + (rec.windowStart + WINDOW_MS - now) / 1000, + ), + }); + } + return result.sort( + (a, b) => b.count / b.limit - a.count / a.limit, + ); + }; + + const platforms = rows(this.platformRecords, (platform) => { + const rule = snapshot.platformRules.get(platform); + return { + limit: rule?.limitPerMinute ?? snapshot.platformDefault.limit, + mode: rule?.mode ?? "limit", + matchedRule: rule?.subject ?? null, + }; + }); + const ips = rows(this.ipRecords, (ip) => { + const rule = snapshot.matchIp(ip); + return { + limit: rule?.limitPerMinute ?? snapshot.ipDefault.limit, + mode: rule?.mode ?? "limit", + matchedRule: rule?.subject ?? null, + }; + }); + + return { + generatedAt: new Date(now), + windowSeconds: WINDOW_MS / 1000, + platforms: platforms.slice(0, USAGE_ROWS), + ips: ips.slice(0, USAGE_ROWS), + truncated: + platforms.length > USAGE_ROWS || ips.length > USAGE_ROWS, + }; + } + + private count( + map: Map, + key: string, + limit: number, + ): { allowed: false; status: 429; retryAfterSeconds: number } | null { + const now = this.now(); + let rec = map.get(key); + + if (!rec || now - rec.windowStart > WINDOW_MS) { + rec = { count: 0, rejected: 0, windowStart: now }; + map.set(key, rec); + } + + rec.count++; + + if (rec.count > limit) { + rec.rejected++; + return { + allowed: false, + status: 429, + retryAfterSeconds: Math.ceil( + (rec.windowStart + WINDOW_MS - now) / 1000, + ), + }; + } -function extractPlatform(token: string): string | null { - try { - const payload = decodeJwt(token); - return (payload as any).platform ?? null; - } catch { return null; } -} -export function checkGlobalRateLimit( - token: string | null, - ip: string, -): { allowed: boolean; retryAfterSeconds: number } { - if (token) { - const platform = extractPlatform(token); - if (platform) { - const result = check( - platformRecords, - platform, - MAX_REQUESTS_PER_PLATFORM, - ); - if (!result.allowed) return result; + // Drops stale entries to prevent memory growth + private sweep(): void { + const now = this.now(); + for (const map of [this.platformRecords, this.ipRecords]) { + for (const [key, rec] of map) { + if (now - rec.windowStart > WINDOW_MS) map.delete(key); + } } } +} + +/** The Fastify `onRequest` hook that enforces a limiter. */ +export function createRateLimitHook(limiter: GlobalRateLimiter) { + return async (request: FastifyRequest, reply: FastifyReply) => { + const authHeader = request.headers.authorization; + const token = authHeader?.startsWith("Bearer ") + ? authHeader.substring(7) + : null; + const decision = await limiter.check( + request.method, + request.ip, + token, + ); + if (decision.allowed) return; - return check(ipRecords, ip, MAX_REQUESTS_PER_IP); + // In an async onRequest hook, Fastify only short-circuits the + // handler chain if you return the reply object. Without the + // return, the response is queued but the downstream handler + // (GraphQL) still runs — turning the rate limiter into a silent + // counter. + if (decision.status === 403) { + return reply.code(403).send({ error: "Forbidden" }); + } + return reply + .code(429) + .header("Retry-After", String(decision.retryAfterSeconds)) + .send({ + error: "Too Many Requests", + retryAfterSeconds: decision.retryAfterSeconds, + scope: decision.scope, + }); + }; } -// Periodically clean up stale entries to prevent memory growth -setInterval(() => { - const now = Date.now(); - for (const [key, rec] of platformRecords) { - if (now - rec.windowStart > WINDOW_MS) platformRecords.delete(key); - } - for (const [key, rec] of ipRecords) { - if (now - rec.windowStart > WINDOW_MS) ipRecords.delete(key); - } -}, 60_000); +/** + * Reads `EVAULT_TRUST_PROXY` into Fastify's `trustProxy` option: unset or + * `false` trusts nothing, `true` trusts every hop, a number trusts that many + * hops, and anything else is a comma-separated list of proxy addresses. + */ +export function parseTrustProxy( + value: string | undefined, +): boolean | number | string { + const trimmed = value?.trim() ?? ""; + if (trimmed === "" || trimmed.toLowerCase() === "false") return false; + if (trimmed.toLowerCase() === "true") return true; + if (/^\d+$/.test(trimmed)) return Number(trimmed); + return trimmed; +} diff --git a/infrastructure/evault-core/src/index.ts b/infrastructure/evault-core/src/index.ts index c8b5a909d..db7a0db3b 100644 --- a/infrastructure/evault-core/src/index.ts +++ b/infrastructure/evault-core/src/index.ts @@ -14,7 +14,12 @@ import { ProvisioningService } from "./services/ProvisioningService"; import { VerificationService } from "./services/VerificationService"; import { createHmacSignature } from "./utils/hmac"; -import { checkGlobalRateLimit } from "./core/http/global-rate-limiter"; +import { + GlobalRateLimiter, + createRateLimitHook, + parseTrustProxy, +} from "./core/http/global-rate-limiter"; +import { RateLimitConfigService } from "./services/RateLimitConfigService"; import fastifyCors from "@fastify/cors"; import fastify, { type FastifyInstance, @@ -84,6 +89,8 @@ let driver: Driver; let provisioningService: ProvisioningService | undefined; let awarenessOutboxDispatcher: AwarenessOutboxDispatcher | undefined; let expressServer: HttpServer | undefined; +let rateLimitConfig: RateLimitConfigService | undefined; +let rateLimiter: GlobalRateLimiter | undefined; // Initialize eVault Core const initializeEVault = async ( @@ -213,6 +220,9 @@ const initializeEVault = async ( // base64-encoded size of the largest allowed file (250MB binary ≈ 343MB // base64 + JSON overhead). Keep in sync with MAX_FILE_BYTES in graphql-server.ts. bodyLimit: 350 * 1024 * 1024, + // Off unless a proxy is declared, so a client cannot choose the IP it + // is rate limited as by sending X-Forwarded-For. + trustProxy: parseTrustProxy(process.env.EVAULT_TRUST_PROXY), }); // Register CORS plugin with relaxed settings @@ -229,28 +239,10 @@ const initializeEVault = async ( credentials: true, }); - // Global rate limiting by platform token identity (IP fallback) - fastifyServer.addHook("onRequest", async (request, reply) => { - const authHeader = request.headers.authorization; - const token = authHeader?.startsWith("Bearer ") - ? authHeader.substring(7) - : null; - const ip = request.ip; - const { allowed, retryAfterSeconds } = checkGlobalRateLimit(token, ip); - if (!allowed) { - // In an async onRequest hook, Fastify only short-circuits the - // handler chain if you return the reply object. Without the - // return, the 429 is queued but the downstream handler (GraphQL) - // still runs — turning the rate limiter into a silent counter. - return reply - .code(429) - .header("Retry-After", String(retryAfterSeconds)) - .send({ - error: "Too Many Requests", - retryAfterSeconds, - }); - } - }); + // Rate limiting per IP, then per verified platform, under admin-set rules + if (rateLimiter) { + fastifyServer.addHook("onRequest", createRateLimitHook(rateLimiter)); + } // Register HTTP routes with provisioning service if available await registerHttpRoutes( @@ -440,6 +432,15 @@ const start = async () => { provisioningController.registerRoutes(expressApp); recoveryController.registerRoutes(expressApp); + const { RateLimitRule } = await import("./entities/RateLimitRule"); + rateLimitConfig = new RateLimitConfigService( + AppDataSource.getRepository(RateLimitRule), + ); + await rateLimitConfig.start(); + const config = rateLimitConfig; + rateLimiter = new GlobalRateLimiter(() => config.snapshot); + rateLimiter.start(); + // Start eVault Core (Fastify + GraphQL) with provisioning service first await initializeEVault(provisioningService); @@ -460,6 +461,8 @@ start(); async function shutdown(signal: string): Promise { console.log(`${signal} received, shutting down eVault`); await awarenessOutboxDispatcher?.stop(); + rateLimitConfig?.stop(); + rateLimiter?.stop(); await fastifyServer?.close(); if (expressServer) { await new Promise((resolve) => From b23d0203d5d377fddf6a7d2d6dd8a31ca5e0164b Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 12 Oct 2026 14:37:14 +0800 Subject: [PATCH 4/7] feat(evault): rate limit admin API --- .env.example | 10 +- .../RateLimitAdminController.spec.ts | 215 ++++++++++++++++++ .../controllers/RateLimitAdminController.ts | 121 ++++++++++ infrastructure/evault-core/src/index.ts | 4 + 4 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 infrastructure/evault-core/src/controllers/RateLimitAdminController.spec.ts create mode 100644 infrastructure/evault-core/src/controllers/RateLimitAdminController.ts diff --git a/.env.example b/.env.example index 757161cb7..4b5464c3b 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,17 @@ NEO4J_PASSWORD=your-neo4j-password PUBLIC_EVAULT_SERVER_URI=http://localhost:4000 -# Rate limiting (requests per minute) +# Rate limiting (requests per minute). These are the defaults; limits set from +# the control panel's Rate Limits page override them without a restart. RATE_LIMIT_PER_PLATFORM=250 RATE_LIMIT_PER_IP=500 +# Shared by evault-core and the control panel to manage rate limit rules. The +# admin API is disabled while this is empty. Use a long random value. +RATE_LIMIT_ADMIN_SECRET="" +# Leave empty when clients reach evault-core directly. Behind a reverse proxy, +# set the number of proxy hops (e.g. 1) or the proxies' addresses +# (e.g. "10.0.0.0/8"), or every client is rate limited as the proxy's IP. +EVAULT_TRUST_PROXY="" REGISTRY_ENTROPY_KEY_JWK='{"kty":"EC","use":"sig","alg":"ES256","kid":"entropy-key-1","crv":"P-256","x":"your-x-value","y":"your-y-value","d":"your-d-value"}' ENCRYPTION_PASSWORD="your-encryption-password" diff --git a/infrastructure/evault-core/src/controllers/RateLimitAdminController.spec.ts b/infrastructure/evault-core/src/controllers/RateLimitAdminController.spec.ts new file mode 100644 index 000000000..30ad2c284 --- /dev/null +++ b/infrastructure/evault-core/src/controllers/RateLimitAdminController.spec.ts @@ -0,0 +1,215 @@ +import "reflect-metadata"; +import type { AddressInfo } from "node:net"; +import express from "express"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { GlobalRateLimiter } from "../core/http/global-rate-limiter"; +import type { RateLimitRule } from "../entities/RateLimitRule"; +import { RateLimitConfigService } from "../services/RateLimitConfigService"; +import { RateLimitAdminController } from "./RateLimitAdminController"; + +const SECRET = "test-admin-secret"; +const RULE_ID = "3f1c2a52-6f0b-4d6e-9d57-0d0f3a1b2c3d"; + +function makeRepoStub() { + const rows = new Map(); + return { + async find() { + return [...rows.values()]; + }, + async findOne({ where }: { where: Partial }) { + return ( + [...rows.values()].find( + (r) => + r.kind === where.kind && r.subject === where.subject, + ) ?? null + ); + }, + create(partial: Partial) { + return partial as RateLimitRule; + }, + async save(row: RateLimitRule) { + row.id ??= RULE_ID; + rows.set(row.id, row); + return row; + }, + async delete({ id }: { id: string }) { + return { affected: rows.delete(id) ? 1 : 0 }; + }, + } as any; +} + +describe("RateLimitAdminController", () => { + const previousSecret = process.env.RATE_LIMIT_ADMIN_SECRET; + let server: ReturnType; + let baseUrl: string; + let config: RateLimitConfigService; + let limiter: GlobalRateLimiter; + + const call = ( + path: string, + init: RequestInit & { secret?: string | null } = {}, + ) => { + const { secret = SECRET, ...rest } = init; + return fetch(`${baseUrl}${path}`, { + ...rest, + headers: { + "content-type": "application/json", + ...(secret ? { "x-shared-secret": secret } : {}), + }, + }); + }; + + const put = (body: unknown) => + call("/admin/rate-limits/rules", { + method: "PUT", + body: JSON.stringify(body), + }); + + beforeAll(() => { + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterAll(() => { + vi.restoreAllMocks(); + if (previousSecret === undefined) { + delete process.env.RATE_LIMIT_ADMIN_SECRET; + } else { + process.env.RATE_LIMIT_ADMIN_SECRET = previousSecret; + } + }); + + beforeEach(() => { + process.env.RATE_LIMIT_ADMIN_SECRET = SECRET; + config = new RateLimitConfigService(makeRepoStub()); + limiter = new GlobalRateLimiter( + () => config.snapshot, + async () => null, + ); + const app = express(); + app.use(express.json()); + new RateLimitAdminController(config, limiter).registerRoutes(app); + server = app.listen(0); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it("is disabled while no secret is configured", async () => { + delete process.env.RATE_LIMIT_ADMIN_SECRET; + expect((await call("/admin/rate-limits")).status).toBe(503); + expect((await put({})).status).toBe(503); + }); + + it("rejects a missing or wrong secret on every route", async () => { + for (const secret of [null, "wrong", `${SECRET}x`]) { + expect((await call("/admin/rate-limits", { secret })).status).toBe( + 401, + ); + expect( + (await call("/admin/rate-limits/usage", { secret })).status, + ).toBe(401); + expect( + ( + await call("/admin/rate-limits/rules", { + secret, + method: "PUT", + body: "{}", + }) + ).status, + ).toBe(401); + expect( + ( + await call(`/admin/rate-limits/rules/${RULE_ID}`, { + secret, + method: "DELETE", + }) + ).status, + ).toBe(401); + } + }); + + it("lists defaults and rules", async () => { + const response = await call("/admin/rate-limits"); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + windowSeconds: 60, + config: { source: "env" }, + trustProxy: false, + defaults: { + platform: { limit: 250, source: "env" }, + ip: { limit: 500, source: "env" }, + }, + rules: [], + }); + }); + + it("saves a rule and applies it at once", async () => { + const response = await put({ + kind: "ip", + subject: "203.0.113.7", + mode: "limit", + limitPerMinute: 1, + updatedBy: "@admin", + }); + expect(response.status).toBe(200); + expect((await response.json()).rule).toMatchObject({ + id: RULE_ID, + subject: "203.0.113.7/32", + updatedBy: "@admin", + }); + + await limiter.check("GET", "203.0.113.7", null); + expect((await limiter.check("GET", "203.0.113.7", null)).allowed).toBe( + false, + ); + + const listed = await (await call("/admin/rate-limits")).json(); + expect(listed.rules).toHaveLength(1); + expect(listed.config.source).toBe("db"); + }); + + it("explains an invalid rule", async () => { + const response = await put({ + kind: "ip", + subject: "not-an-ip", + mode: "blocked", + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "Not a valid IP address or CIDR range", + field: "subject", + }); + }); + + it("removes a rule", async () => { + await put({ kind: "platform", subject: "pictique", mode: "blocked" }); + const path = `/admin/rate-limits/rules/${RULE_ID}`; + expect((await call(path, { method: "DELETE" })).status).toBe(204); + expect(config.snapshot.rules).toEqual([]); + expect((await call(path, { method: "DELETE" })).status).toBe(404); + expect( + (await call("/admin/rate-limits/rules/nope", { method: "DELETE" })) + .status, + ).toBe(404); + }); + + it("reports live usage", async () => { + await limiter.check("GET", "203.0.113.7", null); + const usage = await (await call("/admin/rate-limits/usage")).json(); + expect(usage.ips).toMatchObject([ + { subject: "203.0.113.7", count: 1, limit: 500 }, + ]); + expect(usage.platforms).toEqual([]); + }); +}); diff --git a/infrastructure/evault-core/src/controllers/RateLimitAdminController.ts b/infrastructure/evault-core/src/controllers/RateLimitAdminController.ts new file mode 100644 index 000000000..a447e1bcc --- /dev/null +++ b/infrastructure/evault-core/src/controllers/RateLimitAdminController.ts @@ -0,0 +1,121 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import type { Request, Response } from "express"; +import { validate as uuidValidate } from "uuid"; +import { + type GlobalRateLimiter, + parseTrustProxy, +} from "../core/http/global-rate-limiter"; +import { + type RateLimitConfigService, + RuleValidationError, +} from "../services/RateLimitConfigService"; + +function digest(value: string): Buffer { + return createHash("sha256").update(value).digest(); +} + +// Its own secret rather than PROVISIONER_SHARED_SECRET, which the wallet +// carries and is therefore public. +function requireAdminSecret(req: Request, res: Response): boolean { + const secret = process.env.RATE_LIMIT_ADMIN_SECRET; + if (!secret) { + res.status(503).json({ error: "Rate limit admin API disabled" }); + return false; + } + const provided = req.headers["x-shared-secret"]; + if ( + typeof provided !== "string" || + !timingSafeEqual(digest(provided), digest(secret)) + ) { + res.status(401).json({ + error: "Unauthorized: invalid or missing shared secret", + }); + return false; + } + return true; +} + +/** + * Admin API for rate limit rules, served to the control panel. It lives on + * the Express app, which the limiter does not cover, so no rule can lock an + * admin out of removing it. + */ +export class RateLimitAdminController { + constructor( + private readonly config: RateLimitConfigService, + private readonly limiter: GlobalRateLimiter, + ) {} + + registerRoutes(app: any) { + app.get("/admin/rate-limits", (req: Request, res: Response) => { + if (!requireAdminSecret(req, res)) return; + const snapshot = this.config.snapshot; + res.json({ + windowSeconds: 60, + config: { + source: snapshot.source, + loadedAt: snapshot.loadedAt, + }, + trustProxy: parseTrustProxy(process.env.EVAULT_TRUST_PROXY), + defaults: { + platform: snapshot.platformDefault, + ip: snapshot.ipDefault, + }, + rules: snapshot.rules, + }); + }); + + app.get("/admin/rate-limits/usage", (req: Request, res: Response) => { + if (!requireAdminSecret(req, res)) return; + res.json(this.limiter.getUsage()); + }); + + app.put( + "/admin/rate-limits/rules", + async (req: Request, res: Response) => { + if (!requireAdminSecret(req, res)) return; + try { + const rule = await this.config.upsert(req.body ?? {}); + console.log( + `[rate-limit] ${rule.updatedBy ?? "unknown"} set ${rule.kind} ${rule.subject} to ${rule.mode}${rule.mode === "limit" ? ` ${rule.limitPerMinute}/min` : ""}`, + ); + res.json({ rule }); + } catch (error) { + if (error instanceof RuleValidationError) { + return res + .status(400) + .json({ error: error.message, field: error.field }); + } + console.error("[rate-limit] could not save rule:", error); + res.status(500).json({ error: "Could not save the rule" }); + } + }, + ); + + app.delete( + "/admin/rate-limits/rules/:id", + async (req: Request, res: Response) => { + if (!requireAdminSecret(req, res)) return; + try { + const removed = + uuidValidate(req.params.id) && + (await this.config.remove(req.params.id)); + if (!removed) { + return res + .status(404) + .json({ error: "Rule not found" }); + } + console.log( + `[rate-limit] ${req.header("x-admin-ename") ?? "unknown"} removed rule ${req.params.id}`, + ); + res.status(204).end(); + } catch (error) { + console.error("[rate-limit] could not remove rule:", error); + res.status(500).json({ + error: "Could not remove the rule", + }); + } + }, + ); + } +} diff --git a/infrastructure/evault-core/src/index.ts b/infrastructure/evault-core/src/index.ts index db7a0db3b..b4dce9e5b 100644 --- a/infrastructure/evault-core/src/index.ts +++ b/infrastructure/evault-core/src/index.ts @@ -9,6 +9,7 @@ import { NotificationController } from "./controllers/NotificationController"; import { ProvisioningController } from "./controllers/ProvisioningController"; import { VerificationController } from "./controllers/VerificationController"; import { LegacyVerificationController } from "./controllers/LegacyVerificationController"; +import { RateLimitAdminController } from "./controllers/RateLimitAdminController"; import { RecoveryController } from "./controllers/RecoveryController"; import { ProvisioningService } from "./services/ProvisioningService"; import { VerificationService } from "./services/VerificationService"; @@ -440,6 +441,9 @@ const start = async () => { const config = rateLimitConfig; rateLimiter = new GlobalRateLimiter(() => config.snapshot); rateLimiter.start(); + new RateLimitAdminController(config, rateLimiter).registerRoutes( + expressApp, + ); // Start eVault Core (Fastify + GraphQL) with provisioning service first await initializeEVault(provisioningService); From 68f38c2e68dd83a7057b95d93f4f4ccc297ca0ea Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 12 Oct 2026 14:43:01 +0800 Subject: [PATCH 5/7] feat(control-panel): rate limits page --- .../src/lib/server/rate-limits.ts | 163 ++++ .../control-panel/src/routes/+layout.svelte | 1 + .../src/routes/api/rate-limits/+server.ts | 21 + .../routes/api/rate-limits/rules/+server.ts | 16 + .../api/rate-limits/rules/[id]/+server.ts | 12 + .../routes/api/rate-limits/usage/+server.ts | 12 + .../src/routes/rate-limits/+page.svelte | 775 ++++++++++++++++++ 7 files changed, 1000 insertions(+) create mode 100644 infrastructure/control-panel/src/lib/server/rate-limits.ts create mode 100644 infrastructure/control-panel/src/routes/api/rate-limits/+server.ts create mode 100644 infrastructure/control-panel/src/routes/api/rate-limits/rules/+server.ts create mode 100644 infrastructure/control-panel/src/routes/api/rate-limits/rules/[id]/+server.ts create mode 100644 infrastructure/control-panel/src/routes/api/rate-limits/usage/+server.ts create mode 100644 infrastructure/control-panel/src/routes/rate-limits/+page.svelte diff --git a/infrastructure/control-panel/src/lib/server/rate-limits.ts b/infrastructure/control-panel/src/lib/server/rate-limits.ts new file mode 100644 index 000000000..f74717567 --- /dev/null +++ b/infrastructure/control-panel/src/lib/server/rate-limits.ts @@ -0,0 +1,163 @@ +import { env } from '$env/dynamic/private'; +import { PUBLIC_PROVISIONER_URL, PUBLIC_REGISTRY_URL } from '$env/static/public'; +import { isAdminEName } from '$lib/server/auth/allowlist'; +import { error } from '@sveltejs/kit'; + +export type RateLimitKind = 'platform' | 'ip'; +export type RateLimitMode = 'limit' | 'blocked' | 'exempt'; +export type LimitSource = 'db' | 'env'; + +export interface RateLimitRule { + id: string; + kind: RateLimitKind; + subject: string; + mode: RateLimitMode; + limitPerMinute: number | null; + note: string | null; + updatedBy: string | null; + updatedAt: string; +} + +export interface RateLimitConfig { + windowSeconds: number; + config: { source: LimitSource; loadedAt: string }; + trustProxy: boolean | number | string; + defaults: { + platform: { limit: number; source: LimitSource }; + ip: { limit: number; source: LimitSource }; + }; + rules: RateLimitRule[]; +} + +export interface UsageRow { + subject: string; + count: number; + rejected: number; + limit: number; + mode: RateLimitMode; + matchedRule: string | null; + resetsInSeconds: number; +} + +export interface RateLimitUsage { + generatedAt: string; + windowSeconds: number; + platforms: UsageRow[]; + ips: UsageRow[]; + truncated: boolean; +} + +/** An evault-core answer worth showing the admin as it is. */ +export class RateLimitApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly field?: string + ) { + super(message); + } +} + +/** + * The signed-in admin's eName. The allowlist is re-read here because the + * session cookie outlives a removal from it. + */ +export async function requireAdmin(locals: App.Locals): Promise { + const ename = locals.user?.ename; + if (!ename || !(await isAdminEName(ename))) { + throw error(403, 'Not an admin'); + } + return ename; +} + +async function call(path: string, init: RequestInit = {}): Promise { + const secret = env.RATE_LIMIT_ADMIN_SECRET; + if (!secret) { + throw new RateLimitApiError( + 'RATE_LIMIT_ADMIN_SECRET is not set for the control panel', + 503 + ); + } + const baseUrl = PUBLIC_PROVISIONER_URL || 'http://localhost:3001'; + + let response: Response; + try { + response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + 'x-shared-secret': secret, + ...init.headers + }, + signal: AbortSignal.timeout(10000) + }); + } catch { + throw new RateLimitApiError('Could not reach evault-core', 502); + } + + if (response.ok) return response; + if (response.status === 401) { + throw new RateLimitApiError( + 'evault-core rejected RATE_LIMIT_ADMIN_SECRET; it must match on both services', + 502 + ); + } + if (response.status === 503) { + throw new RateLimitApiError('RATE_LIMIT_ADMIN_SECRET is not set for evault-core', 503); + } + const body = await response.json().catch(() => null); + throw new RateLimitApiError( + body?.error ?? `evault-core answered ${response.status}`, + response.status, + body?.field + ); +} + +export async function getRateLimitConfig(): Promise { + return (await call('/admin/rate-limits')).json(); +} + +export async function getRateLimitUsage(): Promise { + return (await call('/admin/rate-limits/usage')).json(); +} + +export async function saveRateLimitRule( + rule: Record, + updatedBy: string +): Promise { + const response = await call('/admin/rate-limits/rules', { + method: 'PUT', + body: JSON.stringify({ ...rule, updatedBy }) + }); + return (await response.json()).rule; +} + +export async function deleteRateLimitRule(id: string, removedBy: string): Promise { + await call(`/admin/rate-limits/rules/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: { 'x-admin-ename': removedBy } + }); +} + +/** Platform names the Registry lists, to offer before they have traffic. */ +export async function getKnownPlatforms(): Promise { + if (!PUBLIC_REGISTRY_URL) return []; + try { + const response = await fetch(new URL('/platforms', PUBLIC_REGISTRY_URL), { + signal: AbortSignal.timeout(5000) + }); + if (!response.ok) return []; + const platforms: unknown[] = await response.json(); + return platforms.filter((p): p is string => typeof p === 'string' && p.trim() !== ''); + } catch { + return []; + } +} + +/** The JSON answer for a failed call, keeping evault-core's own message. */ +export function errorResponse(err: unknown): Response { + if (err instanceof RateLimitApiError) { + return Response.json({ error: err.message, field: err.field }, { status: err.status }); + } + throw err; +} diff --git a/infrastructure/control-panel/src/routes/+layout.svelte b/infrastructure/control-panel/src/routes/+layout.svelte index 9e6115575..ee0975ffc 100644 --- a/infrastructure/control-panel/src/routes/+layout.svelte +++ b/infrastructure/control-panel/src/routes/+layout.svelte @@ -16,6 +16,7 @@ { label: 'Monitoring', href: '/monitoring' }, { label: 'Actions', href: '/actions' }, { label: 'Notifications', href: '/notifications' }, + { label: 'Rate Limits', href: '/rate-limits' }, { label: 'Visualizer', href: '/visualizer' } ]; diff --git a/infrastructure/control-panel/src/routes/api/rate-limits/+server.ts b/infrastructure/control-panel/src/routes/api/rate-limits/+server.ts new file mode 100644 index 000000000..6fc254a01 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/rate-limits/+server.ts @@ -0,0 +1,21 @@ +import { + errorResponse, + getKnownPlatforms, + getRateLimitConfig, + requireAdmin +} from '$lib/server/rate-limits'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; + +export const GET: RequestHandler = async ({ locals }) => { + await requireAdmin(locals); + try { + const [config, knownPlatforms] = await Promise.all([ + getRateLimitConfig(), + getKnownPlatforms() + ]); + return json({ ...config, knownPlatforms }); + } catch (err) { + return errorResponse(err); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/rate-limits/rules/+server.ts b/infrastructure/control-panel/src/routes/api/rate-limits/rules/+server.ts new file mode 100644 index 000000000..b434b224a --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/rate-limits/rules/+server.ts @@ -0,0 +1,16 @@ +import { errorResponse, requireAdmin, saveRateLimitRule } from '$lib/server/rate-limits'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; + +export const PUT: RequestHandler = async ({ locals, request }) => { + const admin = await requireAdmin(locals); + const body = await request.json().catch(() => null); + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return json({ error: 'Expected a JSON object' }, { status: 400 }); + } + try { + return json({ rule: await saveRateLimitRule(body, admin) }); + } catch (err) { + return errorResponse(err); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/rate-limits/rules/[id]/+server.ts b/infrastructure/control-panel/src/routes/api/rate-limits/rules/[id]/+server.ts new file mode 100644 index 000000000..07acd48d0 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/rate-limits/rules/[id]/+server.ts @@ -0,0 +1,12 @@ +import { deleteRateLimitRule, errorResponse, requireAdmin } from '$lib/server/rate-limits'; +import type { RequestHandler } from '@sveltejs/kit'; + +export const DELETE: RequestHandler = async ({ locals, params }) => { + const admin = await requireAdmin(locals); + try { + await deleteRateLimitRule(params.id ?? '', admin); + return new Response(null, { status: 204 }); + } catch (err) { + return errorResponse(err); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/rate-limits/usage/+server.ts b/infrastructure/control-panel/src/routes/api/rate-limits/usage/+server.ts new file mode 100644 index 000000000..9d505a73b --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/rate-limits/usage/+server.ts @@ -0,0 +1,12 @@ +import { errorResponse, getRateLimitUsage, requireAdmin } from '$lib/server/rate-limits'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; + +export const GET: RequestHandler = async ({ locals }) => { + await requireAdmin(locals); + try { + return json(await getRateLimitUsage()); + } catch (err) { + return errorResponse(err); + } +}; diff --git a/infrastructure/control-panel/src/routes/rate-limits/+page.svelte b/infrastructure/control-panel/src/routes/rate-limits/+page.svelte new file mode 100644 index 000000000..88ee150b6 --- /dev/null +++ b/infrastructure/control-panel/src/routes/rate-limits/+page.svelte @@ -0,0 +1,775 @@ + + +{#snippet usageCell(row: UsageRow | undefined)} + {#if row} + row.limit ? 'text-danger-500 font-semibold' : ''}> + {row.count} / {row.limit} + + {#if row.rejected > 0} + {row.rejected} rejected + {/if} + {:else} + — + {/if} +{/snippet} + +{#snippet modeBadge(mode: RateLimitMode, label: string)} + + {label} + +{/snippet} + +
+
+

Rate Limits

+

+ Requests to the eVault API are limited per IP, then per platform. Changes apply + immediately, without a restart. +

+ {#if loadError} +

+ {loadError} +

+ {:else if config} +
+ + Rules: {config.config.source === 'db' + ? 'loaded from the database' + : 'database unavailable, using env defaults'} + + Loaded: {new Date(config.config.loadedAt).toLocaleTimeString()} + Window: {config.windowSeconds}s + Proxy trust: {trustProxyLabel(config.trustProxy)} +
+

+ Platform names are self-declared, so a platform rule never lifts a caller above its + IP limit. To give a platform more headroom, also raise the limit on its server IPs. + Counters are kept per evault-core instance. +

+ {/if} +
+ + {#if config} +
+

Defaults

+

+ Requests per minute for any platform or IP without its own rule. +

+
+ {#each ['platform', 'ip'] as const as kind (kind)} +
+ +
+ {#if kind === 'platform'} + + {:else} + + {/if} + submitDefault(kind)} + > + Save + + {#if config.defaults[kind].source === 'db'} + resetDefault(kind)} + > + Reset to env + + {/if} +
+
+ {/each} +
+
+ +
+

Add or change a rule

+

+ Saving a rule for a subject that already has one replaces it. The most specific IP + range wins. +

+
+
+ + +
+
+ + +
+
+ + +
+ {#if formMode === 'limit'} +
+ + +
+ {/if} +
+ + +
+
+
+ + {formMode === 'limit' + ? 'Save rule' + : formMode === 'blocked' + ? 'Block…' + : 'Exempt…'} + +
+
+ +
+

Platforms

+

+ Platforms with a rule, with traffic in the current window, or listed by the + Registry. +

+
+ + + + + + + + + + + {#each platformRows as row (row.subject)} + + + + + + + {:else} + + + + {/each} + +
PlatformLimitThis window
+ {row.subject} + {#if row.rule?.note} +

{row.rule.note}

+ {/if} +
+ {@render modeBadge( + row.rule?.mode ?? 'limit', + ruleLabel(row.rule, 'platform') + )} + + {@render usageCell(row.usage)} + + + {#if row.rule} + + {/if} +
+ No platforms seen yet. +
+
+
+ +
+

IP rules

+
+ + + + + + + + + + + + {#each ipRules as rule (rule.id)} + + + + + + + + {:else} + + + + {/each} + +
IP or rangeLimitNoteChanged by
{rule.subject} + {@render modeBadge(rule.mode, ruleLabel(rule, 'ip'))} + {rule.note ?? ''} + {rule.updatedBy ?? ''} + + + +
+ No IP rules. Every IP gets the default. +
+
+
+ +
+

Live IPs

+

+ Callers in the current window, closest to their limit first. Blocked and exempt IPs + are not counted. + {#if usage?.truncated} + Showing the busiest 200. + {/if} +

+
+ + + + + + + + + + + + {#each usage?.ips ?? [] as row (row.subject)} + + + + + + + + {:else} + + + + {/each} + +
IPThis windowRuleResets in
{row.subject} + {@render usageCell(row)} + + {row.matchedRule ?? 'default'} + {row.resetsInSeconds}s + + +
+ No requests in the current window. +
+
+
+ {/if} +
+ +{#if confirmation} + +{/if} + +{#if toast} +
+

{toast.message}

+
+{/if} From 89e59ff8265ed3583175ea9d4237c47957e2bd0e Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 12 Oct 2026 14:43:32 +0800 Subject: [PATCH 6/7] docs(evault): rate limiting --- docs/docs/Infrastructure/eVault.md | 62 ++++++++++++++++++++++++++ infrastructure/control-panel/README.md | 7 +++ 2 files changed, 69 insertions(+) diff --git a/docs/docs/Infrastructure/eVault.md b/docs/docs/Infrastructure/eVault.md index 76a3524c5..64df0eb9b 100644 --- a/docs/docs/Infrastructure/eVault.md +++ b/docs/docs/Infrastructure/eVault.md @@ -814,6 +814,68 @@ The provisioning layer supports shared tenancy (multiple W3IDs can be provisione - **Isolation**: All queries filtered by W3ID - **No Cross-Tenant Access**: Users can only access their own data (unless ACL allows) +## Rate Limiting + +The GraphQL and HTTP API (port 4000) is rate limited per minute, first per client IP and then per calling platform. Limits are managed from the control panel's **Rate Limits** page and apply immediately, without a restart. + +### How a request is decided + +1. A CORS preflight (`OPTIONS`) is always allowed and never counted. +2. The most specific IP rule covering the client decides first: a **blocked** IP gets `403`, an **exempt** IP skips every limit. +3. Otherwise the request counts against the IP's limit: its own rule, or the IP default. +4. If the request carries a valid Registry token, it also counts against that platform's limit: its own rule, or the platform default. A **blocked** platform gets `403`; an **exempt** platform skips only this step. + +A request over a limit gets `429` with a `Retry-After` header: + +```json +{ "error": "Too Many Requests", "retryAfterSeconds": 42, "scope": "platform" } +``` + +`scope` is `ip` or `platform`, naming the limit that was hit. + +### Rules + +| Applies to | Subject | Actions | +| --- | --- | --- | +| IP | A single address or a CIDR range, IPv4 or IPv6 | Set limit, block, exempt | +| Platform | The `platform` value of its Registry token, matched exactly | Set limit, block, exempt | +| Default | Every IP or platform without its own rule | Set limit | + +A limit on a range gives each address in it that limit; it is not one shared budget. Block and exempt rules are refused for ranges broader than `/8` (IPv4) or `/32` (IPv6). + +### What a platform rule can and cannot do + +The Registry signs a token for any platform name it is asked for, so a token proves it is genuine, not who sent it. The limiter verifies the signature and treats an invalid token as no platform at all, but the name itself is self-declared. Two consequences: + +- **The IP limit is the ceiling.** A platform limit or exemption never lets a caller exceed its IP limit. To give a platform more headroom, raise or exempt the IPs of its servers as well. +- **A platform block is advisory.** A blocked caller can request a token under another name. Block its IPs to stop it. Likewise, anyone can send a token carrying another platform's name and use up that platform's budget, up to their own IP limit. + +### Configuration + +| Variable | Purpose | +| --- | --- | +| `RATE_LIMIT_PER_IP` | Default requests per minute per IP (500) until a default is set in the control panel | +| `RATE_LIMIT_PER_PLATFORM` | Default requests per minute per platform (250) until a default is set in the control panel | +| `RATE_LIMIT_ADMIN_SECRET` | Shared with the control panel to manage rules. The admin API is disabled while it is empty | +| `EVAULT_TRUST_PROXY` | Leave empty when clients connect directly. Behind a reverse proxy, the number of proxy hops or the proxies' addresses | + +Rules are stored in the `evault_rate_limit_rule` table, so run `pnpm --filter evault-core migration:run` when deploying. If the table or the database is unavailable, the limiter keeps the last rules it loaded, or the defaults above if it never loaded any; it never refuses all traffic. + +Behind a reverse proxy, set `EVAULT_TRUST_PROXY` or every client is counted as the proxy's IP. Prefer a hop count or the proxy's address over `true`, which lets any client choose the IP it is counted as by sending `X-Forwarded-For`. + +Counters are kept in memory by each eVault process. With several instances behind a load balancer each one counts separately, so the effective limit is the configured value times the number of instances, and the control panel shows the counters of the instance it reached. Rule changes reach every instance within 30 seconds. + +### Admin API + +The control panel manages rules through the provisioning port (3001), which is not rate limited, so no rule can lock an admin out. Every call needs the `x-shared-secret` header set to `RATE_LIMIT_ADMIN_SECRET`. + +| Route | Purpose | +| --- | --- | +| `GET /admin/rate-limits` | Defaults, rules and where they were loaded from | +| `GET /admin/rate-limits/usage` | Live counters per platform and IP for the current window | +| `PUT /admin/rate-limits/rules` | Create or replace the rule for a subject: `{ kind, subject, mode, limitPerMinute?, note? }` with `kind` `ip` or `platform`, `mode` `limit`, `blocked` or `exempt`, and subject `*` for a default | +| `DELETE /admin/rate-limits/rules/:id` | Remove a rule | + ## API Examples diff --git a/infrastructure/control-panel/README.md b/infrastructure/control-panel/README.md index 0a05758ae..47f142710 100644 --- a/infrastructure/control-panel/README.md +++ b/infrastructure/control-panel/README.md @@ -19,6 +19,13 @@ A SvelteKit-based control panel for monitoring and managing various services and - **Static admin allowlist**: Access is granted only if the authenticated eName exists in `config/admin-enames.json` - **No local DB**: Admin authorization is file-based and reloaded from disk when changed +### Rate Limits + +- **Per-platform and per-IP limits**: `/rate-limits` sets the eVault API's requests-per-minute limits per platform and per IP or CIDR range, and the defaults for everyone else +- **Block and exempt**: An IP, range or platform can be blocked or exempted +- **Live usage**: Shows the callers closest to their limit in the current window +- **Setup**: Set `RATE_LIMIT_ADMIN_SECRET` to the same value for the control panel and evault-core; `PUBLIC_PROVISIONER_URL` must point at evault-core's provisioning port + ## Prerequisites ### Kubernetes Access From b81eef48c14e2586cfa4bf6d90ec32178dfcc6f5 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 12 Oct 2026 14:59:38 +0800 Subject: [PATCH 7/7] fix(evault): keep a rejected rule draft, refuse unmatchable mapped ranges and tighten proxy trust guidance --- .env.example | 5 +++-- docs/docs/Infrastructure/eVault.md | 4 ++-- .../src/routes/rate-limits/+page.svelte | 15 +++++++++++---- .../src/core/http/global-rate-limiter.ts | 4 ++++ .../evault-core/src/core/http/ip-rules.spec.ts | 10 +++++++++- .../evault-core/src/core/http/ip-rules.ts | 10 ++++++++-- 6 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 4b5464c3b..c97d0a969 100644 --- a/.env.example +++ b/.env.example @@ -13,8 +13,9 @@ RATE_LIMIT_PER_IP=500 # admin API is disabled while this is empty. Use a long random value. RATE_LIMIT_ADMIN_SECRET="" # Leave empty when clients reach evault-core directly. Behind a reverse proxy, -# set the number of proxy hops (e.g. 1) or the proxies' addresses -# (e.g. "10.0.0.0/8"), or every client is rate limited as the proxy's IP. +# set the proxies' addresses (e.g. "10.0.0.5" or "10.0.0.0/8"), or every client +# is rate limited as the proxy's IP. A hop count (e.g. 1) also works, but only +# if evault-core cannot be reached except through the proxy. EVAULT_TRUST_PROXY="" REGISTRY_ENTROPY_KEY_JWK='{"kty":"EC","use":"sig","alg":"ES256","kid":"entropy-key-1","crv":"P-256","x":"your-x-value","y":"your-y-value","d":"your-d-value"}' diff --git a/docs/docs/Infrastructure/eVault.md b/docs/docs/Infrastructure/eVault.md index 64df0eb9b..7476907ed 100644 --- a/docs/docs/Infrastructure/eVault.md +++ b/docs/docs/Infrastructure/eVault.md @@ -857,11 +857,11 @@ The Registry signs a token for any platform name it is asked for, so a token pro | `RATE_LIMIT_PER_IP` | Default requests per minute per IP (500) until a default is set in the control panel | | `RATE_LIMIT_PER_PLATFORM` | Default requests per minute per platform (250) until a default is set in the control panel | | `RATE_LIMIT_ADMIN_SECRET` | Shared with the control panel to manage rules. The admin API is disabled while it is empty | -| `EVAULT_TRUST_PROXY` | Leave empty when clients connect directly. Behind a reverse proxy, the number of proxy hops or the proxies' addresses | +| `EVAULT_TRUST_PROXY` | Leave empty when clients connect directly. Behind a reverse proxy, the proxies' addresses or CIDR ranges | Rules are stored in the `evault_rate_limit_rule` table, so run `pnpm --filter evault-core migration:run` when deploying. If the table or the database is unavailable, the limiter keeps the last rules it loaded, or the defaults above if it never loaded any; it never refuses all traffic. -Behind a reverse proxy, set `EVAULT_TRUST_PROXY` or every client is counted as the proxy's IP. Prefer a hop count or the proxy's address over `true`, which lets any client choose the IP it is counted as by sending `X-Forwarded-For`. +Behind a reverse proxy, set `EVAULT_TRUST_PROXY` to the proxies' addresses or every client is counted as the proxy's IP. A hop count (such as `1`) or `true` is also accepted, but both believe `X-Forwarded-For` from whoever connects: if the eVault port can be reached without going through the proxy, a client can choose the IP it is counted as and sidestep every IP limit and block. Counters are kept in memory by each eVault process. With several instances behind a load balancer each one counts separately, so the effective limit is the configured value times the number of instances, and the control panel shows the counters of the instance it reached. Rule changes reach every instance within 30 seconds. diff --git a/infrastructure/control-panel/src/routes/rate-limits/+page.svelte b/infrastructure/control-panel/src/routes/rate-limits/+page.svelte index 88ee150b6..1ef0b36bb 100644 --- a/infrastructure/control-panel/src/routes/rate-limits/+page.svelte +++ b/infrastructure/control-panel/src/routes/rate-limits/+page.svelte @@ -115,7 +115,8 @@ } } - async function saveRule(draft: RuleDraft) { + // Resolves to whether the rule was saved, so a rejected draft is kept. + async function saveRule(draft: RuleDraft): Promise { try { await readJson( await fetch('/api/rate-limits/rules', { @@ -126,8 +127,10 @@ ); showToast(`Saved ${describe(draft)}`, 'success'); await Promise.all([loadConfig(), loadUsage()]); + return true; } catch (err) { showToast(err instanceof Error ? err.message : 'Could not save', 'error'); + return false; } } @@ -200,7 +203,7 @@ lines: [ `Every ${kind} without its own rule drops from ${current} to ${limit} requests per minute, effective immediately.` ], - action: () => saveRule(draft) + action: async () => void (await saveRule(draft)) }); } else { void saveRule(draft); @@ -238,7 +241,7 @@ ...(formNote.trim() && { note: formNote.trim() }) }; if (formMode === 'limit') { - void saveRule(draft).then(clearForm); + void saveAndClear(draft); return; } @@ -268,10 +271,14 @@ title: `${formMode === 'blocked' ? 'Block' : 'Exempt'} ${subject}?`, lines, typed: subject, - action: () => saveRule(draft).then(clearForm) + action: () => saveAndClear(draft) }); } + async function saveAndClear(draft: RuleDraft) { + if (await saveRule(draft)) clearForm(); + } + function clearForm() { formSubject = ''; formNote = ''; diff --git a/infrastructure/evault-core/src/core/http/global-rate-limiter.ts b/infrastructure/evault-core/src/core/http/global-rate-limiter.ts index 64098ba6f..399c06ad5 100644 --- a/infrastructure/evault-core/src/core/http/global-rate-limiter.ts +++ b/infrastructure/evault-core/src/core/http/global-rate-limiter.ts @@ -255,6 +255,10 @@ export function createRateLimitHook(limiter: GlobalRateLimiter) { * Reads `EVAULT_TRUST_PROXY` into Fastify's `trustProxy` option: unset or * `false` trusts nothing, `true` trusts every hop, a number trusts that many * hops, and anything else is a comma-separated list of proxy addresses. + * + * Only an address list checks who is speaking. `true` and a hop count + * believe X-Forwarded-For from any peer, so a client that can reach this + * port without going through the proxy picks the IP it is limited as. */ export function parseTrustProxy( value: string | undefined, diff --git a/infrastructure/evault-core/src/core/http/ip-rules.spec.ts b/infrastructure/evault-core/src/core/http/ip-rules.spec.ts index 78d8ca470..41ac3e78c 100644 --- a/infrastructure/evault-core/src/core/http/ip-rules.spec.ts +++ b/infrastructure/evault-core/src/core/http/ip-rules.spec.ts @@ -45,7 +45,15 @@ describe("ip-rules", () => { }); it("rejects invalid input", () => { - for (const bad of ["", "nope", "10", "1.2.3.4/33", "1.2.3/24", "*"]) { + for (const bad of [ + "", + "nope", + "10", + "1.2.3.4/33", + "1.2.3/24", + "*", + "::ffff:0:0/95", + ]) { expect(() => canonicalizeSubject(bad)).toThrow(); } }); diff --git a/infrastructure/evault-core/src/core/http/ip-rules.ts b/infrastructure/evault-core/src/core/http/ip-rules.ts index d3a613384..c3f36fd4f 100644 --- a/infrastructure/evault-core/src/core/http/ip-rules.ts +++ b/infrastructure/evault-core/src/core/http/ip-rules.ts @@ -46,9 +46,15 @@ export function canonicalizeSubject(input: string): string { } if ( address.kind() === "ipv6" && - (address as ipaddr.IPv6).isIPv4MappedAddress() && - prefix >= 96 + (address as ipaddr.IPv6).isIPv4MappedAddress() ) { + // Clients in the mapped block are matched as IPv4, so a range + // reaching beyond it could never apply to them. + if (prefix < 96) { + throw new Error( + "An IPv4-mapped range must be /96 or narrower; enter it as IPv4", + ); + } address = (address as ipaddr.IPv6).toIPv4Address(); prefix -= 96; }