From 080f071febbdb10dc5e52d586300bee540ec64dd Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Fri, 18 Sep 2026 09:43:46 +0000 Subject: [PATCH] Add application-wide rate limiting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/package.json | 1 + apps/server/src/app.ts | 8 + .../src/modules/security/rate-limit.test.ts | 190 ++++++++++++++++++ .../server/src/modules/security/rate-limit.ts | 64 ++++++ apps/web/src/App.tsx | 36 +++- apps/web/src/api/_client.test.ts | 75 +++++++ apps/web/src/api/_client.ts | 62 +++++- apps/web/src/api/agent.ts | 22 +- apps/web/src/i18n/resources/en/common.json | 3 +- apps/web/src/i18n/resources/zh-CN/common.json | 3 +- apps/web/src/store/canvasSyncStore.ts | 17 +- docs/architecture/deployment-security.md | 13 +- pnpm-lock.yaml | 24 +++ 13 files changed, 497 insertions(+), 21 deletions(-) create mode 100644 apps/server/src/modules/security/rate-limit.test.ts create mode 100644 apps/server/src/modules/security/rate-limit.ts create mode 100644 apps/web/src/api/_client.test.ts diff --git a/apps/server/package.json b/apps/server/package.json index 02140a413..b880d73f0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -36,6 +36,7 @@ "@fastify/compress": "^8.3.1", "@fastify/cors": "^10.0.1", "@fastify/multipart": "^9.0.1", + "@fastify/rate-limit": "^11.2.0", "@fastify/static": "^8.0.2", "@huabu/shared": "workspace:*", "@mozilla/readability": "^0.6.0", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 31f32b7fa..81590a723 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -8,6 +8,7 @@ import { join } from 'node:path'; import compress from '@fastify/compress'; import cors from '@fastify/cors'; import multipart from '@fastify/multipart'; +import rateLimit from '@fastify/rate-limit'; import staticPlugin from '@fastify/static'; import { fastify, type FastifyBaseLogger } from 'fastify'; @@ -57,6 +58,7 @@ import { originGuardPlugin, resolveAllowedHostnames, } from './modules/security/index.js'; +import { createApplicationRateLimitOptions } from './modules/security/rate-limit.js'; import { closeStorage } from './modules/storage/index.js'; import webRoutes from './modules/web/web.route.js'; import { @@ -143,6 +145,12 @@ app.register(cors, { app.register(hostGuardPlugin); app.register(originGuardPlugin); +// Bound request admission independently from authentication. The limiter keys +// the direct TCP peer and deliberately ignores forwarding headers; supported +// reverse proxies therefore share one conservative bucket until Huabu defines +// an explicit trusted-proxy contract. +app.register(rateLimit, createApplicationRateLimitOptions()); + // Register multipart for file uploads. // The file-size ceiling is shared with `bodyLimit` above and tunable via // `HUABU_MAX_UPLOAD_BYTES`; canvas imports bundle their `.artifacts/` dir diff --git a/apps/server/src/modules/security/rate-limit.test.ts b/apps/server/src/modules/security/rate-limit.test.ts new file mode 100644 index 000000000..70ad8d15f --- /dev/null +++ b/apps/server/src/modules/security/rate-limit.test.ts @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import rateLimit from '@fastify/rate-limit'; +import { fastify, type FastifyInstance } from 'fastify'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createApplicationRateLimitOptions } from './rate-limit.js'; + +const openApps: FastifyInstance[] = []; + +async function buildApp(max = 2, timeWindow = 60_000) { + const app = fastify(); + openApps.push(app); + await app.register( + rateLimit, + createApplicationRateLimitOptions({ max, timeWindow }), + ); + app.get('/protected', async () => ({ ok: true })); + app.get('/api/deployment/readiness', async () => ({ ready: true })); + app.options('/protected', async (_request, reply) => reply.code(204).send()); + app.get('/stream', async (_request, reply) => + reply + .type('text/event-stream') + .send(': connected\n\nevent: update\ndata: {}\n\n: ping\n\n'), + ); + await app.ready(); + return app; +} + +afterEach(async () => { + vi.useRealTimers(); + await Promise.all(openApps.splice(0).map((app) => app.close())); +}); + +describe('application-wide rate limiting', () => { + it('returns the canonical 429 body and standard retry headers', async () => { + const app = await buildApp(1); + await app.inject({ method: 'GET', url: '/protected' }); + + const response = await app.inject({ method: 'GET', url: '/protected' }); + + expect(response.statusCode).toBe(429); + expect(response.json()).toMatchObject({ + code: 'RATE_LIMITED', + details: { retryAfterSeconds: 60 }, + }); + expect(response.headers).toMatchObject({ + 'retry-after': '60', + 'x-ratelimit-limit': '1', + 'x-ratelimit-remaining': '0', + }); + }); + + it('resets admission after the configured window', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-18T00:00:00Z')); + const app = await buildApp(1, 1_000); + await app.inject({ method: 'GET', url: '/protected' }); + expect( + (await app.inject({ method: 'GET', url: '/protected' })).statusCode, + ).toBe(429); + + vi.setSystemTime(new Date('2026-09-18T00:00:01Z')); + + expect( + (await app.inject({ method: 'GET', url: '/protected' })).statusCode, + ).toBe(200); + }); + + it('exempts only preflight and deployment readiness requests', async () => { + const app = await buildApp(1); + for (let index = 0; index < 3; index += 1) { + expect( + ( + await app.inject({ + method: 'GET', + url: '/api/deployment/readiness', + }) + ).statusCode, + ).toBe(200); + expect( + (await app.inject({ method: 'OPTIONS', url: '/protected' })).statusCode, + ).toBe(204); + } + + expect( + (await app.inject({ method: 'GET', url: '/protected' })).statusCode, + ).toBe(200); + expect( + (await app.inject({ method: 'GET', url: '/protected' })).statusCode, + ).toBe(429); + }); + + it('shares a direct-peer bucket across auth mechanisms', async () => { + const app = await buildApp(2); + await app.inject({ + method: 'GET', + url: '/protected', + headers: { authorization: 'Basic owner' }, + }); + await app.inject({ + method: 'GET', + url: '/protected', + headers: { authorization: 'Bearer agent' }, + }); + + expect( + (await app.inject({ method: 'GET', url: '/protected' })).statusCode, + ).toBe(429); + }); + + it('ignores spoofed forwarding headers and separates real peers', async () => { + const app = await buildApp(1); + await app.inject({ + method: 'GET', + url: '/protected', + remoteAddress: '192.0.2.10', + headers: { 'x-forwarded-for': '198.51.100.1' }, + }); + + expect( + ( + await app.inject({ + method: 'GET', + url: '/protected', + remoteAddress: '192.0.2.10', + headers: { 'x-forwarded-for': '203.0.113.1' }, + }) + ).statusCode, + ).toBe(429); + expect( + ( + await app.inject({ + method: 'GET', + url: '/protected', + remoteAddress: '192.0.2.11', + headers: { 'x-forwarded-for': '198.51.100.1' }, + }) + ).statusCode, + ).toBe(200); + }); + + it('counts an SSE connection once rather than its streamed frames', async () => { + const app = await buildApp(2); + + const stream = await app.inject({ method: 'GET', url: '/stream' }); + + expect(stream.statusCode).toBe(200); + expect(stream.body).toContain(': ping'); + expect( + (await app.inject({ method: 'GET', url: '/protected' })).statusCode, + ).toBe(200); + expect( + (await app.inject({ method: 'GET', url: '/protected' })).statusCode, + ).toBe(429); + }); + + it('rejects uploads before parsing their request body', async () => { + const app = fastify(); + openApps.push(app); + let parsedBodies = 0; + await app.register( + rateLimit, + createApplicationRateLimitOptions({ max: 1 }), + ); + app.addContentTypeParser( + 'application/octet-stream', + { parseAs: 'buffer' }, + (_request, body, done) => { + parsedBodies += 1; + done(null, body); + }, + ); + app.get('/protected', async () => ({ ok: true })); + app.post('/upload', async () => ({ uploaded: true })); + await app.ready(); + await app.inject({ method: 'GET', url: '/protected' }); + + const response = await app.inject({ + method: 'POST', + url: '/upload', + headers: { 'content-type': 'application/octet-stream' }, + payload: Buffer.from('large upload placeholder'), + }); + + expect(response.statusCode).toBe(429); + expect(parsedBodies).toBe(0); + }); +}); diff --git a/apps/server/src/modules/security/rate-limit.ts b/apps/server/src/modules/security/rate-limit.ts new file mode 100644 index 000000000..536a3c584 --- /dev/null +++ b/apps/server/src/modules/security/rate-limit.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { normalizeIP } from '@fastify/rate-limit'; + +import type { FastifyRequest } from 'fastify'; + +export const APPLICATION_RATE_LIMIT_MAX = 1_000; +export const APPLICATION_RATE_LIMIT_WINDOW_MS = 60_000; + +function isExemptRequest(request: FastifyRequest): boolean { + if (request.method === 'OPTIONS') return true; + return ( + request.method === 'GET' && + request.url.split('?', 1)[0] === '/api/deployment/readiness' + ); +} + +function directPeerKey(request: FastifyRequest): string { + const peer = request.socket.remoteAddress; + return peer ? normalizeIP(peer) : 'unknown-peer'; +} + +export function createApplicationRateLimitOptions( + overrides: { max?: number; timeWindow?: number } = {}, +) { + const max = overrides.max ?? APPLICATION_RATE_LIMIT_MAX; + const timeWindow = overrides.timeWindow ?? APPLICATION_RATE_LIMIT_WINDOW_MS; + + return { + global: true, + max, + timeWindow, + hook: 'onRequest' as const, + keyGenerator: directPeerKey, + allowList: (request: FastifyRequest) => isExemptRequest(request), + errorResponseBuilder: ( + _request: FastifyRequest, + context: { statusCode: number; ttl: number }, + ) => { + const retryAfterSeconds = Math.max(1, Math.ceil(context.ttl / 1_000)); + const body = { + message: `Too many requests. Try again in ${retryAfterSeconds} seconds.`, + code: 'RATE_LIMITED', + details: { retryAfterSeconds }, + }; + Object.defineProperty(body, 'statusCode', { + value: context.statusCode, + enumerable: false, + }); + return body; + }, + onExceeded: (request: FastifyRequest, key: string) => { + request.log.warn( + { + client: key, + method: request.method, + route: request.routeOptions.url, + }, + 'Request rate limit exceeded', + ); + }, + }; +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4ed1637b0..b457ac307 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,6 +9,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; @@ -20,8 +21,9 @@ import { useBlocker, } from 'react-router-dom'; +import { RATE_LIMITED_EVENT, type RateLimitedEventDetail } from './api/_client'; import { Loading } from './components/Common/Loading'; -import { ToastContainer } from './components/Common/Toast'; +import { dismissToast, toast, ToastContainer } from './components/Common/Toast'; import { GlobalModals } from './components/Shell/GlobalModals'; import { NativeMenuBridge } from './components/Shell/NativeMenuBridge'; import { WindowChrome } from './components/Shell/WindowChrome'; @@ -234,16 +236,48 @@ function WorkspaceLanding() { } export default function App() { + const { t } = useTranslation(); useInputModeListener(); useDisableBrowserZoom(); const init = useWorkspaceStore((s) => s.init); const [initialising, setInitialising] = useState(true); + const rateLimitToastIdRef = useRef(null); + const lastRateLimitToastAtRef = useRef(0); useEffect(() => { void init().finally(() => setInitialising(false)); }, [init]); + useEffect(() => { + const handleRateLimited = (event: Event) => { + const now = Date.now(); + if (now - lastRateLimitToastAtRef.current < 5_000) return; + lastRateLimitToastAtRef.current = now; + + const { retryAfterSeconds } = ( + event as CustomEvent + ).detail; + if (rateLimitToastIdRef.current) { + dismissToast(rateLimitToastIdRef.current); + } + rateLimitToastIdRef.current = toast( + t('errors.rateLimited', { + seconds: Math.max(1, Math.ceil(retryAfterSeconds)), + }), + { + tone: 'warning', + duration: 6_000, + dismissible: true, + }, + ); + }; + window.addEventListener(RATE_LIMITED_EVENT, handleRateLimited); + return () => { + window.removeEventListener(RATE_LIMITED_EVENT, handleRateLimited); + }; + }, [t]); + // Build the data router exactly once for the lifetime of the app. // We need a data router (not the legacy ``) so that // `useBlocker` inside `CanvasPage` can hold navigation while pending diff --git a/apps/web/src/api/_client.test.ts b/apps/web/src/api/_client.test.ts new file mode 100644 index 000000000..97a4bb556 --- /dev/null +++ b/apps/web/src/api/_client.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + ApiError, + apiErrorFromResponse, + apiFetch, + getRateLimitRetryAfterSeconds, + RATE_LIMITED_EVENT, + type RateLimitedEventDetail, +} from './_client'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('rate-limit API errors', () => { + it('turns a 429 into ApiError and publishes its retry interval', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + message: 'Too many requests.', + code: 'RATE_LIMITED', + details: { retryAfterSeconds: 30 }, + }), + { + status: 429, + headers: { + 'content-type': 'application/json', + 'retry-after': '42', + }, + }, + ), + ), + ); + const details: RateLimitedEventDetail[] = []; + const listener = (event: Event) => { + details.push((event as CustomEvent).detail); + }; + window.addEventListener(RATE_LIMITED_EVENT, listener); + + let error: unknown; + try { + await apiFetch('/limited'); + } catch (caught) { + error = caught; + } finally { + window.removeEventListener(RATE_LIMITED_EVENT, listener); + } + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ + status: 429, + code: 'RATE_LIMITED', + details: { retryAfterSeconds: 42 }, + }); + expect(getRateLimitRetryAfterSeconds(error)).toBe(42); + expect(details).toEqual([{ retryAfterSeconds: 42 }]); + }); + + it('uses a safe retry fallback for a non-JSON SSE rejection', async () => { + const error = await apiErrorFromResponse( + new Response('Too Many Requests', { status: 429 }), + 'Stream failed', + ); + + expect(error.message).toBe('Stream failed'); + expect(error.code).toBe('RATE_LIMITED'); + expect(getRateLimitRetryAfterSeconds(error)).toBe(60); + }); +}); diff --git a/apps/web/src/api/_client.ts b/apps/web/src/api/_client.ts index 8054e17c3..34e47cf78 100644 --- a/apps/web/src/api/_client.ts +++ b/apps/web/src/api/_client.ts @@ -26,6 +26,12 @@ import { API_CONFIG } from '../config/api'; import type { ApiErrorBody } from '@huabu/shared'; +export const RATE_LIMITED_EVENT = 'huabu:rate-limited'; + +export interface RateLimitedEventDetail { + retryAfterSeconds: number; +} + /** Strongly-typed runtime error raised when the server returns a non-2xx. */ export class ApiError extends Error { readonly status: number; @@ -75,6 +81,56 @@ async function readErrorBody( return {}; } +function retryAfterSeconds( + response: Response, + body: Partial, +): number { + const headerValue = Number.parseInt( + response.headers.get('retry-after') ?? '', + ); + if (Number.isFinite(headerValue) && headerValue > 0) return headerValue; + + const detailValue = + body.details && + typeof body.details === 'object' && + 'retryAfterSeconds' in body.details + ? Number(body.details.retryAfterSeconds) + : Number.NaN; + return Number.isFinite(detailValue) && detailValue > 0 ? detailValue : 60; +} + +function notifyRateLimited(detail: RateLimitedEventDetail): void { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(RATE_LIMITED_EVENT, { detail }), + ); +} + +export function getRateLimitRetryAfterSeconds(error: unknown): number | null { + if (!(error instanceof ApiError) || error.status !== 429) return null; + const value = + error.details && + typeof error.details === 'object' && + 'retryAfterSeconds' in error.details + ? Number(error.details.retryAfterSeconds) + : Number.NaN; + return Number.isFinite(value) && value > 0 ? value : 60; +} + +export async function apiErrorFromResponse( + response: Response, + fallback: string, +): Promise { + const body = await readErrorBody(response); + if (response.status === 429) { + const seconds = retryAfterSeconds(response, body); + body.code ??= 'RATE_LIMITED'; + body.details = { retryAfterSeconds: seconds }; + notifyRateLimited({ retryAfterSeconds: seconds }); + } + return new ApiError(response.status, body, fallback); +} + /** * Perform a JSON request and parse the response. * @@ -121,10 +177,8 @@ export async function apiFetch( const response = await fetch(apiUrl(path), init); if (!response.ok) { - const errBody = await readErrorBody(response); - throw new ApiError( - response.status, - errBody, + throw await apiErrorFromResponse( + response, fallbackMessage ?? `Request to ${path} failed: ${response.status} ${response.statusText}`, ); diff --git a/apps/web/src/api/agent.ts b/apps/web/src/api/agent.ts index c7832977f..dcba2673f 100644 --- a/apps/web/src/api/agent.ts +++ b/apps/web/src/api/agent.ts @@ -10,12 +10,11 @@ import { AGENT_HOST_SSE_EVENTS, AGENT_SSE_EVENTS } from '@huabu/shared'; -import { ApiError, apiFetch, apiUrl } from './_client'; +import { ApiError, apiErrorFromResponse, apiFetch, apiUrl } from './_client'; import { routes } from './_routes'; import { readTypedSSEStream } from './_sse'; import type { - ApiErrorBody, AgentBinding, AgentHostStreamEvent, AgentInputKind, @@ -208,7 +207,13 @@ export const agentApi = { if (response.status === 404) return { status: 'inactive' }; if (!response.ok || !response.body) { - throw new Error(`Agent stream failed with HTTP ${response.status}`); + if (!response.ok) { + throw await apiErrorFromResponse( + response, + `Agent stream failed with HTTP ${response.status}`, + ); + } + throw new Error('Agent stream response body is null'); } const terminal = await pumpAgentStream(response, callbacks, signal, { @@ -310,15 +315,8 @@ export const agentApi = { }); if (!response.ok) { - let body: Partial = {}; - try { - body = (await response.json()) as Partial; - } catch { - // Preserve the status even when an intermediary returns non-JSON. - } - throw new ApiError( - response.status, - body, + throw await apiErrorFromResponse( + response, `Agent request failed with HTTP ${response.status}`, ); } diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 84f2f9695..70ecd2e0b 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -1017,6 +1017,7 @@ } }, "errors": { - "nodeSaveFailed": "Couldn't write \"{{name}}\" to disk — the file may be locked or not writable." + "nodeSaveFailed": "Couldn't write \"{{name}}\" to disk — the file may be locked or not writable.", + "rateLimited": "Too many requests. Try again in {{seconds}} seconds." } } diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index d8c4a3a61..0dc154715 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -1017,6 +1017,7 @@ } }, "errors": { - "nodeSaveFailed": "「{{name}}」写盘失败,可能文件被占用或没有写入权限。" + "nodeSaveFailed": "「{{name}}」写盘失败,可能文件被占用或没有写入权限。", + "rateLimited": "请求过于频繁,请在 {{seconds}} 秒后重试。" } } diff --git a/apps/web/src/store/canvasSyncStore.ts b/apps/web/src/store/canvasSyncStore.ts index 8ffb608d3..16ef365e1 100644 --- a/apps/web/src/store/canvasSyncStore.ts +++ b/apps/web/src/store/canvasSyncStore.ts @@ -3,6 +3,10 @@ import { create } from 'zustand'; +import { + apiErrorFromResponse, + getRateLimitRetryAfterSeconds, +} from '@/api/_client'; import { readTypedSSEStream } from '@/api/_sse'; import { canvasSyncStreamUrl } from '@/api/canvasSync'; import { dismissToast, toast } from '@/components/Common/Toast'; @@ -144,11 +148,18 @@ export const useCanvasSyncStore = create((set, get) => ({ void (async () => { let reconnectDelay = INITIAL_RECONNECT_DELAY_MS; while (!signal.aborted && get().canvasId === canvasId) { + let nextDelay = reconnectDelay; try { const response = await fetch(canvasSyncStreamUrl(canvasId), { signal, }); if (!response.ok || !response.body) { + if (!response.ok) { + throw await apiErrorFromResponse( + response, + `Canvas sync failed with HTTP ${response.status}`, + ); + } throw new Error(`Canvas sync failed with HTTP ${response.status}`); } await readTypedSSEStream( @@ -259,6 +270,10 @@ export const useCanvasSyncStore = create((set, get) => ({ } } catch (error) { if (signal.aborted || get().canvasId !== canvasId) return; + const retryAfterSeconds = getRateLimitRetryAfterSeconds(error); + if (retryAfterSeconds !== null) { + nextDelay = Math.max(nextDelay, retryAfterSeconds * 1_000); + } console.warn('[canvasSync] reconnecting after stream failure', error); } @@ -271,7 +286,7 @@ export const useCanvasSyncStore = create((set, get) => ({ window.clearTimeout(timeout); finish(); }; - const timeout = window.setTimeout(finish, reconnectDelay); + const timeout = window.setTimeout(finish, nextDelay); signal.addEventListener('abort', onAbort, { once: true }); }); reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY_MS); diff --git a/docs/architecture/deployment-security.md b/docs/architecture/deployment-security.md index cdba4035e..d3b06ede2 100644 --- a/docs/architecture/deployment-security.md +++ b/docs/architecture/deployment-security.md @@ -1,6 +1,6 @@ # Deployment Security -> Network exposure, owner authentication, deployment readiness, and transport guidance. Last updated: 2026-08-15 +> Network exposure, owner authentication, deployment readiness, transport guidance, and request-volume controls. Last updated: 2026-09-18 ## Security model @@ -35,6 +35,16 @@ The response is deliberately redacted: it never contains usernames, passwords, c Settings loads readiness when it opens. A read-only credential store disables API-key and OAuth mutations while leaving non-secret model configuration available. Standalone deployments enable encrypted credential writes with `HUABU_SECRET_KEY`; see [`credential-storage.md`](./credential-storage.md). +## Application-wide rate limiting + +Every production HTTP route inherits one in-memory `@fastify/rate-limit` policy: 1,000 admitted requests per 60-second window, keyed by the normalized direct TCP peer IP. Authentication and authorization are independent of admission control, so loopback clients, Basic-authenticated browser traffic, and RFS bearer traffic receive neither a bypass nor credential-specific buckets. + +Huabu does not enable Fastify `trustProxy` and does not derive rate-limit identity from `Forwarded`, `X-Forwarded-For`, or similar caller-controlled headers. A supported external TLS terminator therefore shares one bucket for all clients that reach Huabu through that proxy. Per-client identity behind trusted proxies requires a future explicit proxy trust contract; operators must not enable arbitrary forwarded-header trust as a workaround. + +`OPTIONS` preflights and `GET /api/deployment/readiness` are exempt so browsers can negotiate access and deployment monitoring remains available. There is no separate health endpoint. Static assets, ordinary APIs, RFS—including the public root Skill bootstrap—and uploads are limited. Upload admission runs before body parsing and remains additionally bounded by the 500 MB upload-size ceiling. + +An SSE connection attempt consumes one request when the stream opens; events and heartbeats on the established connection consume no further requests. Reconnect attempts are ordinary requests. A rejected request returns HTTP 429 with the canonical `ApiErrorBody`, `code: "RATE_LIMITED"`, retry details, limit/remaining/reset headers, and `Retry-After`. The web client shows one deduplicated warning and Canvas Sync waits at least the advertised retry interval before reconnecting. The server records a structured warning containing the direct-peer key, method, and route template, never credentials. + ## Transport Huabu's Node server currently speaks HTTP. A non-loopback bind logs and reports `operator-unverified` transport because the process cannot prove whether a private network or external TLS terminator protects the client-facing connection. @@ -50,6 +60,7 @@ The Desktop remote-client path uses the operating system's normal certificate va | [`apps/server/src/modules/security/deployment-config.ts`](../../apps/server/src/modules/security/deployment-config.ts) | Resolve and fail closed on invalid bind, allowed-host, and Basic Auth combinations. | | [`apps/server/src/modules/security/owner.ts`](../../apps/server/src/modules/security/owner.ts) | Recognize the loopback or Basic-authenticated single owner. | | [`apps/server/src/modules/security/deployment.route.ts`](../../apps/server/src/modules/security/deployment.route.ts) | Serve the redacted readiness model. | +| [`apps/server/src/modules/security/rate-limit.ts`](../../apps/server/src/modules/security/rate-limit.ts) | Define global request admission, identity, exemptions, and 429 diagnostics. | | [`apps/server/src/modules/agent/change-review-config.route.ts`](../../apps/server/src/modules/agent/change-review-config.route.ts) | Enforce owner-only access to the global Agent Change Review configuration. | | [`apps/server/src/app.ts`](../../apps/server/src/app.ts) | Apply Host, Origin, Basic Auth, and route composition. | | [`apps/web/vite.config.ts`](../../apps/web/vite.config.ts) | Gate non-loopback development clients before assets and API proxying. | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 368210f60..6f4499360 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,6 +202,9 @@ importers: '@fastify/multipart': specifier: ^9.0.1 version: 9.4.0 + '@fastify/rate-limit': + specifier: ^11.2.0 + version: 11.2.0 '@fastify/static': specifier: ^8.0.2 version: 8.3.0 @@ -1762,6 +1765,9 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/rate-limit@11.2.0': + resolution: {integrity: sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==} + '@fastify/send@4.1.0': resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} @@ -4193,6 +4199,9 @@ packages: fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + fastify@5.12.1: resolution: {integrity: sha512-FWi+tQvwxR/PeRX7Z2mhfEF5ozJ3jn9asiiclzKXNSzJRHAYcU924aIOKAdHFJ+YIKieh3cqr1IwCOvTr41B3Q==} @@ -4559,6 +4568,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ip-address@10.7.2: + resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==} + engines: {node: '>= 12'} + ipaddr.js@2.5.0: resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} engines: {node: '>= 10'} @@ -8272,6 +8285,13 @@ snapshots: '@fastify/forwarded': 3.0.2 ipaddr.js: 2.5.0 + '@fastify/rate-limit@11.2.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 6.0.0 + ip-address: 10.7.2 + toad-cache: 3.7.4 + '@fastify/send@4.1.0': dependencies: '@lukeed/ms': 2.0.2 @@ -11204,6 +11224,8 @@ snapshots: fastify-plugin@5.1.0: {} + fastify-plugin@6.0.0: {} + fastify@5.12.1: dependencies: '@fastify/ajv-compiler': 4.0.6 @@ -11658,6 +11680,8 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.1 + ip-address@10.7.2: {} + ipaddr.js@2.5.0: {} is-array-buffer@3.0.5: