-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Complete Phase M — Technical Debt Resolution (M.1 + M.2 + M.3) #252
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
061f279
0f31923
2afb09e
c10e67e
f2dd04e
291e527
6602577
19d785a
2375dec
f130ff3
974a244
4e42934
fe97f78
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -10,6 +10,10 @@ | |||||
| import { handle } from '@hono/node-server/vercel'; | ||||||
| import { cors } from 'hono/cors'; | ||||||
| import { secureHeaders } from 'hono/secure-headers'; | ||||||
| import { rateLimit } from './middleware/rate-limit.js'; | ||||||
| import { bodyLimit } from './middleware/body-limit.js'; | ||||||
| import { sanitize } from './middleware/sanitize.js'; | ||||||
| import { contentTypeGuard } from './middleware/content-type-guard.js'; | ||||||
|
|
||||||
| /* ------------------------------------------------------------------ */ | ||||||
| /* Bootstrap (runs once per cold-start) */ | ||||||
|
|
@@ -64,6 +68,32 @@ async function bootstrapKernel(): Promise<void> { | |||||
| }), | ||||||
| ); | ||||||
|
|
||||||
| // ── Body size limit (1 MB default) ──────────────────────── | ||||||
| honoApp.use('/api/v1/*', bodyLimit({ maxSize: 1_048_576 })); | ||||||
|
|
||||||
| // ── Content-Type guard (mutation routes must send JSON) ── | ||||||
| honoApp.use( | ||||||
| '/api/v1/*', | ||||||
| contentTypeGuard({ | ||||||
| excludePaths: ['/api/v1/storage/upload'], | ||||||
| }), | ||||||
| ); | ||||||
|
|
||||||
| // ── XSS sanitization (strips HTML/script from JSON bodies) ── | ||||||
|
||||||
| // ── XSS sanitization (strips HTML/script from JSON bodies) ── | |
| // ── XSS sanitization (HTML-entity encodes HTML/script in JSON bodies) ── |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,34 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Body Size Limit Middleware for Hono | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Rejects requests whose Content-Length exceeds the configured maximum. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * @module api/middleware/body-limit | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * @see docs/guide/technical-debt-resolution.md — TD-4 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { MiddlewareHandler } from 'hono'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface BodyLimitConfig { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Maximum body size in bytes (default: 1 MB) */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| maxSize?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Creates a middleware that rejects requests with bodies larger than `maxSize`. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Returns 413 Payload Too Large when the Content-Length header exceeds the limit. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function bodyLimit(config: BodyLimitConfig = {}): MiddlewareHandler { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const maxSize = config.maxSize ?? 1_048_576; // 1 MB | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return async (c, next) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const contentLength = c.req.header('content-length'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (contentLength && parseInt(contentLength, 10) > maxSize) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return c.json( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { error: 'Payload too large', maxSize }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 413, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+25
to
+31
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const contentLength = c.req.header('content-length'); | |
| if (contentLength && parseInt(contentLength, 10) > maxSize) { | |
| return c.json( | |
| { error: 'Payload too large', maxSize }, | |
| 413, | |
| ); | |
| } | |
| const rawReq = c.req.raw; | |
| // Fast-path: honor Content-Length when present | |
| const contentLength = rawReq.headers.get('content-length'); | |
| if (contentLength && parseInt(contentLength, 10) > maxSize) { | |
| return c.json( | |
| { error: 'Payload too large', maxSize }, | |
| 413, | |
| ); | |
| } | |
| // If there's no body, nothing to limit. | |
| if (!rawReq.body) { | |
| await next(); | |
| return; | |
| } | |
| // Enforce limit based on actual bytes read, regardless of transfer encoding. | |
| const [limitStream, forwardStream] = rawReq.body.tee(); | |
| const reader = limitStream.getReader(); | |
| let totalBytes = 0; | |
| // Read until stream ends or we exceed maxSize. | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) { | |
| break; | |
| } | |
| if (value) { | |
| totalBytes += value.byteLength; | |
| if (totalBytes > maxSize) { | |
| // Exceeded limit: stop reading and reject. | |
| reader.releaseLock(); | |
| return c.json( | |
| { error: 'Payload too large', maxSize }, | |
| 413, | |
| ); | |
| } | |
| } | |
| } | |
| // Within limit: create a new Request with the untouched tee branch | |
| // so downstream middleware/handlers can still consume the body. | |
| const limitedReq = new Request(rawReq, { body: forwardStream }); | |
| // Hono's Context.req wraps the underlying Request; update its raw field. | |
| // @ts-expect-error: accessing framework-internal property | |
| (c.req as any).raw = limitedReq; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| /** | ||
| * Content-Type Guard Middleware for Hono | ||
| * | ||
| * Rejects mutation requests (POST/PUT/PATCH) that do not carry an accepted | ||
| * Content-Type header. File-upload endpoints can be excluded via the | ||
| * `excludePaths` option. | ||
| * | ||
| * @module api/middleware/content-type-guard | ||
| * @see docs/guide/technical-debt-resolution.md — TD-4 | ||
| */ | ||
| import type { MiddlewareHandler } from 'hono'; | ||
|
|
||
| export interface ContentTypeGuardConfig { | ||
| /** Accepted content types (default: `['application/json']`) */ | ||
| allowedTypes?: string[]; | ||
| /** Path prefixes to exclude (e.g., file upload endpoints) */ | ||
| excludePaths?: string[]; | ||
| } | ||
|
|
||
| /** | ||
| * Creates a middleware that rejects mutation requests without an allowed | ||
| * Content-Type header. | ||
| */ | ||
| export function contentTypeGuard( | ||
| config: ContentTypeGuardConfig = {}, | ||
| ): MiddlewareHandler { | ||
| const allowedTypes = config.allowedTypes ?? ['application/json']; | ||
| const excludePaths = config.excludePaths ?? []; | ||
|
|
||
| return async (c, next) => { | ||
| if (['POST', 'PUT', 'PATCH'].includes(c.req.method)) { | ||
| const path = c.req.path; | ||
|
|
||
| // Skip excluded paths (e.g., file uploads) | ||
| if (excludePaths.some((prefix) => path.startsWith(prefix))) { | ||
| return next(); | ||
| } | ||
|
|
||
| const contentType = c.req.header('content-type') ?? ''; | ||
| const isAllowed = allowedTypes.some((t) => contentType.includes(t)); | ||
|
|
||
| if (!isAllowed) { | ||
| return c.json( | ||
| { | ||
| error: 'Unsupported Media Type', | ||
| message: `Content-Type must be one of: ${allowedTypes.join(', ')}`, | ||
| }, | ||
| 415, | ||
| ); | ||
| } | ||
| } | ||
| await next(); | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| /** | ||
| * Rate Limiting Middleware for Hono | ||
| * | ||
| * Implements a sliding-window counter per key (IP or user). | ||
| * Adds standard X-RateLimit-* headers and returns 429 when exceeded. | ||
| * | ||
| * @module api/middleware/rate-limit | ||
| * @see docs/guide/technical-debt-resolution.md — TD-3 | ||
| */ | ||
| import type { MiddlewareHandler, Context } from 'hono'; | ||
|
|
||
| export interface RateLimitConfig { | ||
| /** Time window in milliseconds (default: 60_000 = 1 minute) */ | ||
| windowMs?: number; | ||
| /** Maximum requests per window (default: 100) */ | ||
| maxRequests?: number; | ||
| /** Custom key generator — defaults to IP address */ | ||
| keyGenerator?: (c: Context) => string; | ||
| /** Skip counting requests that returned a successful (2xx) status */ | ||
| skipSuccessfulRequests?: boolean; | ||
| /** Skip counting requests that returned a failed (non-2xx) status */ | ||
| skipFailedRequests?: boolean; | ||
|
Comment on lines
+19
to
+22
|
||
| /** Custom handler for 429 responses */ | ||
| handler?: MiddlewareHandler; | ||
| } | ||
|
|
||
| interface WindowEntry { | ||
| count: number; | ||
| resetAt: number; | ||
| } | ||
|
|
||
| /** | ||
| * Creates a Hono rate-limiting middleware using a sliding-window counter. | ||
| * | ||
| * Expired entries are garbage-collected periodically to prevent memory leaks. | ||
| */ | ||
| export function rateLimit(config: RateLimitConfig = {}): MiddlewareHandler { | ||
| const windowMs = config.windowMs ?? 60_000; | ||
| const maxRequests = config.maxRequests ?? 100; | ||
| const keyGenerator = | ||
| config.keyGenerator ?? | ||
| ((c: Context) => | ||
| c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? | ||
| c.req.header('x-real-ip') ?? | ||
| 'unknown'); | ||
|
|
||
| const store = new Map<string, WindowEntry>(); | ||
|
|
||
| // Periodic cleanup of expired entries (every 60 s) | ||
| const cleanupInterval = setInterval(() => { | ||
| const now = Date.now(); | ||
| for (const [key, entry] of store) { | ||
| if (now > entry.resetAt) { | ||
| store.delete(key); | ||
| } | ||
| } | ||
| }, 60_000); | ||
|
|
||
| // Allow the timer to be garbage-collected when the process exits | ||
| if (cleanupInterval && typeof cleanupInterval === 'object' && 'unref' in cleanupInterval) { | ||
| (cleanupInterval as NodeJS.Timeout).unref(); | ||
| } | ||
|
|
||
| return async (c, next) => { | ||
| const key = keyGenerator(c); | ||
| const now = Date.now(); | ||
| let entry = store.get(key); | ||
|
|
||
| if (!entry || now > entry.resetAt) { | ||
| entry = { count: 1, resetAt: now + windowMs }; | ||
| store.set(key, entry); | ||
| } else if (entry.count >= maxRequests) { | ||
| // Rate limit exceeded | ||
| c.header('X-RateLimit-Limit', String(maxRequests)); | ||
| c.header('X-RateLimit-Remaining', '0'); | ||
| c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000))); | ||
| c.header('Retry-After', String(Math.ceil((entry.resetAt - now) / 1000))); | ||
|
|
||
| if (config.handler) { | ||
| return config.handler(c, next); | ||
| } | ||
| return c.json({ error: 'Too many requests' }, 429); | ||
| } else { | ||
| entry.count++; | ||
| } | ||
|
|
||
| // Set rate-limit headers on successful pass-through | ||
| c.header('X-RateLimit-Limit', String(maxRequests)); | ||
| c.header('X-RateLimit-Remaining', String(Math.max(0, maxRequests - entry.count))); | ||
| c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000))); | ||
|
|
||
| await next(); | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PR description mentions the Zod validation middleware being registered on
/api/v1/*, butapi/index.tsdoesn’t import or applyvalidate(). If validation is intended as part of the global middleware stack, add it here (or update the PR description/docs to reflect that validation is route-specific).