From f813e151c8f2a12fea1a317ef235ff87a81d5042 Mon Sep 17 00:00:00 2001 From: nicoske Date: Wed, 22 Jul 2026 20:40:38 +0200 Subject: [PATCH 1/2] fix(http): parse body before guards and return parsed body synchronously Guards ran before body parsing, and request.body kept returning a Promise even after the pipeline had parsed the body. Any guard that reads request.body (the usual shape of an auth guard that binds a token to payload fields) saw a pending Promise instead of the payload, and the same happened to schema validators that read request.body without awaiting (e.g. Nestia's @TypedBody). On Express none of this is an issue because body-parser middleware runs ahead of the guards. Parse the body before guard execution and store it on the request, and make the body getter return the parsed value directly once it is available. Awaiting request.body keeps working in both states, and pipes overwrite the stored body as before. Streaming content types (multipart, octet-stream) are untouched. The trade-off is that a request rejected by a guard now pays the body parse cost first, which is the same behavior as Express with global body-parser middleware. --- src/http/core/request.ts | 21 +-- src/http/routing/route-registry.ts | 38 ++--- test/http/guards-body-access.e2e.spec.ts | 168 +++++++++++++++++++++++ 3 files changed, 203 insertions(+), 24 deletions(-) create mode 100644 test/http/guards-body-access.e2e.spec.ts diff --git a/src/http/core/request.ts b/src/http/core/request.ts index 1d5acd3..d032939 100644 --- a/src/http/core/request.ts +++ b/src/http/core/request.ts @@ -1330,9 +1330,12 @@ export class UwsRequest extends Readable { /** * Get body based on content-type (convenience method) * - * **IMPORTANT**: Unlike Express, this returns a Promise because uWebSockets.js - * body parsing is inherently async. In NestJS, use the @Body() decorator instead - * of accessing this property directly. + * Once the middleware pipeline has parsed the body (or pipes have + * transformed it), this returns the parsed value directly, so guards and + * other synchronous readers observe the actual body like they would on + * Express. Before parsing it returns a Promise because uWebSockets.js + * body parsing is inherently async - `await request.body` works in both + * cases. * * Automatically parses the body based on the Content-Type header: * - application/json → json() @@ -1342,7 +1345,7 @@ export class UwsRequest extends Readable { * * @example * ```typescript - * // Must await the promise + * // Works whether or not the body has been parsed yet * const data = await request.body; * * // In NestJS, use decorators instead: @@ -1352,12 +1355,14 @@ export class UwsRequest extends Readable { * } * ``` * - * @returns Promise that resolves with the parsed body + * @returns The parsed body, or a Promise resolving to it when parsing has + * not happened yet */ - get body(): Promise { - // If body was transformed by pipes, return that instead of re-parsing + get body(): unknown { + // Once parsed (or transformed by pipes), return the value synchronously + // so non-awaiting readers like guards see the real body if (this.hasTransformedBody) { - return Promise.resolve(this.transformedBody); + return this.transformedBody; } // Use is() method for robust content-type matching diff --git a/src/http/routing/route-registry.ts b/src/http/routing/route-registry.ts index 689c877..af2c143 100644 --- a/src/http/routing/route-registry.ts +++ b/src/http/routing/route-registry.ts @@ -480,7 +480,28 @@ export class RouteRegistry { // Create execution context for middleware const context = new HttpExecutionContext(req, res, handler, metadata?.classRef); - // 1. Execute guards + // 1. Parse body if content-type header is present + // Parsing happens before guards so guards (and anything else that reads + // request.body without awaiting) observe the parsed value, matching the + // Express platform where body-parser middleware runs ahead of guards. + // Skip auto-parsing for streaming content types (application/octet-stream, multipart/form-data) + // These should be handled explicitly by the user via req.on('data') or req.multipart() + let body: unknown; + const contentType = req.contentType; + const normalizedContentType = contentType?.toLowerCase(); + const isStreamingContentType = + normalizedContentType && + (normalizedContentType.includes('application/octet-stream') || + normalizedContentType.includes('multipart/form-data')); + + if (normalizedContentType && !isStreamingContentType) { + body = await req.body; + // Store the parsed body so the request.body getter resolves + // synchronously from here on + req._setTransformedBody(body); + } + + // 2. Execute guards // Guards can either: // - Return false → 403 Forbidden (handled here) // - Throw exception → Propagates to exception filters (preserves HttpException status) @@ -498,21 +519,6 @@ export class RouteRegistry { } } - // 2. Parse body if content-type header is present - // Skip auto-parsing for streaming content types (application/octet-stream, multipart/form-data) - // These should be handled explicitly by the user via req.on('data') or req.multipart() - let body: unknown; - const contentType = req.contentType; - const normalizedContentType = contentType?.toLowerCase(); - const isStreamingContentType = - normalizedContentType && - (normalizedContentType.includes('application/octet-stream') || - normalizedContentType.includes('multipart/form-data')); - - if (normalizedContentType && !isStreamingContentType) { - body = await req.body; - } - // 3. Execute pipes on body // Run pipes if content-type was present (body was parsed), even for falsy values like null, 0, "", false // Skip pipes for streaming content types since body wasn't parsed diff --git a/test/http/guards-body-access.e2e.spec.ts b/test/http/guards-body-access.e2e.spec.ts new file mode 100644 index 0000000..08fd562 --- /dev/null +++ b/test/http/guards-body-access.e2e.spec.ts @@ -0,0 +1,168 @@ +// @ts-nocheck - NestJS decorators in test files cause false positive TypeScript errors +import { NestFactory } from '@nestjs/core'; +import { + Controller, + Post, + Req, + Res, + Module, + INestApplication, + CanActivate, + ExecutionContext, + UseGuards, + UnauthorizedException, +} from '@nestjs/common'; +import { UwsPlatformAdapter } from '../../src/http/platform/uws-platform.adapter'; +import { UwsResponse } from '../../src/http/core/response'; +import * as http from 'http'; + +// ============================================================================ +// Guard that reads request.body synchronously, like typical auth guards that +// bind a token to fields of the payload +// ============================================================================ + +let bodySeenByGuard: unknown; + +class BodyAuthGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const body = request.body; + bodySeenByGuard = body; + + if (!body || typeof body !== 'object' || typeof body.then === 'function') { + throw new UnauthorizedException('Missing payload'); + } + + return body.role === 'admin'; + } +} + +// ============================================================================ +// Controller +// ============================================================================ + +@Controller('guarded') +class GuardedController { + @Post('action') + @UseGuards(BodyAuthGuard) + action(@Res() res: UwsResponse) { + res.status(200).json({ ok: true }); + } +} + +@Controller('plain') +class PlainController { + @Post('echo') + echo(@Req() req, @Res() res: UwsResponse) { + // request.body is already parsed by the time the handler runs, so a + // synchronous read returns the value, not a Promise + res.status(200).json({ body: req.body, wasPromise: typeof req.body?.then === 'function' }); + } +} + +@Module({ + controllers: [GuardedController, PlainController], +}) +class TestModule {} + +// ============================================================================ +// E2E Tests +// ============================================================================ + +describe('Guards and body access E2E', () => { + let app: INestApplication; + let baseUrl: string; + const port = 13391; + + beforeAll(async () => { + const adapter = new UwsPlatformAdapter({ port }); + app = await NestFactory.create(TestModule, adapter, { logger: false }); + await app.init(); + + await new Promise((resolve, reject) => { + adapter.listen(port, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + + baseUrl = `http://localhost:${port}`; + }, 10000); + + afterAll(async () => { + if (app) { + await app.close(); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + }); + + beforeEach(() => { + bodySeenByGuard = undefined; + }); + + function request( + method: string, + path: string, + body?: string, + contentType = 'application/json' + ): Promise<{ status: number; body: Record }> { + return new Promise((resolve, reject) => { + const headers: Record = {}; + if (body !== undefined) { + headers['Content-Type'] = contentType; + headers['Content-Length'] = Buffer.byteLength(body).toString(); + } + + const req = http.request(`${baseUrl}${path}`, { method, agent: false, headers }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString(); + let parsed: Record; + try { + parsed = JSON.parse(raw); + } catch { + parsed = { raw }; + } + resolve({ status: res.statusCode || 0, body: parsed }); + }); + }); + req.setTimeout(5000, () => { + req.destroy(new Error(`${method} ${path} timed out`)); + }); + req.on('error', reject); + if (body !== undefined) req.write(body); + req.end(); + }); + } + + it('lets a guard read the parsed body synchronously', async () => { + const res = await request('POST', '/guarded/action', JSON.stringify({ role: 'admin' })); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + expect(bodySeenByGuard).toEqual({ role: 'admin' }); + }); + + it('rejects based on body content read inside the guard', async () => { + const res = await request('POST', '/guarded/action', JSON.stringify({ role: 'viewer' })); + + expect(res.status).toBe(403); + expect(bodySeenByGuard).toEqual({ role: 'viewer' }); + }); + + it('exposes the parsed body synchronously to handlers', async () => { + const res = await request('POST', '/plain/echo', JSON.stringify({ hello: 'world' })); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ body: { hello: 'world' }, wasPromise: false }); + }); + + it('still rejects malformed JSON before the handler runs', async () => { + const res = await request('POST', '/plain/echo', '{not json'); + + // Same status invalid JSON produced before the parse/guard reorder + // (see body-parsing.e2e.spec.ts) + expect(res.status).toBe(500); + }); +}); From 759d99fd7869fc3a23cf6520c52236ea265a6a43 Mon Sep 17 00:00:00 2001 From: nicoske Date: Wed, 29 Jul 2026 09:59:41 +0200 Subject: [PATCH 2/2] fix(http): parse non-streaming body without content-type before guards --- src/http/routing/route-registry.ts | 8 +++++--- test/http/guards-body-access.e2e.spec.ts | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/http/routing/route-registry.ts b/src/http/routing/route-registry.ts index af2c143..de0f565 100644 --- a/src/http/routing/route-registry.ts +++ b/src/http/routing/route-registry.ts @@ -480,10 +480,12 @@ export class RouteRegistry { // Create execution context for middleware const context = new HttpExecutionContext(req, res, handler, metadata?.classRef); - // 1. Parse body if content-type header is present - // Parsing happens before guards so guards (and anything else that reads + // 1. Parse body before guards so guards (and anything else that reads // request.body without awaiting) observe the parsed value, matching the // Express platform where body-parser middleware runs ahead of guards. + // Parse whenever the request is not a streaming type, including requests + // that carry a body without a Content-Type header (otherwise guards get + // the pending buffer() Promise instead of the stored body). // Skip auto-parsing for streaming content types (application/octet-stream, multipart/form-data) // These should be handled explicitly by the user via req.on('data') or req.multipart() let body: unknown; @@ -494,7 +496,7 @@ export class RouteRegistry { (normalizedContentType.includes('application/octet-stream') || normalizedContentType.includes('multipart/form-data')); - if (normalizedContentType && !isStreamingContentType) { + if (!isStreamingContentType) { body = await req.body; // Store the parsed body so the request.body getter resolves // synchronously from here on diff --git a/test/http/guards-body-access.e2e.spec.ts b/test/http/guards-body-access.e2e.spec.ts index 08fd562..2fa2fc4 100644 --- a/test/http/guards-body-access.e2e.spec.ts +++ b/test/http/guards-body-access.e2e.spec.ts @@ -104,12 +104,13 @@ describe('Guards and body access E2E', () => { method: string, path: string, body?: string, - contentType = 'application/json' + contentType: string | null = 'application/json' ): Promise<{ status: number; body: Record }> { return new Promise((resolve, reject) => { const headers: Record = {}; if (body !== undefined) { - headers['Content-Type'] = contentType; + // contentType === null sends a body WITHOUT a Content-Type header + if (contentType !== null) headers['Content-Type'] = contentType; headers['Content-Length'] = Buffer.byteLength(body).toString(); } @@ -158,6 +159,18 @@ describe('Guards and body access E2E', () => { expect(res.body).toEqual({ body: { hello: 'world' }, wasPromise: false }); }); + it('gives guards a concrete body, not a pending Promise, when the request has a body but no Content-Type header', async () => { + const res = await request('POST', '/guarded/action', JSON.stringify({ role: 'admin' }), null); + + // A body without a Content-Type is buffered (not JSON-decoded), so the guard + // cannot read .role and returns false -> 403. The point is that it saw a + // resolved value: before parsing moved ahead of guards it saw the pending + // buffer() Promise and threw 401 "Missing payload". + expect(res.status).toBe(403); + expect(bodySeenByGuard).toBeDefined(); + expect(typeof (bodySeenByGuard as { then?: unknown })?.then).not.toBe('function'); + }); + it('still rejects malformed JSON before the handler runs', async () => { const res = await request('POST', '/plain/echo', '{not json');