Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions src/http/core/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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:
Expand All @@ -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<unknown> {
// 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
Expand Down
40 changes: 24 additions & 16 deletions src/http/routing/route-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,237 +213,237 @@
* @param metadata - Optional middleware metadata (guards, pipes, filters)
* @throws Error if route is already registered
*/
register(
method: string,
path: string,
handler: RouteHandler,
metadata?: RouteMetadata,
implicitHead = false
): void {
// Convert method to uWS format and normalize to uppercase for consistency
const uwsMethod = this.convertMethod(method);
const normalizedMethod = method.toUpperCase();

// Check if path has complex patterns (optional params, wildcards, etc.)
const isComplex = this.needsRegexMatching(path);

// Convert path and extract parameter names
const uwsPath = this.convertPath(path);
const paramNames = this.extractParamNames(path);
const pattern = isComplex ? this.pathToRegex(path) : uwsPath;

// Check for duplicate route registration using normalized method
const routeKey = `${normalizedMethod}:${path}`;
const existingRoute = this.routes.get(routeKey);
if (existingRoute) {
if (implicitHead) {
return;
}

if (normalizedMethod === 'HEAD' && existingRoute.implicitHead) {
const routeInfo = {
method: normalizedMethod,
path,
uwsPath,
pattern,
paramNames,
isComplex,
handler,
metadata,
};

this.routes.set(routeKey, routeInfo);
// Also update trailing-slash variant if it exists
const slashRouteKey = `${normalizedMethod}:${path}/`;
if (this.routes.has(slashRouteKey)) {
this.routes.set(slashRouteKey, { ...routeInfo, trailingSlash: true });
}
if (isComplex) {
const staticPrefix = this.extractStaticPrefix(path);
const registrationPath = staticPrefix ? `${staticPrefix}/*` : '/*';
const wildcardKey = `${uwsMethod}:${registrationPath}`;
this.replaceComplexRoute(wildcardKey, routeInfo);
}
return;
}

throw new Error(
`Route already registered: ${normalizedMethod} ${path}. ` +
`Duplicate route registration is not allowed as it would cause multiple handlers to execute for the same route.`
);
}

const routeInfo = {
method: normalizedMethod,
path,
uwsPath,
pattern,
paramNames,
isComplex,
handler,
metadata,
implicitHead,
};

// Track registered route with normalized method
this.routes.set(routeKey, routeInfo);

// Get the uWS method function
const uwsMethodFn = this.uwsApp[uwsMethod as keyof uWS.TemplatedApp] as any;

if (typeof uwsMethodFn !== 'function') {
throw new Error(`Invalid HTTP method: ${method} (converted to: ${uwsMethod})`);
}

if (isComplex) {
// For complex routes, register with a wildcard pattern
// Extract static prefix for more specific matching
const staticPrefix = this.extractStaticPrefix(path);
const registrationPath = staticPrefix ? `${staticPrefix}/*` : '/*';
const wildcardKey = `${uwsMethod}:${registrationPath}`;

// Add to complex routes collection
if (!this.complexRoutesByWildcard.has(wildcardKey)) {
this.complexRoutesByWildcard.set(wildcardKey, []);

// Create the shared handler function that will be used for both wildcard and bare routes
const sharedHandler = async (uwsRes: uWS.HttpResponse, uwsReq: uWS.HttpRequest) => {
const requestPath = uwsReq.getUrl();
const routesForWildcard = this.complexRoutesByWildcard.get(wildcardKey) || [];

// Create response wrapper early to attach abort handler
const res = new UwsResponse(uwsRes);
if (this.compressionHandler) {
res.setCompressionHandler(this.compressionHandler);
}

// Attach abort handler immediately to satisfy uWS requirement
res._onAbort(() => {
// Connection was aborted, nothing to do
});

// Try to find a matching route
let matched = false;
for (const routeInfo of routesForWildcard) {
const matches = this.matchPath(routeInfo.pattern as RegExp, requestPath);

if (matches) {
matched = true;

// Create request wrapper
const req = new UwsRequest(uwsReq, uwsRes, []);
res.bindRequest(req);

// Set extracted parameters using proper API
req._setParams(matches);

// Initialize body parser with configured size limit and fast abort option
// Pass response for abort multiplexing
req._initBodyParser(
this.options.maxBodySize ?? 1024 * 1024,
this.options.fastAbort ?? false,
res
);

// Execute handler with error handling
await this.executeHandler(routeInfo.handler, req, res, routeInfo.metadata);

break; // Stop after first match
}
}

// If no route matched, send 404
if (!matched) {
const req = new UwsRequest(uwsReq, uwsRes, []);
res.bindRequest(req);

// Handle CORS for unmatched routes (including preflight)
if (this.corsHandler && (await this.corsHandler.handle(req, res))) {
return;
}

// Only send 404 if response hasn't been sent and isn't aborted
// UwsResponse.send() already handles aborted state, but checking here
// avoids unnecessary work and makes intent explicit
if (!res.headersSent && !res.isAborted) {
res.status(404);
res.send({
statusCode: 404,
message: 'Not Found',
});
}
}
};

// Register the wildcard route (e.g., /users/*)
uwsMethodFn.call(this.uwsApp, registrationPath, sharedHandler);

// Register companion bare route for the static prefix (e.g., /users)
// This is necessary because uWS wildcards like /users/* do NOT match /users (bare path)
// For routes with optional parameters like /users/:id?, we need both registrations
// to handle both /users and /users/123
if (staticPrefix) {
uwsMethodFn.call(this.uwsApp, staticPrefix, sharedHandler);
}
}

// Add this route to the wildcard's route list
this.complexRoutesByWildcard.get(wildcardKey)!.push(routeInfo);
} else {
// Simple route - use native uWS routing
const createHandler =
(key: string) => async (uwsRes: uWS.HttpResponse, uwsReq: uWS.HttpRequest) => {
const activeRoute = this.routes.get(key)!;

// Create request/response wrappers
const req = new UwsRequest(uwsReq, uwsRes, paramNames);
const res = new UwsResponse(uwsRes);
if (this.compressionHandler) {
res.setCompressionHandler(this.compressionHandler);
}
// Attach abort handler immediately to satisfy uWS requirement
// This must be done before any async operations
res._onAbort(() => {
// Connection was aborted, nothing to do
});

res.bindRequest(req);

// Initialize body parser with configured size limit and fast abort option
req._initBodyParser(
this.options.maxBodySize ?? 1024 * 1024,
this.options.fastAbort ?? false,
res
);

// Execute handler with error handling
await this.executeHandler(activeRoute.handler, req, res, activeRoute.metadata);
};

uwsMethodFn.call(this.uwsApp, uwsPath, createHandler(routeKey));

// Also register trailing-slash variant for Express compatibility
// (e.g. /api and /api/ should both match the same route)
if (!uwsPath.endsWith('/') && uwsPath !== '/') {
const slashRouteKey = `${normalizedMethod}:${path}/`;
if (!this.routes.has(slashRouteKey)) {
this.routes.set(slashRouteKey, { ...routeInfo, trailingSlash: true });
uwsMethodFn.call(this.uwsApp, uwsPath + '/', createHandler(slashRouteKey));
}
} else if (uwsPath.endsWith('/') && uwsPath !== '/') {
const nonTrailingPath = uwsPath.slice(0, -1);
const companionKey = `${normalizedMethod}:${path.slice(0, -1)}`;
if (!this.routes.has(companionKey)) {
this.routes.set(companionKey, { ...routeInfo, trailingSlash: false });
uwsMethodFn.call(this.uwsApp, nonTrailingPath, createHandler(companionKey));
}
}
}

if (normalizedMethod === 'GET' && !implicitHead) {
this.register('HEAD', path, handler, metadata, true);
}
}

Check notice on line 446 in src/http/routing/route-registry.ts

View check run for this annotation

codefactor.io / CodeFactor

src/http/routing/route-registry.ts#L216-L446

Complex Method

/**
* Execute a route handler with middleware pipeline
Expand Down Expand Up @@ -480,7 +480,30 @@
// 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)
Expand All @@ -498,21 +521,6 @@
}
}

// 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
Expand Down
181 changes: 181 additions & 0 deletions test/http/guards-body-access.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void>((resolve, reject) => {
adapter.listen(port, (error) => {
if (error) reject(error);
else resolve();
});
});

baseUrl = `http://localhost:${port}`;
Comment thread
VikramAditya33 marked this conversation as resolved.
}, 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<string, unknown> }> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = {};
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<string, unknown>;
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);
});
});
Loading