diff --git a/packages/wobe-documentation/doc/concepts/wobe.md b/packages/wobe-documentation/doc/concepts/wobe.md index cc6127b..eb94320 100644 --- a/packages/wobe-documentation/doc/concepts/wobe.md +++ b/packages/wobe-documentation/doc/concepts/wobe.md @@ -17,6 +17,9 @@ The `Wobe` constructor can have some options: - `hostname`: The hostname where the server will be listening. - `onError`: A function that will be called when an error occurs. - `onNotFound`: A function that will be called when a route is not found. +- `maxBodySize`: Maximum accepted request body size in bytes (default: 1_048_576). Requests above the limit are rejected with `413`. +- `allowedContentEncodings`: Whitelisted `Content-Encoding` values (default: `['identity', '']`). Add `gzip`, `deflate` or `br` if you want to accept compressed bodies; decompressed size is still enforced by `maxBodySize`. +- `trustProxy`: When `true`, `X-Forwarded-For` is used to derive client IP (rate limiting/logs). Keep `false` if the app is directly reachable to avoid IP spoofing; enable only behind a trusted proxy that overwrites the header. - `tls`: An object with the key, the cert and the passphrase if exist to enable HTTPS. ```ts diff --git a/packages/wobe-documentation/doc/ecosystem/hooks/bearer-auth.md b/packages/wobe-documentation/doc/ecosystem/hooks/bearer-auth.md index 0aeede9..a123d38 100644 --- a/packages/wobe-documentation/doc/ecosystem/hooks/bearer-auth.md +++ b/packages/wobe-documentation/doc/ecosystem/hooks/bearer-auth.md @@ -13,7 +13,7 @@ const app = new Wobe() .beforeHandler( bearerAuth({ token: 'token', - }), + }) ) .get('/protected', (req, res) => { res.send('Protected') @@ -38,7 +38,7 @@ const app = new Wobe() bearerAuth({ token: 'token', hashFunction: (token) => token, - }), + }) ) .get('/protected', (req, res) => { res.send('Protected') @@ -57,4 +57,4 @@ const request = new Request('http://localhost:3000/test', { - `token` (string) : The token to compare with the Authorization header. - `realm` (string) : The realm to send in the WWW-Authenticate header. -- `hashFunction` ((token: string) => string) : A function to hash the token before comparing it. +- `hashFunction` ((token: string) => string) : A function to hash the token before comparing it. Tokens are compared in constant time; prefer a hash function that returns fixed-length output to avoid timing leaks on length. diff --git a/packages/wobe-documentation/doc/ecosystem/hooks/body-limit.md b/packages/wobe-documentation/doc/ecosystem/hooks/body-limit.md index 181164d..4d27dcd 100644 --- a/packages/wobe-documentation/doc/ecosystem/hooks/body-limit.md +++ b/packages/wobe-documentation/doc/ecosystem/hooks/body-limit.md @@ -2,6 +2,8 @@ Wobe has a `beforeHandler` hook to put a limit to the body size of each requests. +Note: Wobe also enforces a global body size limit (default 1 MiB) at the adapter level via the `maxBodySize` option on `new Wobe({ ... })`. Use the hook if you need a tighter or route-specific limit. + ## Example ```ts diff --git a/packages/wobe-documentation/doc/ecosystem/hooks/csrf.md b/packages/wobe-documentation/doc/ecosystem/hooks/csrf.md index 3ddf0b5..47407cd 100644 --- a/packages/wobe-documentation/doc/ecosystem/hooks/csrf.md +++ b/packages/wobe-documentation/doc/ecosystem/hooks/csrf.md @@ -2,6 +2,12 @@ Wobe has a `beforeHandler` hook to manage CSRF. +Behavior: + +- Enforced only on non-idempotent methods (POST/PUT/PATCH/DELETE/etc.). +- Uses `Origin` when present; falls back to checking `Referer` host when `Origin` is missing. +- Rejects with `403` when the origin/referer does not match the allowed list/function. + ## Example In this example all the requests without the origin equal to `http://localhost:3000` will be blocked. @@ -22,7 +28,7 @@ import { Wobe, csrf } from 'wobe' const app = new Wobe() .beforeHandler( - csrf({ origin: ['http://localhost:3000', 'http://localhost:3001'] }), + csrf({ origin: ['http://localhost:3000', 'http://localhost:3001'] }) ) .get('/hello', (context) => context.res.sendText('Hello world')) .listen(3000) @@ -35,7 +41,7 @@ import { Wobe, csrf } from 'wobe' const app = new Wobe() .beforeHandler( - csrf({ origin: (origin) => origin === 'http://localhost:3000' }), + csrf({ origin: (origin) => origin === 'http://localhost:3000' }) ) .get('/hello', (context) => context.res.sendText('Hello world')) .listen(3000) diff --git a/packages/wobe-documentation/doc/ecosystem/hooks/secure-headers.md b/packages/wobe-documentation/doc/ecosystem/hooks/secure-headers.md index 609dce2..77e6e3e 100644 --- a/packages/wobe-documentation/doc/ecosystem/hooks/secure-headers.md +++ b/packages/wobe-documentation/doc/ecosystem/hooks/secure-headers.md @@ -15,7 +15,7 @@ app.beforeHandler( 'default-src': ["'self'"], 'report-to': 'endpoint-5', }, - }), + }) ) app.get('/', (req, res) => { @@ -35,3 +35,4 @@ app.listen(3000) - `strictTransportSecurity` (string[]) : The Strict-Transport-Security header value. [For more informations](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security) - `xContentTypeOptions` (string) : The X-Content-Type-Options header value. [For more informations](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options) - `xDownloadOptions` (string) : The X-Download-Options header value. +- `xFrameOptions` (string | false) : The X-Frame-Options header value. Defaults to `SAMEORIGIN`; set to `false` to disable (e.g., if you intentionally allow framing). diff --git a/packages/wobe-documentation/doc/ecosystem/hooks/upload-directory.md b/packages/wobe-documentation/doc/ecosystem/hooks/upload-directory.md index 85dd997..f29b5b3 100644 --- a/packages/wobe-documentation/doc/ecosystem/hooks/upload-directory.md +++ b/packages/wobe-documentation/doc/ecosystem/hooks/upload-directory.md @@ -7,53 +7,65 @@ Wobe provides an `uploadDirectory` hook to easily serve files from a specified d A simple example to serve files from a directory. ```ts -import { Wobe, uploadDirectory } from 'wobe'; +import { Wobe, uploadDirectory } from 'wobe' const app = new Wobe() - .get('/bucket/:filename', uploadDirectory({ - directory: './bucket', - })) - .listen(3000); + .get( + '/bucket/:filename', + uploadDirectory({ + directory: './bucket', + }) + ) + .listen(3000) // A request like this will serve the file `example.jpg` from the `./bucket` directory -const request = new Request('http://localhost:3000/bucket/example.jpg'); +const request = new Request('http://localhost:3000/bucket/example.jpg') ``` ## Options - `directory` (string) : The directory path from which to serve files. This path should be relative to your project's root directory or an absolute path. - `isAuthorized` (boolean) : A boolean value indicating whether the hook should check if the request is authorized. If set to `true`, the hook will be authorized to serve files, otherwise, it will be unauthorized. The default value is `true`. Usefull for example to allow access files only in development mode (with for example S3 storage on production). +- `allowSymlinks` (boolean) : Allow serving symlinks (default `false`). When `false`, symlinks are rejected. +- `allowDotfiles` (boolean) : Allow dotfiles like `.env` (default `false`). When `false`, dotfiles are hidden. ## Usage To use the uploadDirectory hook, define a route in your Wobe application and pass the directory path as an option. The hook will handle requests to this route by serving the specified file from the directory. ```ts -import { Wobe, uploadDirectory } from 'wobe'; +import { Wobe, uploadDirectory } from 'wobe' const app = new Wobe() - .get('/bucket/:filename', uploadDirectory({ - directory: './path/to/your/directory', - })) - .listen(3000); + .get( + '/bucket/:filename', + uploadDirectory({ + directory: './path/to/your/directory', + }) + ) + .listen(3000) ``` ## Error Handling The `uploadDirectory` hook handles errors gracefully by providing appropriate HTTP responses for common issues: -- **Missing Filename Parameter**: If the `filename` parameter is missing in the request, the hook will respond with a `400 Bad Request` status and the message "Filename is required". +- **Missing Filename Parameter**: If the `filename` parameter is missing in the request, the hook will respond with a `400 Bad Request` status and the message "Filename is required". ```ts - const response = await fetch('http://localhost:3000/bucket/'); - console.log(response.status); // 400 - console.log(await response.text()); // "Filename is required" +const response = await fetch('http://localhost:3000/bucket/') +console.log(response.status) // 400 +console.log(await response.text()) // "Filename is required" ``` -- **File Not Found**: If the file specified by the `filename` parameter does not exist in the directory, the hook will respond with a `404 Not Found` status and the message "File not found". +- **File Not Found**: If the file specified by the `filename` parameter does not exist in the directory, the hook will respond with a `404 Not Found` status and the message "File not found". ```ts - const response = await fetch('http://localhost:3000/bucket/non-existent-file.txt'); - console.log(response.status); // 404 - console.log(await response.text()); // "File not found" +const response = await fetch( + 'http://localhost:3000/bucket/non-existent-file.txt' +) +console.log(response.status) // 404 +console.log(await response.text()) // "File not found" ``` + +- **Traversal or forbidden path**: Paths that escape the configured directory (e.g., `../secret`) return `403 Forbidden`. By default, dotfiles and symlinks are also blocked unless explicitly allowed. diff --git a/packages/wobe/src/Wobe.test.ts b/packages/wobe/src/Wobe.test.ts index dc9f347..e89a51a 100644 --- a/packages/wobe/src/Wobe.test.ts +++ b/packages/wobe/src/Wobe.test.ts @@ -95,6 +95,9 @@ describe('Wobe', () => { .get('/test', (ctx) => { return ctx.res.send('Test') }) + .post('/test', (ctx) => { + return ctx.res.send('Test') + }) .post('/testRequestBodyCache', async (ctx) => { return ctx.res.send(await ctx.request.text()) }) @@ -161,6 +164,10 @@ describe('Wobe', () => { '/test/', csrf({ origin: `http://127.0.0.1:${port}` }), ) + .beforeHandler( + '/test', + csrf({ origin: `http://127.0.0.1:${port}` }), + ) .beforeAndAfterHandler(logger()) .beforeHandler('/testBearer', bearerAuth({ token: '123' })) .beforeHandler('/test/*', mockHookWithWildcardRoute) @@ -467,9 +474,11 @@ describe('Wobe', () => { it('should not block requests with valid origin', async () => { const res = await fetch(`http://127.0.0.1:${port}/test`, { + method: 'POST', headers: { origin: `http://127.0.0.1:${port}`, }, + body: 'payload', }) expect(res.status).toBe(200) @@ -477,9 +486,11 @@ describe('Wobe', () => { it('should block requests with invalid origin', async () => { const res = await fetch(`http://127.0.0.1:${port}/test`, { + method: 'POST', headers: { origin: 'invalid-origin', }, + body: 'payload', }) expect(res.status).toBe(403) diff --git a/packages/wobe/src/Wobe.ts b/packages/wobe/src/Wobe.ts index 17d31f7..b0525ef 100644 --- a/packages/wobe/src/Wobe.ts +++ b/packages/wobe/src/Wobe.ts @@ -9,6 +9,21 @@ export interface WobeOptions { hostname?: string onError?: (error: Error) => void onNotFound?: (request: Request) => void + /** + * Maximum accepted body size in bytes (default: 1 MiB). + * Used by adapters to reject overly large requests early. + */ + maxBodySize?: number + /** + * Allowed content-encodings. If undefined, only identity/empty is allowed. + * Example: ['identity', 'gzip', 'deflate']. + */ + allowedContentEncodings?: string[] + /** + * Trust proxy headers (X-Forwarded-For) for client IP detection. + * Default false to avoid spoofing. + */ + trustProxy?: boolean tls?: { key: string cert: string @@ -268,7 +283,13 @@ export class Wobe { * @param webSocketHandler The WebSocket handler */ useWebSocket(webSocketHandler: WobeWebSocket) { - this.webSocket = webSocketHandler + this.webSocket = { + maxPayloadLength: 1024 * 1024, // 1 MiB + idleTimeout: 60, + backpressureLimit: 1024 * 1024, + closeOnBackpressureLimit: true, + ...webSocketHandler, + } return this } diff --git a/packages/wobe/src/WobeResponse.test.ts b/packages/wobe/src/WobeResponse.test.ts index 24ac652..72af6f6 100644 --- a/packages/wobe/src/WobeResponse.test.ts +++ b/packages/wobe/src/WobeResponse.test.ts @@ -256,6 +256,34 @@ describe('Wobe Response', () => { ) }) + it('should reject invalid cookie name', () => { + const wobeResponse = new WobeResponse( + new Request('http://localhost:3000/test'), + ) + + expect(() => wobeResponse.setCookie('bad name', 'value')).toThrow() + }) + + it('should reject cookie value with CRLF', () => { + const wobeResponse = new WobeResponse( + new Request('http://localhost:3000/test'), + ) + + expect(() => wobeResponse.setCookie('safe', 'val\r\nue')).toThrow() + }) + + it('should encode dangerous cookie value', () => { + const wobeResponse = new WobeResponse( + new Request('http://localhost:3000/test'), + ) + + wobeResponse.setCookie('safe', 'value;inject') + + expect(wobeResponse.headers?.get('Set-Cookie')).toBe( + 'safe=value%3Binject;', + ) + }) + it('should delete a cookie from a response', () => { const wobeResponse = new WobeResponse( new Request('http://localhost:3000/test'), diff --git a/packages/wobe/src/WobeResponse.ts b/packages/wobe/src/WobeResponse.ts index e733ed1..699e135 100644 --- a/packages/wobe/src/WobeResponse.ts +++ b/packages/wobe/src/WobeResponse.ts @@ -15,6 +15,19 @@ export class WobeResponse { public status = 200 public statusText = 'OK' + private static isCookieNameValid(name: string) { + return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name) + } + + private static sanitizeCookieValue(value: string) { + if (/[\r\n]/.test(value)) + throw new Error('Invalid cookie value: contains CR/LF') + + if (value.includes(';')) return encodeURIComponent(value) // avoid header injection + + return value + } + constructor(request: Request) { this.request = request } @@ -46,7 +59,12 @@ export class WobeResponse { * @param options The options of the cookie */ setCookie(name: string, value: string, options?: SetCookieOptions) { - let cookie = `${name}=${value};` + if (!WobeResponse.isCookieNameValid(name)) + throw new Error('Invalid cookie name') + + const safeValue = WobeResponse.sanitizeCookieValue(value) + + let cookie = `${name}=${safeValue};` if (options) { const { diff --git a/packages/wobe/src/adapters/bun/bun.test.ts b/packages/wobe/src/adapters/bun/bun.test.ts index ace6492..32607d4 100644 --- a/packages/wobe/src/adapters/bun/bun.test.ts +++ b/packages/wobe/src/adapters/bun/bun.test.ts @@ -4,6 +4,7 @@ import { Wobe } from '../../Wobe' import { HttpException } from '../../HttpException' import { join } from 'node:path' import { readFile } from 'node:fs/promises' +import { gzipSync } from 'node:zlib' describe.skipIf(process.env.NODE_TEST === 'true')('Bun server', () => { const spyBunServer = spyOn(global.Bun, 'serve') @@ -127,10 +128,94 @@ describe.skipIf(process.env.NODE_TEST === 'true')('Bun server', () => { const fileContent = await readFile(filePath) const responseArrayBuffer = await response.arrayBuffer() - expect( - // @ts-expect-error - Buffer.from(responseArrayBuffer).equals(Buffer.from(fileContent)), - ).toBe(true) + const respBuffer = Buffer.from(responseArrayBuffer) + // @ts-expect-error + expect(Buffer.compare(respBuffer, fileContent)).toBe(0) + + wobe.stop() + }) + + it('should reject payloads above the configured maxBodySize', async () => { + const port = await getPort() + const wobe = new Wobe({ maxBodySize: 8 }) + + wobe.post('/echo', async (ctx) => + ctx.res.sendText(await ctx.request.text()), + ) + + wobe.listen(port) + + const response = await fetch(`http://127.0.0.1:${port}/echo`, { + method: 'POST', + body: 'too-long-payload', + }) + + expect(response.status).toBe(413) + + wobe.stop() + }) + + it('should reject unsupported content-encoding', async () => { + const port = await getPort() + const wobe = new Wobe() + + wobe.post('/echo', (ctx) => ctx.res.sendText('ok')) + + wobe.listen(port) + + const response = await fetch(`http://127.0.0.1:${port}/echo`, { + method: 'POST', + headers: { + 'content-encoding': 'gzip', + }, + body: 'hello', + } as any) + + expect(response.status).toBe(415) + + wobe.stop() + }) + + it('should enforce decompressed size limit for gzip payloads', async () => { + const port = await getPort() + const wobe = new Wobe({ + maxBodySize: 16, + allowedContentEncodings: ['gzip'], + }) + + wobe.post('/echo', async (ctx) => + ctx.res.sendText(await ctx.request.text()), + ) + + wobe.listen(port) + + const largeBody = 'x'.repeat(64) + const gzipped = gzipSync(largeBody) + + const response = await fetch(`http://127.0.0.1:${port}/echo`, { + method: 'POST', + headers: { 'content-encoding': 'gzip' }, + body: gzipped, + } as any) + + expect(response.status).toBe(413) + + wobe.stop() + }) + + it('should use x-forwarded-for when trustProxy is enabled', async () => { + const port = await getPort() + const wobe = new Wobe({ trustProxy: true }) + + wobe.get('/ip', (ctx) => ctx.res.sendText(ctx.getIpAdress())) + + wobe.listen(port) + + const response = await fetch(`http://127.0.0.1:${port}/ip`, { + headers: { 'x-forwarded-for': '198.51.100.5' }, + }) + + expect(await response.text()).toBe('198.51.100.5') wobe.stop() }) diff --git a/packages/wobe/src/adapters/bun/bun.ts b/packages/wobe/src/adapters/bun/bun.ts index 5c1e3a8..36f88af 100644 --- a/packages/wobe/src/adapters/bun/bun.ts +++ b/packages/wobe/src/adapters/bun/bun.ts @@ -4,6 +4,53 @@ import { HttpException } from '../../HttpException' import type { WobeOptions, WobeWebSocket } from '../../Wobe' import type { RadixTree } from '../../router' import { bunWebSocket } from './websocket' +import { brotliDecompressSync, gunzipSync, inflateSync } from 'node:zlib' + +const DEFAULT_MAX_BODY_SIZE = 1024 * 1024 // 1 MiB + +const normalizeEncodings = (encodings?: string[]) => + (encodings || ['identity', '']).map((e) => e.toLowerCase()) + +const isHostHeaderValid = (host?: string | null) => { + if (!host) return false + if (host.includes(',')) return false + return /^[A-Za-z0-9.-]+(:\d+)?$/.test(host.trim()) +} + +const parseForwardedIp = (xff?: string | null) => { + if (!xff) return undefined + const first = xff.split(',')[0]?.trim() + return first && first.length <= 100 ? first : undefined +} + +const decompressBody = ( + encoding: string, + buffer: Uint8Array, + maxBodySize: number, +) => { + const lower = encoding.toLowerCase() + if (lower === 'identity' || lower === '') return Buffer.from(buffer) + + let decompressed: Buffer + + switch (lower) { + case 'gzip': + decompressed = gunzipSync(buffer) + break + case 'deflate': + decompressed = inflateSync(buffer) + break + case 'br': + decompressed = brotliDecompressSync(buffer) + break + default: + throw new Error('UNSUPPORTED_ENCODING') + } + + if (decompressed.length > maxBodySize) throw new Error('PAYLOAD_TOO_LARGE') + + return decompressed +} export const BunAdapter = (): RuntimeAdapter => ({ createServer: ( @@ -19,11 +66,92 @@ export const BunAdapter = (): RuntimeAdapter => ({ development: process.env.NODE_ENV !== 'production', websocket: bunWebSocket(webSocket), async fetch(req, server) { + const maxBodySize = + options?.maxBodySize ?? DEFAULT_MAX_BODY_SIZE + const allowedContentEncodings = normalizeEncodings( + options?.allowedContentEncodings, + ) + + const hostHeader = req.headers.get('host') + if (!isHostHeaderValid(hostHeader)) + return new Response(null, { status: 400 }) + + const expectHeader = req.headers.get('expect') + if (expectHeader) return new Response(null, { status: 417 }) + try { - const context = new Context(req, router) + const contentEncoding = + req.headers.get('content-encoding')?.toLowerCase() || + 'identity' + + if (!allowedContentEncodings.includes(contentEncoding)) + return new Response('Unsupported Content-Encoding', { + status: 415, + }) - context.getIpAdress = () => - this.requestIP(req)?.address || '' + // Validate declared content-length before reading + const contentLengthHeader = + req.headers.get('content-length') || '0' + const parsedLength = Number(contentLengthHeader) + + if ( + !Number.isNaN(parsedLength) && + parsedLength > maxBodySize + ) + return new Response(null, { status: 413 }) + + let requestForContext = req + + if ( + req.method !== 'GET' && + req.method !== 'HEAD' && + req.method !== 'OPTIONS' + ) { + const rawBody = new Uint8Array(await req.arrayBuffer()) + + if (rawBody.byteLength > maxBodySize) + return new Response(null, { status: 413 }) + + let decodedBody: Buffer + try { + decodedBody = decompressBody( + contentEncoding, + rawBody, + maxBodySize, + ) + } catch (err: any) { + if (err?.message === 'UNSUPPORTED_ENCODING') + return new Response( + 'Unsupported Content-Encoding', + { status: 415 }, + ) + if (err?.message === 'PAYLOAD_TOO_LARGE') + return new Response(null, { status: 413 }) + + return new Response('Invalid compressed body', { + status: 400, + }) + } + + requestForContext = new Request(req.url, { + method: req.method, + headers: req.headers, + body: decodedBody, + }) + } + + const context = new Context(requestForContext, router) + + context.getIpAdress = () => { + if (options?.trustProxy) { + const forwarded = parseForwardedIp( + req.headers.get('x-forwarded-for'), + ) + if (forwarded) return forwarded + } + + return this.requestIP(req)?.address || '' + } if (webSocket && webSocket.path === context.pathname) { // We need to run hook sequentially diff --git a/packages/wobe/src/adapters/node/node.test.ts b/packages/wobe/src/adapters/node/node.test.ts index e7cff6e..9ad1199 100644 --- a/packages/wobe/src/adapters/node/node.test.ts +++ b/packages/wobe/src/adapters/node/node.test.ts @@ -6,6 +6,7 @@ import getPort from 'get-port' import { HttpException } from '../../HttpException' import { join } from 'node:path' import { readFile } from 'node:fs/promises' +import { gzipSync } from 'node:zlib' describe.skipIf(process.env.NODE_TEST !== 'true')('Node server', () => { const spyCreateHttpServer = spyOn(nodeHttp, 'createServer') @@ -117,9 +118,120 @@ describe.skipIf(process.env.NODE_TEST !== 'true')('Node server', () => { const fileContent = await readFile(filePath) const responseArrayBuffer = await response.arrayBuffer() - expect( - Buffer.from(responseArrayBuffer).equals(Buffer.from(fileContent)), - ).toBe(true) + const respBuffer = Buffer.from(responseArrayBuffer) + // @ts-expect-error + expect(Buffer.compare(respBuffer, fileContent)).toBe(0) + + wobe.stop() + }) + + it('should reject payloads above the configured maxBodySize', async () => { + const port = await getPort() + const wobe = new Wobe({ maxBodySize: 5 }) + + wobe.post('/echo', async (ctx) => { + const text = await ctx.request.text() + return ctx.res.sendText(text) + }) + + wobe.listen(port) + + const response = await fetch(`http://127.0.0.1:${port}/echo`, { + method: 'POST', + body: '123456', + }) + + expect(response.status).toBe(413) + + wobe.stop() + }) + + it('should accept payloads under maxBodySize', async () => { + const port = await getPort() + const wobe = new Wobe({ maxBodySize: 10 }) + + wobe.post('/echo', async (ctx) => { + const text = await ctx.request.text() + return ctx.res.sendText(text) + }) + + wobe.listen(port) + + const response = await fetch(`http://127.0.0.1:${port}/echo`, { + method: 'POST', + body: '1234', + }) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('1234') + + wobe.stop() + }) + + it('should reject unsupported content-encoding', async () => { + const port = await getPort() + const wobe = new Wobe() + + wobe.post('/echo', (ctx) => ctx.res.sendText('ok')) + + wobe.listen(port) + + const response = await fetch(`http://127.0.0.1:${port}/echo`, { + method: 'POST', + headers: { + 'content-encoding': 'gzip', + }, + body: 'hello', + } as any) + + expect(response.status).toBe(415) + + wobe.stop() + }) + + it('should enforce decompressed size limit for gzip payloads', async () => { + const port = await getPort() + const wobe = new Wobe({ + maxBodySize: 16, + allowedContentEncodings: ['gzip'], + }) + + wobe.post('/echo', async (ctx) => { + const text = await ctx.request.text() + return ctx.res.sendText(text) + }) + + wobe.listen(port) + + const largeBody = 'x'.repeat(32) + const gzipped = gzipSync(largeBody) + + const response = await fetch(`http://127.0.0.1:${port}/echo`, { + method: 'POST', + headers: { + 'content-encoding': 'gzip', + }, + body: gzipped, + } as any) + + expect(response.status).toBe(413) + + wobe.stop() + }) + + it('should use x-forwarded-for when trustProxy is enabled', async () => { + const port = await getPort() + const wobe = new Wobe({ trustProxy: true }) + + wobe.get('/ip', (ctx) => ctx.res.sendText(ctx.getIpAdress())) + + wobe.listen(port) + + const response = await fetch(`http://127.0.0.1:${port}/ip`, { + headers: { 'x-forwarded-for': '203.0.113.10' }, + }) + + expect(await response.text()).toBe('203.0.113.10') wobe.stop() }) diff --git a/packages/wobe/src/adapters/node/node.ts b/packages/wobe/src/adapters/node/node.ts index de726b7..1e338d6 100644 --- a/packages/wobe/src/adapters/node/node.ts +++ b/packages/wobe/src/adapters/node/node.ts @@ -1,11 +1,70 @@ import { createServer as createHttpServer } from 'node:http' import { createServer as createHttpsServer } from 'node:https' +import { brotliDecompressSync, gunzipSync, inflateSync } from 'node:zlib' import { HttpException } from '../../HttpException' import { Context } from '../../Context' import type { RuntimeAdapter } from '..' import type { RadixTree } from '../../router' import type { WobeOptions } from '../../Wobe' +const DEFAULT_MAX_BODY_SIZE = 1024 * 1024 // 1 MiB + +const normalizeEncodings = (encodings?: string[]) => + (encodings || ['identity', '']).map((e) => e.toLowerCase()) + +const isHostHeaderValid = (host?: string | string[]) => { + if (!host || Array.isArray(host)) return false + if (host.includes(',')) return false + // Allow hostname with optional port + return /^[A-Za-z0-9.-]+(:\d+)?$/.test(host.trim()) +} + +const parseForwardedIp = (xff?: string | string[]) => { + if (!xff || Array.isArray(xff)) return undefined + const first = xff.split(',')[0]?.trim() + return first && first.length <= 100 ? first : undefined +} + +const getClientIp = (req: any, trustProxy?: boolean) => { + if (trustProxy) { + const forwarded = parseForwardedIp( + req.headers['x-forwarded-for'] as string, + ) + if (forwarded) return forwarded + } + + return req.socket.remoteAddress || '' +} + +const decompressBody = ( + encoding: string, + buffer: Uint8Array, + maxBodySize: number, +): Uint8Array => { + const lower = encoding.toLowerCase() + if (lower === 'identity' || lower === '') return buffer + + let decompressed: Buffer + + switch (lower) { + case 'gzip': + decompressed = gunzipSync(buffer) + break + case 'deflate': + decompressed = inflateSync(buffer) + break + case 'br': + decompressed = brotliDecompressSync(buffer) + break + default: + throw new Error('UNSUPPORTED_ENCODING') + } + + if (decompressed.length > maxBodySize) throw new Error('PAYLOAD_TOO_LARGE') + + return new Uint8Array(decompressed) +} + const transformResponseInstanceToValidResponse = async (response: Response) => { const headers: Record = {} @@ -32,24 +91,78 @@ export const NodeAdapter = (): RuntimeAdapter => ({ ? createHttpsServer : createHttpServer const certificateObject = options?.tls || {} + const maxBodySize = options?.maxBodySize ?? DEFAULT_MAX_BODY_SIZE + const allowedContentEncodings = normalizeEncodings( + options?.allowedContentEncodings, + ) return createServer(certificateObject, async (req, res) => { const url = `http://${req.headers.host}${req.url}` - let body = '' + // Basic Host and Expect validation to avoid smuggling/ambiguous routing + if (!isHostHeaderValid(req.headers.host)) { + res.writeHead(400) + res.end() + return + } + + if (req.headers.expect) { + res.writeHead(417) + res.end() + return + } + + const contentEncoding = + ( + req.headers['content-encoding'] as string | undefined + )?.toLowerCase() || 'identity' + + if (!allowedContentEncodings.includes(contentEncoding)) { + res.writeHead(415) + res.end('Unsupported Content-Encoding') + return + } + + const chunks: Uint8Array[] = [] + let receivedLength = 0 req.on('data', (chunk) => { - body += chunk + if (receivedLength > maxBodySize) return + + receivedLength += chunk.length + + if (receivedLength > maxBodySize) { + res.writeHead(413) + res.end() + req.destroy() + return + } + + chunks.push(new Uint8Array(chunk)) }) req.on('end', async () => { try { + let bodyBuffer: Uint8Array | undefined + + if (req.method !== 'GET' && req.method !== 'HEAD') { + const rawBuffer = Buffer.concat(chunks) + const decompressed = decompressBody( + contentEncoding, + new Uint8Array( + rawBuffer.buffer, + rawBuffer.byteOffset, + rawBuffer.byteLength, + ), + maxBodySize, + ) + + bodyBuffer = decompressed + } + const request = new Request(url, { method: req.method, headers: req.headers as any, - body: - req.method !== 'GET' && req.method !== 'HEAD' - ? body - : undefined, + body: bodyBuffer, }) const context = new Context(request, router) @@ -62,7 +175,8 @@ export const NodeAdapter = (): RuntimeAdapter => ({ return } - context.getIpAdress = () => req.socket.remoteAddress || '' + context.getIpAdress = () => + getClientIp(req, options?.trustProxy) || '' const response = await context.executeHandler() @@ -77,11 +191,36 @@ export const NodeAdapter = (): RuntimeAdapter => ({ res.write(responseBody) } catch (err: any) { + if (err?.message === 'PAYLOAD_TOO_LARGE') { + res.writeHead(413) + res.end() + return + } + + if (err?.message === 'UNSUPPORTED_ENCODING') { + res.writeHead(415) + res.end('Unsupported Content-Encoding') + return + } + + // zlib errors on malformed compressed bodies + if (err?.code === 'Z_DATA_ERROR') { + res.writeHead(400) + res.end('Invalid compressed body') + return + } + if (err instanceof Error) options?.onError?.(err) if (!(err instanceof HttpException)) { - res.writeHead(Number(err.code) || 500) - res.write(err.message) + const statusCode = Number(err.code) || 500 + const message = + err instanceof Error + ? err.message + : 'Internal Server Error' + + res.writeHead(statusCode) + res.write(message) res.end() return diff --git a/packages/wobe/src/hooks/bearerAuth.test.ts b/packages/wobe/src/hooks/bearerAuth.test.ts index 98e08b5..7fc4fa3 100644 --- a/packages/wobe/src/hooks/bearerAuth.test.ts +++ b/packages/wobe/src/hooks/bearerAuth.test.ts @@ -96,4 +96,21 @@ describe('BearerAuth', () => { expect(() => handler(context)).toThrow() }) + + it('should reject tokens with different length using timing safe compare', () => { + const request = new Request('http://localhost:3000/test', { + headers: { + Authorization: 'Bearer longer-token', + }, + }) + + const handler = bearerAuth({ + token: 'short', + hashFunction: (token) => token, // disable hashing to create length diff + }) + + const context = new Context(request) + + expect(() => handler(context)).toThrow() + }) }) diff --git a/packages/wobe/src/hooks/bearerAuth.ts b/packages/wobe/src/hooks/bearerAuth.ts index c413fa3..d590501 100644 --- a/packages/wobe/src/hooks/bearerAuth.ts +++ b/packages/wobe/src/hooks/bearerAuth.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto' +import { createHash, timingSafeEqual } from 'node:crypto' import { HttpException } from '../HttpException' import type { WobeHandler } from '../Wobe' @@ -21,6 +21,9 @@ export const bearerAuth = ({ hashFunction = defaultHash, realm = '', }: BearerAuthOptions): WobeHandler => { + const toBytes = (value: string) => new Uint8Array(Buffer.from(value)) + const hashedToken = toBytes(hashFunction(token)) + return (ctx) => { const requestAuthorization = ctx.request.headers.get('Authorization') @@ -46,10 +49,12 @@ export const bearerAuth = ({ const requestToken = requestAuthorization.slice(prefix.length).trim() - const hashedRequestToken = hashFunction(requestToken) - const hashedToken = hashFunction(token) + const hashedRequestToken = toBytes(hashFunction(requestToken)) - if (hashedToken !== hashedRequestToken) + if ( + hashedRequestToken.length !== hashedToken.length || + !timingSafeEqual(hashedToken, hashedRequestToken) + ) throw new HttpException( new Response('Unauthorized', { status: 401, diff --git a/packages/wobe/src/hooks/csrf.test.ts b/packages/wobe/src/hooks/csrf.test.ts index 4af429a..f497b54 100644 --- a/packages/wobe/src/hooks/csrf.test.ts +++ b/packages/wobe/src/hooks/csrf.test.ts @@ -5,6 +5,7 @@ import { Context } from '../Context' describe('Csrf hook', () => { it('should not block requests with a valid origin (string)', () => { const request = new Request('http://localhost:3000/test', { + method: 'POST', headers: { origin: 'http://localhost:3000', }, @@ -20,6 +21,7 @@ describe('Csrf hook', () => { it('should not block requests with a valid origin (array)', () => { const request = new Request('http://localhost:3000/test', { + method: 'POST', headers: { origin: 'http://localhost:3000', }, @@ -37,6 +39,7 @@ describe('Csrf hook', () => { it('should not block requests with a valid origin (function)', () => { const request = new Request('http://localhost:3000/test', { + method: 'POST', headers: { origin: 'http://localhost:3000', }, @@ -53,7 +56,9 @@ describe('Csrf hook', () => { }) it('should block requests with an invalid origin (string)', async () => { - const request = new Request('http://localhost:3000/test', {}) + const request = new Request('http://localhost:3000/test', { + method: 'POST', + }) const handler = csrf({ origin: 'http://localhost:3000' }) @@ -65,6 +70,7 @@ describe('Csrf hook', () => { it('should block requests with an invalid origin (array)', () => { const request = new Request('http://localhost:3000/test', { + method: 'POST', headers: { origin: 'http://localhost:3001', }, @@ -82,6 +88,7 @@ describe('Csrf hook', () => { it('should block requests with an invalid origin (function)', () => { const request = new Request('http://localhost:3000/test', { + method: 'POST', headers: { origin: 'http://localhost:3001', }, @@ -96,4 +103,43 @@ describe('Csrf hook', () => { expect(() => handler(context)).toThrow() }) + + it('should allow requests with valid referer when origin is missing', () => { + const request = new Request('http://localhost:3000/test', { + method: 'POST', + headers: { + referer: 'http://localhost:3000/form', + }, + }) + + const handler = csrf({ origin: 'http://localhost:3000' }) + const context = new Context(request) + + expect(() => handler(context)).not.toThrow() + }) + + it('should block requests with cross-site referer when origin is missing', () => { + const request = new Request('http://localhost:3000/test', { + method: 'POST', + headers: { + referer: 'http://evil.com/attack', + }, + }) + + const handler = csrf({ origin: 'http://localhost:3000' }) + const context = new Context(request) + + expect(() => handler(context)).toThrow() + }) + + it('should ignore CSRF check for safe methods', () => { + const request = new Request('http://localhost:3000/test', { + method: 'GET', + }) + + const handler = csrf({ origin: 'http://localhost:3000' }) + const context = new Context(request) + + expect(() => handler(context)).not.toThrow() + }) }) diff --git a/packages/wobe/src/hooks/csrf.ts b/packages/wobe/src/hooks/csrf.ts index d54b46a..77038c4 100644 --- a/packages/wobe/src/hooks/csrf.ts +++ b/packages/wobe/src/hooks/csrf.ts @@ -8,6 +8,7 @@ export interface CsrfOptions { } const isSameOrigin = (optsOrigin: Origin, requestOrigin: string) => { + if (!requestOrigin) return false if (typeof optsOrigin === 'string') return optsOrigin === requestOrigin if (typeof optsOrigin === 'function') return optsOrigin(requestOrigin) @@ -24,11 +25,49 @@ const isSameOrigin = (optsOrigin: Origin, requestOrigin: string) => { */ export const csrf = (options: CsrfOptions): WobeHandler => { return (ctx) => { - const requestOrigin = ctx.request.headers.get('origin') || '' + const method = ctx.request.method?.toUpperCase?.() - if (!isSameOrigin(options.origin, requestOrigin)) - throw new HttpException( - new Response('CSRF: Invalid origin', { status: 403 }), - ) + // Only enforce on non-idempotent methods + if ( + !method || + method === 'GET' || + method === 'HEAD' || + method === 'OPTIONS' + ) + return + + const requestOrigin = ctx.request.headers.get('origin') + const requestReferer = ctx.request.headers.get('referer') + + // Prefer Origin when available + if (requestOrigin) { + if (!isSameOrigin(options.origin, requestOrigin)) + throw new HttpException( + new Response('CSRF: Invalid origin', { + status: 403, + statusText: 'Forbidden', + }), + ) + + return + } + + if (requestReferer) { + try { + const refererHost = new URL(requestReferer).host + const requestHost = new URL(ctx.request.url).host + + if (refererHost === requestHost) return + } catch { + // fallthrough to rejection + } + } + + throw new HttpException( + new Response('CSRF: Invalid origin', { + status: 403, + statusText: 'Forbidden', + }), + ) } } diff --git a/packages/wobe/src/hooks/secureHeaders.test.ts b/packages/wobe/src/hooks/secureHeaders.test.ts index 85d4212..d2efa5d 100644 --- a/packages/wobe/src/hooks/secureHeaders.test.ts +++ b/packages/wobe/src/hooks/secureHeaders.test.ts @@ -271,4 +271,26 @@ describe('Secure headers', () => { 'random-value', ) }) + + it('should set default X-Frame-Options', () => { + const request = new Request('http://localhost:3000/test', {}) + + const handler = secureHeaders({}) + const context = new Context(request) + + handler(context) + + expect(context.res.headers.get('X-Frame-Options')).toEqual('SAMEORIGIN') + }) + + it('should disable X-Frame-Options when set to false', () => { + const request = new Request('http://localhost:3000/test', {}) + + const handler = secureHeaders({ xFrameOptions: false }) + const context = new Context(request) + + handler(context) + + expect(context.res.headers.get('X-Frame-Options')).toBeNull() + }) }) diff --git a/packages/wobe/src/hooks/secureHeaders.ts b/packages/wobe/src/hooks/secureHeaders.ts index 0e0b994..98ef674 100644 --- a/packages/wobe/src/hooks/secureHeaders.ts +++ b/packages/wobe/src/hooks/secureHeaders.ts @@ -34,6 +34,7 @@ export interface SecureHeadersOptions { strictTransportSecurity?: string[] xContentTypeOptions?: string xDownloadOptions?: string + xFrameOptions?: string | false } /** @@ -48,6 +49,7 @@ export const secureHeaders = ({ strictTransportSecurity = ['max-age=31536000; includeSubDomains'], xContentTypeOptions = 'nosniff', xDownloadOptions = 'noopen', + xFrameOptions = 'SAMEORIGIN', }: SecureHeadersOptions): WobeHandler => { return (ctx) => { if (contentSecurityPolicy) { @@ -100,5 +102,7 @@ export const secureHeaders = ({ if (xDownloadOptions) ctx.res.headers.set('X-Download-Options', xDownloadOptions) + + if (xFrameOptions) ctx.res.headers.set('X-Frame-Options', xFrameOptions) } } diff --git a/packages/wobe/src/hooks/uploadDirectory.test.ts b/packages/wobe/src/hooks/uploadDirectory.test.ts index 96fa01c..63ca6c9 100644 --- a/packages/wobe/src/hooks/uploadDirectory.test.ts +++ b/packages/wobe/src/hooks/uploadDirectory.test.ts @@ -1,19 +1,23 @@ import { describe, expect, it, beforeEach, afterEach } from 'bun:test' import { uploadDirectory } from './uploadDirectory' import { join } from 'node:path' -import { mkdir, writeFile, rm } from 'node:fs/promises' +import { mkdir, writeFile, rm, symlink } from 'node:fs/promises' import getPort from 'get-port' import { Wobe } from '../Wobe' +import { Context } from '../Context' describe('UploadDirectory Hook', () => { const testDirectory = join(__dirname, 'test-bucket') const fileName = 'test-file.txt' const filePath = join(testDirectory, fileName) + const dotFileName = '.env' + const dotFilePath = join(testDirectory, dotFileName) beforeEach(async () => { // Create a test directory and file before each test await mkdir(testDirectory, { recursive: true }) await writeFile(filePath, 'This is a test file.') + await writeFile(dotFilePath, 'SECRET') }) afterEach(async () => { @@ -66,6 +70,57 @@ describe('UploadDirectory Hook', () => { wobe.stop() }) + it('should forbid path traversal', async () => { + const handler = uploadDirectory({ directory: testDirectory }) + const request = new Request('http://localhost/bucket/../test') + const context = new Context(request) + + context.params = { filename: '../test-file' } + + const response = await handler(context) + + expect(response?.status).toBe(403) + }) + + it('should forbid dotfiles by default', async () => { + const port = await getPort() + const wobe = new Wobe() + + wobe.get( + '/bucket/:filename', + uploadDirectory({ directory: testDirectory }), + ) + + wobe.listen(port) + + const response = await fetch( + `http://127.0.0.1:${port}/bucket/${dotFileName}`, + ) + + expect(response.status).toBe(404) + wobe.stop() + }) + + it('should forbid symlinks when not allowed', async () => { + const port = await getPort() + const wobe = new Wobe() + + const symlinkPath = join(testDirectory, 'link.txt') + await symlink(filePath, symlinkPath) + + wobe.get( + '/bucket/:filename', + uploadDirectory({ directory: testDirectory }), + ) + + wobe.listen(port) + + const response = await fetch(`http://127.0.0.1:${port}/bucket/link.txt`) + + expect(response.status).toBe(403) + wobe.stop() + }) + it('should return 400 if the filename parameter is missing', async () => { const port = await getPort() const wobe = new Wobe() diff --git a/packages/wobe/src/hooks/uploadDirectory.ts b/packages/wobe/src/hooks/uploadDirectory.ts index b559e3a..290cedf 100644 --- a/packages/wobe/src/hooks/uploadDirectory.ts +++ b/packages/wobe/src/hooks/uploadDirectory.ts @@ -1,22 +1,39 @@ -import { access, constants, readFile } from 'node:fs/promises' -import { join, extname } from 'node:path' +import { access, constants, lstat, readFile } from 'node:fs/promises' +import { extname, resolve, sep } from 'node:path' import type { WobeHandler } from '../Wobe' import mimeTypes from '../utils' export interface UploadDirectoryOptions { directory: string isAuthorized?: boolean + allowSymlinks?: boolean + allowDotfiles?: boolean } /** - * uploadDirectory is a hook that allow you to access to all files in a directory - * You must provide the filename parameter in the route - * Usage: wobe.get('/bucket/:filename', uploadDirectory({ directory: './bucket', isAuthorized: true })) + * Serve a file from a given directory for routes like `/bucket/:filename`. + * It blocks traversal (`..`), dotfiles (by default), and symlinks (by default), + * and returns 400/401/403/404 with clear messages when access is not allowed. */ export const uploadDirectory = ({ directory, isAuthorized = true, + allowSymlinks = false, + allowDotfiles = false, }: UploadDirectoryOptions): WobeHandler => { + const resolvedRoot = resolve(directory) + + const isDotFile = (relativePath: string) => + relativePath + .split(/[\\/]/) + .filter((segment) => segment !== '' && segment !== '.') + .some((segment) => segment.startsWith('.')) + const hasTraversal = (relativePath: string) => + relativePath + .split(/[\\/]/) + .filter((segment) => segment.length > 0) + .some((segment) => segment === '..') + return async (ctx) => { if (!isAuthorized) { ctx.res.status = 401 @@ -30,11 +47,38 @@ export const uploadDirectory = ({ return ctx.res.sendText('Filename is required') } - const filePath = join(directory, fileName) + if (hasTraversal(fileName)) { + ctx.res.status = 403 + return ctx.res.sendText('Forbidden') + } + + // Protect against traversal and dotfiles + if (!allowDotfiles && isDotFile(fileName)) { + ctx.res.status = 404 + return ctx.res.sendText('File not found') + } + + const filePath = resolve(resolvedRoot, fileName) + + const isInsideRoot = + filePath === resolvedRoot || filePath.startsWith(resolvedRoot + sep) + + if (!isInsideRoot) { + ctx.res.status = 403 + return ctx.res.sendText('Forbidden') + } try { await access(filePath, constants.F_OK) + if (!allowSymlinks) { + const stats = await lstat(filePath) + if (stats.isSymbolicLink()) { + ctx.res.status = 403 + return ctx.res.sendText('Forbidden') + } + } + const fileContent = await readFile(filePath) const ext = extname(filePath).toLowerCase()