Skip to content

Commit 6ce8150

Browse files
committed
refactor(api): ✨ Standardize middleware stack and API responses
Refactors the core API middleware stack and standardizes response handling across all route handlers for improved security and consistency. Key changes: * Implemented comprehensive security headers (CSP, HSTS, Permissions Policy) using `hono/secure-headers`. * Introduced global rate limiting for DoS protection and specific limits for auth endpoints. * Added global `app.onError` handler for consistent 500 error responses. * Replaced custom response helpers with direct `c.json` calls, improving type safety and Hono integration. * Added `contextStorage`, `timing` (Server-Timing), and `bodyLimit` middleware.
1 parent 62ffcb0 commit 6ce8150

File tree

17 files changed

+654
-454
lines changed

17 files changed

+654
-454
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,4 @@ drizzle/meta/
7777
**/cypress/videos
7878
**/cypress_output.txt
7979
**/check_output.txt
80+
bun.lock

apps/api/src/app.ts

Lines changed: 203 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,24 @@
3333
// FOIA Stream - Main Application Entry
3434
// ============================================
3535

36+
import { bodyLimit } from 'hono/body-limit';
37+
import { contextStorage } from 'hono/context-storage';
3638
import { cors } from 'hono/cors';
3739
import { prettyJSON } from 'hono/pretty-json';
3840
import { secureHeaders } from 'hono/secure-headers';
41+
import { timing } from 'hono/timing';
3942

4043
import { env } from './config/env';
4144
import configureOpenAPI from './lib/configure-open-api';
4245
import createApp from './lib/create-app';
46+
import { logger } from './lib/logger';
47+
import {
48+
apiRateLimit,
49+
authRateLimit,
50+
autoBanProtection,
51+
passwordResetRateLimit,
52+
uploadRateLimit,
53+
} from './middleware/rate-limit.middleware';
4354
import { httpsEnforcement, requestId } from './middleware/security.middleware';
4455
import { cacheMiddleware } from './middleware/shared.middleware';
4556
// OpenAPI Routes (all modules now use OpenAPI pattern)
@@ -51,6 +62,21 @@ import redactionOpenAPIRoute from './routes/redaction';
5162
import requestsOpenAPIRoute from './routes/requests';
5263
import templatesOpenAPIRoute from './routes/templates';
5364

65+
// ============================================
66+
// Timing Middleware Configuration
67+
// ============================================
68+
69+
/**
70+
* Server-Timing header for performance monitoring
71+
* @compliance NIST 800-53 AU-12 (Audit Generation)
72+
*/
73+
const timingMiddleware = timing({
74+
total: true,
75+
enabled: env.NODE_ENV === 'production',
76+
totalDescription: 'Total API Response Time',
77+
autoEnd: true,
78+
});
79+
5480
/**
5581
* Main Hono application instance with OpenAPI support
5682
*
@@ -63,27 +89,150 @@ import templatesOpenAPIRoute from './routes/templates';
6389
const app = createApp();
6490

6591
// ============================================
66-
// Additional Global Middleware
92+
// Core Middleware Stack
6793
// ============================================
6894

95+
// Context storage for request-scoped data
96+
app.use('*', contextStorage());
97+
98+
// Performance timing headers
99+
app.use('*', timingMiddleware);
100+
69101
// Request ID for tracing
70102
app.use('*', requestId());
71103

104+
// Body size limit (2MB default)
105+
// @compliance NIST 800-53 SI-10 (Information Input Validation)
106+
app.use('*', bodyLimit({ maxSize: env.MAX_FILE_SIZE || 1024 * 1024 * 2 }));
107+
72108
// HTTPS enforcement (redirects HTTP to HTTPS in production)
73109
// @compliance NIST 800-53 SC-8 (Transmission Confidentiality)
74110
app.use('*', httpsEnforcement());
75111

76-
// Security headers
77-
app.use('*', secureHeaders());
112+
// Enhanced security headers
113+
// @compliance NIST 800-53 SC-8, SC-13 (Cryptographic Protection)
114+
// @compliance OWASP Security Headers
115+
app.use(
116+
'*',
117+
secureHeaders({
118+
// HSTS: 2 years with subdomains and preload (max security)
119+
strictTransportSecurity: 'max-age=63072000; includeSubDomains; preload',
120+
121+
// Prevent clickjacking completely
122+
xFrameOptions: 'DENY',
123+
124+
// XSS Protection: Modern browsers use CSP, but set to 0 to prevent bypass attacks
125+
// (mode=block can introduce vulnerabilities in older IE)
126+
xXssProtection: '0',
127+
128+
// Prevent MIME type sniffing attacks
129+
xContentTypeOptions: 'nosniff',
130+
131+
// Disable DNS prefetching to prevent information leakage
132+
xDnsPrefetchControl: 'off',
133+
134+
// Prevent IE from opening downloads directly
135+
xDownloadOptions: 'noopen',
136+
137+
// Block Flash/PDF cross-domain policies
138+
xPermittedCrossDomainPolicies: 'none',
139+
140+
// Cross-Origin Isolation headers for maximum security
141+
crossOriginEmbedderPolicy: 'require-corp',
142+
crossOriginResourcePolicy: 'same-origin',
143+
crossOriginOpenerPolicy: 'same-origin',
144+
145+
// Enable origin isolation for performance and security
146+
originAgentCluster: true,
147+
148+
// Strict referrer policy - only send origin for same-origin, nothing for cross-origin
149+
referrerPolicy: 'strict-origin-when-cross-origin',
150+
151+
// Content Security Policy - tight restrictions
152+
contentSecurityPolicy: {
153+
defaultSrc: ["'self'"],
154+
// Scripts: self only, no inline scripts (use nonces in production)
155+
scriptSrc: ["'self'"],
156+
// Styles: self only, inline needed for some UI frameworks
157+
styleSrc: ["'self'", "'unsafe-inline'"],
158+
// Images: self, data URIs for base64, and HTTPS sources
159+
imgSrc: ["'self'", 'data:', 'https:'],
160+
// Connections: same origin plus the API domain
161+
connectSrc: ["'self'"],
162+
// Fonts: self and data URIs
163+
fontSrc: ["'self'", 'data:'],
164+
// Block all object/embed/applet elements
165+
objectSrc: ["'none'"],
166+
// Media: self only
167+
mediaSrc: ["'self'"],
168+
// Frames: none - prevent embedding
169+
frameSrc: ["'none'"],
170+
// Frame ancestors: none - prevent being embedded
171+
frameAncestors: ["'none'"],
172+
// Base URI: self only - prevent base tag hijacking
173+
baseUri: ["'self'"],
174+
// Form actions: self only
175+
formAction: ["'self'"],
176+
// Upgrade insecure requests to HTTPS
177+
upgradeInsecureRequests: [],
178+
},
179+
180+
// Permissions Policy - disable ALL unnecessary browser features
181+
// This prevents fingerprinting and limits attack surface
182+
permissionsPolicy: {
183+
// Location/sensors - disable completely
184+
accelerometer: [],
185+
ambientLightSensor: [],
186+
autoplay: [],
187+
battery: [],
188+
camera: [],
189+
displayCapture: [],
190+
encryptedMedia: [],
191+
executionWhileNotRendered: [],
192+
executionWhileOutOfViewport: [],
193+
fullscreen: ["'self'"],
194+
gamepad: [],
195+
geolocation: [],
196+
gyroscope: [],
197+
hid: [],
198+
identityCredentialsGet: [],
199+
idleDetection: [],
200+
localFonts: [],
201+
magnetometer: [],
202+
microphone: [],
203+
midi: [],
204+
payment: [],
205+
pictureInPicture: [],
206+
publickeyCredentialsGet: [],
207+
screenWakeLock: [],
208+
serial: [],
209+
speakerSelection: [],
210+
// Only enable storage access for self
211+
storageAccess: ["'self'"],
212+
syncXhr: [],
213+
usb: [],
214+
webShare: [],
215+
windowManagement: [],
216+
xrSpatialTracking: [],
217+
},
218+
}),
219+
);
78220

79221
// CORS
80222
app.use(
81223
'*',
82224
cors({
83225
origin: env.CORS_ORIGIN === '*' ? '*' : env.CORS_ORIGIN.split(','),
84-
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
85-
allowHeaders: ['Content-Type', 'Authorization', 'b3', 'traceparent', 'tracestate'],
86-
exposeHeaders: ['Content-Length', 'X-Request-Id'],
226+
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'],
227+
allowHeaders: [
228+
'Content-Type',
229+
'Authorization',
230+
'b3',
231+
'traceparent',
232+
'tracestate',
233+
'X-Request-Id',
234+
],
235+
exposeHeaders: ['Content-Length', 'X-Request-Id', 'Server-Timing'],
87236
maxAge: 86400,
88237
credentials: true,
89238
}),
@@ -94,6 +243,30 @@ if (env.NODE_ENV === 'development') {
94243
app.use('*', prettyJSON());
95244
}
96245

246+
// ============================================
247+
// Global Error Handler
248+
// ============================================
249+
250+
/**
251+
* Global error handler for unhandled exceptions
252+
* @compliance NIST 800-53 SI-11 (Error Handling)
253+
*/
254+
app.onError((err, c) => {
255+
const error = err instanceof Error ? err : new Error(String(err));
256+
257+
logger.error({ error: error.message, stack: error.stack }, 'Unhandled API error');
258+
259+
const errorResponse = {
260+
success: false,
261+
error: {
262+
message: env.NODE_ENV === 'production' ? 'Internal server error' : error.message,
263+
code: error.name || 'UNKNOWN_ERROR',
264+
},
265+
};
266+
267+
return c.json(errorResponse, 500);
268+
});
269+
97270
// ============================================
98271
// Health Check (inline for OpenAPI spec)
99272
// ============================================
@@ -154,6 +327,30 @@ app.use('/api/v1/requests/*', cacheMiddleware('Private'));
154327
app.use('/api/v1/documents/*', cacheMiddleware('Private'));
155328
app.use('/api/v1/redaction/*', cacheMiddleware('NoCache'));
156329

330+
// ============================================
331+
// Rate Limiting
332+
// ============================================
333+
// @compliance NIST 800-53 SC-5 (Denial of Service Protection)
334+
335+
// Global API rate limit (100 requests/minute)
336+
app.use('/api/v1/*', apiRateLimit);
337+
338+
// Auto-ban protection - bans IPs after repeated rate limit violations
339+
app.use('/api/v1/*', autoBanProtection());
340+
341+
// Strict rate limits for authentication endpoints (5 requests/15 min)
342+
// Prevents brute-force password guessing attacks
343+
app.use('/api/v1/auth/login', authRateLimit);
344+
app.use('/api/v1/auth/register', authRateLimit);
345+
346+
// Very strict limit for password reset (3 requests/hour)
347+
app.use('/api/v1/auth/forgot-password', passwordResetRateLimit);
348+
app.use('/api/v1/auth/reset-password', passwordResetRateLimit);
349+
350+
// Upload rate limit for document endpoints (20 uploads/hour)
351+
app.use('/api/v1/documents/upload', uploadRateLimit);
352+
app.use('/api/v1/redaction/upload', uploadRateLimit);
353+
157354
app.route('/', indexRoute);
158355
app.route('/api/v1', authOpenAPIRoute);
159356
app.route('/api/v1', agenciesOpenAPIRoute);

apps/api/src/lib/file-helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export interface FileParseResult {
3434
export interface FileParseError {
3535
success: false;
3636
error: string;
37-
status: number;
37+
status: 400;
3838
}
3939

4040
export interface PdfScanResult {

apps/api/src/lib/responses.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import { z } from '@hono/zod-openapi';
1111
import type { Context } from 'hono';
12-
12+
import type { ContentfulStatusCode } from 'hono/utils/http-status';
1313
import { HttpStatusCodes } from './constants';
1414

1515
// ============================================
@@ -75,35 +75,43 @@ export function paginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
7575
/**
7676
* Send a success response with data
7777
*/
78-
export function successResponse<T>(
78+
export function successResponse<T, S extends ContentfulStatusCode = 200>(
7979
c: Context,
8080
data: T,
81-
options?: { message?: string; status?: number },
81+
options?: { message?: string; status?: S },
8282
) {
8383
const response: { success: true; data: T; message?: string } = {
84-
success: true,
84+
success: true as const,
8585
data,
8686
};
8787

8888
if (options?.message) {
8989
response.message = options.message;
9090
}
9191

92-
return c.json(response, (options?.status ?? 200) as any);
92+
return c.json(response, (options?.status ?? 200) as S);
9393
}
9494

9595
/**
9696
* Send an error response
9797
*/
98-
export function errorResponse(c: Context, error: string, status: number = 400) {
99-
return c.json({ success: false, error }, status as any);
98+
export function errorResponse<S extends ContentfulStatusCode = 400>(
99+
c: Context,
100+
error: string,
101+
status: S = 400 as S,
102+
) {
103+
return c.json({ success: false as const, error }, status);
100104
}
101105

102106
/**
103107
* Send a message-only success response
104108
*/
105-
export function messageResponse(c: Context, message: string, status: number = 200) {
106-
return c.json({ success: true, message }, status as any);
109+
export function messageResponse<S extends ContentfulStatusCode = 200>(
110+
c: Context,
111+
message: string,
112+
status: S = 200 as S,
113+
) {
114+
return c.json({ success: true as const, message }, status);
107115
}
108116

109117
/**

0 commit comments

Comments
 (0)