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..de0f565 100644 --- a/src/http/routing/route-registry.ts +++ b/src/http/routing/route-registry.ts @@ -480,7 +480,30 @@ export class RouteRegistry { // Create execution context for middleware const context = new HttpExecutionContext(req, res, handler, metadata?.classRef); - // 1. Execute guards + // 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; + const contentType = req.contentType; + const normalizedContentType = contentType?.toLowerCase(); + const isStreamingContentType = + normalizedContentType && + (normalizedContentType.includes('application/octet-stream') || + normalizedContentType.includes('multipart/form-data')); + + if (!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 +521,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..2fa2fc4 --- /dev/null +++ b/test/http/guards-body-access.e2e.spec.ts @@ -0,0 +1,181 @@ +// @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: string | null = 'application/json' + ): Promise<{ status: number; body: Record }> { + return new Promise((resolve, reject) => { + const headers: Record = {}; + if (body !== undefined) { + // contentType === null sends a body WITHOUT a Content-Type header + if (contentType !== null) 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('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'); + + // Same status invalid JSON produced before the parse/guard reorder + // (see body-parsing.e2e.spec.ts) + expect(res.status).toBe(500); + }); +});