Skip to content

Commit 6bd33d9

Browse files
mabry1985Claude Agentclaude
authored
Rate limiting and security middleware (#23)
* feat: add rate limiting and security middleware hardening Add helmet for security headers, per-route rate limiters (vault read/write, sensor report by sensor ID, general API), and reduce JSON body limit to 1mb. All rate-limited responses return standardized 429 with { success: false, error: 'Too many requests' }. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: Rate limiting and security middleware --------- Co-authored-by: Claude Agent <agent@protolabsai.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d004f50 commit 6bd33d9

File tree

6 files changed

+106
-27
lines changed

6 files changed

+106
-27
lines changed

apps/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"dotenv": "17.2.3",
7171
"express": "5.2.1",
7272
"express-rate-limit": "^8.2.1",
73+
"helmet": "^8.1.0",
7374
"groq-sdk": "^0.5.0",
7475
"morgan": "1.10.1",
7576
"node-pty": "1.1.0-beta41",

apps/server/src/server/middleware.ts

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
// Middleware setup: Morgan, CORS, Prometheus, cookie-parser, raw body
1+
// Middleware setup: Helmet, Morgan, CORS, Prometheus, cookie-parser, raw body, rate limiting
22

33
import morgan from 'morgan';
44
import cors from 'cors';
5+
import helmet from 'helmet';
56
import cookieParser from 'cookie-parser';
7+
import rateLimit from 'express-rate-limit';
68
import express, { type Request, type Response } from 'express';
79
import type { Express } from 'express';
810
import { prometheusMiddleware } from '../lib/prometheus.js';
@@ -31,10 +33,16 @@ export function isRequestLoggingEnabled(): boolean {
3133
return requestLoggingEnabled;
3234
}
3335

36+
/** Standard 429 response body for all rate limiters */
37+
const RATE_LIMIT_RESPONSE = { success: false, error: 'Too many requests' } as const;
38+
3439
/**
35-
* Register all Express middleware: logging, CORS, Prometheus, body parsing
40+
* Register all Express middleware: helmet, logging, CORS, Prometheus, body parsing
3641
*/
3742
export function setupMiddleware(app: Express, options?: { allowAllOrigins?: boolean }): void {
43+
// Security headers via helmet (must be early in the chain)
44+
app.use(helmet());
45+
3846
// Custom colored logger showing only endpoint and status code (dynamically configurable)
3947
morgan.token('status-colored', (_req, res) => {
4048
const status = res.statusCode;
@@ -122,7 +130,7 @@ export function setupMiddleware(app: Express, options?: { allowAllOrigins?: bool
122130
// This middleware must be before express.json()
123131
app.use(
124132
express.json({
125-
limit: '50mb',
133+
limit: '1mb',
126134
verify: (req: RequestWithRawBody, _res: Response, buf: Buffer) => {
127135
// Store raw body for routes that need it (e.g., webhook signature verification)
128136
req.rawBody = buf;
@@ -131,3 +139,69 @@ export function setupMiddleware(app: Express, options?: { allowAllOrigins?: bool
131139
);
132140
app.use(cookieParser());
133141
}
142+
143+
// ---------------------------------------------------------------------------
144+
// Rate limiters — exported for use in routes.ts
145+
// ---------------------------------------------------------------------------
146+
147+
/** Helper to build a rate limiter with consistent 429 response format */
148+
function createRateLimiter(options: {
149+
windowMs: number;
150+
limit: number;
151+
keyGenerator?: (req: Request) => string;
152+
skip?: (req: Request) => boolean;
153+
}) {
154+
return rateLimit({
155+
windowMs: options.windowMs,
156+
limit: options.limit,
157+
standardHeaders: 'draft-7',
158+
legacyHeaders: false,
159+
keyGenerator: options.keyGenerator,
160+
skip: options.skip,
161+
message: RATE_LIMIT_RESPONSE,
162+
});
163+
}
164+
165+
/** General API rate limiter: 300 req/min per IP (skips health + setup endpoints) */
166+
export const apiRateLimiter = createRateLimiter({
167+
windowMs: 60 * 1000,
168+
limit: 300,
169+
skip: (req) =>
170+
req.path === '/health' ||
171+
req.path.startsWith('/health/') ||
172+
req.path.startsWith('/setup/') ||
173+
req.path === '/settings/status',
174+
});
175+
176+
/** Vault read limiter: 30 req/min per IP */
177+
export const vaultReadRateLimiter = createRateLimiter({
178+
windowMs: 60 * 1000,
179+
limit: 30,
180+
});
181+
182+
/** Vault write limiter: 10 req/min per IP */
183+
export const vaultWriteRateLimiter = createRateLimiter({
184+
windowMs: 60 * 1000,
185+
limit: 10,
186+
});
187+
188+
/**
189+
* Sensor report limiter: 120 req/min keyed on sensor ID.
190+
* Attempts to extract sensor ID from body or X-Sensor-Id header, falls back to IP.
191+
*/
192+
export const sensorReportRateLimiter = createRateLimiter({
193+
windowMs: 60 * 1000,
194+
limit: 120,
195+
keyGenerator: (req: Request): string => {
196+
const body = req.body as Record<string, unknown> | undefined;
197+
const sensorIdFromBody = body?.sensorId;
198+
if (typeof sensorIdFromBody === 'string' && sensorIdFromBody.length > 0) {
199+
return `sensor:${sensorIdFromBody}`;
200+
}
201+
const sensorIdFromHeader = req.headers['x-sensor-id'];
202+
if (typeof sensorIdFromHeader === 'string' && sensorIdFromHeader.length > 0) {
203+
return `sensor:${sensorIdFromHeader}`;
204+
}
205+
return req.ip ?? 'unknown';
206+
},
207+
});

apps/server/src/server/routes.ts

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
// Route registration: all app.use() mounts, rate limiting, and OTEL initialization
22

33
import type { Express } from 'express';
4-
import rateLimit from 'express-rate-limit';
54
import { createLogger } from '@protolabsai/utils';
65

76
import type { ServiceContainer } from './services.js';
87

98
import { authMiddleware } from '../lib/auth.js';
109
import { requireJsonContentType } from '../middleware/require-json-content-type.js';
10+
import {
11+
apiRateLimiter,
12+
vaultReadRateLimiter,
13+
vaultWriteRateLimiter,
14+
sensorReportRateLimiter,
15+
} from './middleware.js';
1116
// Note: OTel is initialized in startup.ts via initOtel() — a single unified NodeSDK
1217
// with both OTLP exporter and LangfuseSpanProcessor. No separate init needed here.
1318
import { cleanupStaleValidations } from '../routes/github/routes/validation-common.js';
@@ -171,29 +176,18 @@ export function registerRoutes(app: Express, services: ServiceContainer): void {
171176
}, VALIDATION_CLEANUP_INTERVAL_MS);
172177

173178
// Rate limiting — general API (skip health checks and read-only status endpoints)
174-
const apiLimiter = rateLimit({
175-
windowMs: 60 * 1000, // 1 minute
176-
limit: 300,
177-
standardHeaders: 'draft-7',
178-
legacyHeaders: false,
179-
skip: (req) =>
180-
req.path === '/health' ||
181-
req.path.startsWith('/health/') ||
182-
req.path.startsWith('/setup/') ||
183-
req.path === '/settings/status',
184-
message: { error: 'Too many requests, please try again later' },
185-
});
186-
app.use('/api', apiLimiter);
187-
188-
// Stricter rate limit for auth login (brute force protection)
189-
const authLimiter = rateLimit({
190-
windowMs: 15 * 60 * 1000, // 15 minutes
191-
limit: 20,
192-
standardHeaders: 'draft-7',
193-
legacyHeaders: false,
194-
message: { error: 'Too many login attempts, please try again later' },
179+
app.use('/api', apiRateLimiter);
180+
181+
// Sensor report endpoint: 120 req/min keyed on sensor ID
182+
app.use('/api/sensors/report', sensorReportRateLimiter);
183+
184+
// Vault-specific rate limits (applied before vault routes are mounted)
185+
// Reads (GET): 30 req/min per IP; Writes (POST/PUT/DELETE): 10 req/min per IP
186+
app.use('/api/vault', (req, _res, next) => {
187+
const isWrite = req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE';
188+
const limiter = isWrite ? vaultWriteRateLimiter : vaultReadRateLimiter;
189+
limiter(req, _res, next);
195190
});
196-
app.use('/api/auth/login', authLimiter);
197191

198192
// Require Content-Type: application/json for all API POST/PUT/PATCH requests
199193
app.use('/api', requireJsonContentType);

node_modules

Lines changed: 0 additions & 1 deletion
This file was deleted.

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
"@types/dagre": "^0.7.53",
108108
"cross-spawn": "7.0.6",
109109
"dagre": "^0.8.5",
110+
"helmet": "^8.1.0",
110111
"langsmith": "0.4.12",
111112
"rehype-sanitize": "6.0.0",
112113
"tree-kill": "1.2.2"

0 commit comments

Comments
 (0)