Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
190 changes: 190 additions & 0 deletions apps/server/src/modules/security/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
64 changes: 64 additions & 0 deletions apps/server/src/modules/security/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -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',
);
},
};
}
36 changes: 35 additions & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
Expand All @@ -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';
Expand Down Expand Up @@ -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<string | null>(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<RateLimitedEventDetail>
).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 `<BrowserRouter>`) so that
// `useBlocker` inside `CanvasPage` can hold navigation while pending
Expand Down
Loading
Loading