Skip to content

Commit 608cb3f

Browse files
committed
feat(security): enforce URL and route param limits with 414 🎲
1 parent 73f2842 commit 608cb3f

3 files changed

Lines changed: 66 additions & 8 deletions

File tree

src/interfaces/Handler.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import type * as Types from '@interfaces/index.ts'
44
export interface HandlerOptions {
55
/** Custom error response builder */
66
errorResponseBuilder?: Types.ErrorResponseBuilder
7+
/** Max request URL length; 414 when exceeded */
8+
maxUrlLength?: number
9+
/** Max length per route param; 414 when exceeded */
10+
maxRouteParamLength?: number
711
/** Request timeout in ms; 503 on timeout when set */
812
requestTimeoutMs?: number
913
/** Custom static file handler */
@@ -18,6 +22,10 @@ export interface HandlerOptions {
1822
export interface RouterOptions {
1923
/** Custom error response builder */
2024
errorResponseBuilder?: Types.ErrorResponseBuilder
25+
/** Max request URL length; 414 when exceeded */
26+
maxUrlLength?: number
27+
/** Max length per route param; 414 when exceeded */
28+
maxRouteParamLength?: number
2129
/** Request timeout in ms; 503 on timeout when set */
2230
requestTimeoutMs?: number
2331
/** Directory path for file-based routes */

src/routing/Handler.ts

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { FastRouter } from '@neabyte/fast-router'
99
* @description Scans routes, runs middleware chain, dispatches to route or static.
1010
*/
1111
export class Handler {
12+
/** Default max route param length */
13+
private static readonly defaultMaxRouteParamLength = 1024
14+
/** Default max request URL length */
15+
private static readonly defaultMaxUrlLength = 8192
1216
/** Default error response builder using Error. */
1317
private static readonly defaultErrorResponseBuilder: Types.ErrorResponseBuilder = {
1418
build: (ctx, statusCode, error, errorMiddleware) =>
@@ -26,6 +30,10 @@ export class Handler {
2630
private errorResponseBuilder: Types.ErrorResponseBuilder
2731
/** Fast router for route matching. */
2832
private routerInstance = new FastRouter<Types.RouteMetadata>()
33+
/** Max length per route param; 414 when exceeded */
34+
private maxRouteParamLength: number | undefined
35+
/** Max request URL length; 414 when exceeded */
36+
private maxUrlLength: number | undefined
2937
/** Request timeout in ms; 503 when exceeded when set. */
3038
private requestTimeoutMs: number | undefined
3139
/** Static file handler; default or custom. */
@@ -43,13 +51,15 @@ export class Handler {
4351
constructor(options?: Types.HandlerOptions) {
4452
this.errorResponseBuilder = options?.errorResponseBuilder ?? Handler.defaultErrorResponseBuilder
4553
this.staticHandler = options?.staticHandler ?? Handler.defaultStaticHandler
54+
this.maxUrlLength = options?.maxUrlLength ?? Handler.defaultMaxUrlLength
55+
this.maxRouteParamLength = options?.maxRouteParamLength ?? Handler.defaultMaxRouteParamLength
4656
this.requestTimeoutMs = options?.requestTimeoutMs
47-
this.workerPool = options?.worker !== undefined
48-
? Core.Worker.createPool(options.worker)
49-
: undefined
50-
this.viewEngine = options?.viewsDir !== undefined
51-
? new Rendering.Engine({ viewsDir: options.viewsDir })
52-
: undefined
57+
this.workerPool =
58+
options?.worker !== undefined ? Core.Worker.createPool(options.worker) : undefined
59+
this.viewEngine =
60+
options?.viewsDir !== undefined
61+
? new Rendering.Engine({ viewsDir: options.viewsDir })
62+
: undefined
5363
}
5464

5565
/**
@@ -92,7 +102,12 @@ export class Handler {
92102
* @returns Async function from Request to Response
93103
*/
94104
createHandler(): (req: Request) => Promise<Response> {
105+
const maxUrlLength = this.maxUrlLength
106+
const maxRouteParamLength = this.maxRouteParamLength
95107
const run = async (req: Request): Promise<Response> => {
108+
if (maxUrlLength !== undefined && maxUrlLength > 0 && req.url.length > maxUrlLength) {
109+
return Handler.buildUriTooLongResponse(req)
110+
}
96111
const url = new URL(req.url)
97112
const ctx = new Core.Context(req, url, {}, this.handleResponse.bind(this))
98113
if (this.workerPool) {
@@ -115,6 +130,13 @@ export class Handler {
115130
return await ctx.handleError(404, new Error('Route not found'))
116131
}
117132
if ('params' in routeResult && routeResult.params) {
133+
if (maxRouteParamLength !== undefined && maxRouteParamLength > 0) {
134+
for (const paramValue of Object.values(routeResult.params)) {
135+
if (paramValue.length > maxRouteParamLength) {
136+
return await ctx.handleError(414, new Error('URI Too Long'))
137+
}
138+
}
139+
}
118140
ctx.setParams(routeResult.params)
119141
}
120142
const { handler } = metadata as Types.RouteMetadata
@@ -143,7 +165,7 @@ export class Handler {
143165
const timeoutMs = this.requestTimeoutMs
144166
return async (req: Request) => {
145167
if (timeoutMs !== undefined && timeoutMs > 0) {
146-
const timeoutResponse = new Promise<Response>((resolve) => {
168+
const timeoutResponse = new Promise<Response>(resolve => {
147169
setTimeout(
148170
() => resolve(new Response(null, { status: 503, statusText: 'Service Unavailable' })),
149171
timeoutMs
@@ -230,6 +252,28 @@ export class Handler {
230252
Routing.Scanner.validateModule(module, routePath, Core.Constant.httpMethods)
231253
}
232254

255+
/**
256+
* Build 414 response from request.
257+
* @description Returns JSON or HTML by Accept header.
258+
* @param req - Incoming request
259+
* @returns 414 Response
260+
*/
261+
private static buildUriTooLongResponse(req: Request): Response {
262+
const statusCode = 414
263+
const error = 'URI Too Long'
264+
const isJson = req.headers.get('accept')?.includes('application/json')
265+
if (isJson) {
266+
return globalThis.Response.json(
267+
{ error, path: '', statusCode },
268+
{ status: statusCode, headers: { 'Content-Type': 'application/json' } }
269+
)
270+
}
271+
return new Response(Core.Error.defaultErrorHtml(statusCode, error), {
272+
status: statusCode,
273+
headers: { 'Content-Type': 'text/html' }
274+
})
275+
}
276+
233277
/**
234278
* Run middleware chain for pathname.
235279
* @description Filters by path then runs next chain; returns first response.
@@ -241,7 +285,7 @@ export class Handler {
241285
ctx: Core.Context,
242286
pathname: string
243287
): Promise<Response | undefined> {
244-
const applicableMiddlewares = this.entryMiddleware.filter((middlewareEntry) => {
288+
const applicableMiddlewares = this.entryMiddleware.filter(middlewareEntry => {
245289
if (middlewareEntry.path === '' || middlewareEntry.path === '*') {
246290
return true
247291
}

src/routing/Router.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ export class Router {
1919
if (options?.errorResponseBuilder !== undefined) {
2020
handlerOptions.errorResponseBuilder = options.errorResponseBuilder
2121
}
22+
if (options?.maxUrlLength !== undefined) {
23+
handlerOptions.maxUrlLength = options.maxUrlLength
24+
}
25+
if (options?.maxRouteParamLength !== undefined) {
26+
handlerOptions.maxRouteParamLength = options.maxRouteParamLength
27+
}
2228
if (options?.staticHandler !== undefined) {
2329
handlerOptions.staticHandler = options.staticHandler
2430
}

0 commit comments

Comments
 (0)