Skip to content

Commit 39ff2a5

Browse files
matthewpastro-actions[bot]sarah11918
authored
Harden Node adapter HTTP server defaults and request body handling (withastro#15759)
* Harden Node adapter HTTP server defaults and add global body size limit * Make bodySizeLimit a user-configurable option in the Node adapter * Update changeset: bump @astrojs/node to minor for new bodySizeLimit option * Update .changeset/harden-node-server-defaults.md Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> * Remove timeout defaults * Remove timeout hardening tests --------- Co-authored-by: astro-actions[bot] <houston@astro.build> Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com>
1 parent 2ff96f4 commit 39ff2a5

6 files changed

Lines changed: 181 additions & 7 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'astro': patch
3+
'@astrojs/node': minor
4+
---
5+
6+
Adds a new `bodySizeLimit` option to the `@astrojs/node` adapter
7+
8+
You can now configure a maximum allowed request body size for your Node.js standalone server. The default limit is 1 GB. Set the value in bytes, or pass `0` to disable the limit entirely:
9+
10+
```js
11+
import node from '@astrojs/node';
12+
import { defineConfig } from 'astro/config';
13+
14+
export default defineConfig({
15+
adapter: node({
16+
mode: 'standalone',
17+
bodySizeLimit: 1024 * 1024 * 100, // 100 MB
18+
}),
19+
});
20+
```

packages/astro/src/core/app/node.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,14 @@ export function createRequest(
3939
{
4040
skipBody = false,
4141
allowedDomains = [],
42+
bodySizeLimit,
4243
port: serverPort,
43-
}: { skipBody?: boolean; allowedDomains?: Partial<RemotePattern>[]; port?: number } = {},
44+
}: {
45+
skipBody?: boolean;
46+
allowedDomains?: Partial<RemotePattern>[];
47+
bodySizeLimit?: number;
48+
port?: number;
49+
} = {},
4450
): Request {
4551
const controller = new AbortController();
4652

@@ -103,7 +109,7 @@ export function createRequest(
103109
};
104110
const bodyAllowed = options.method !== 'HEAD' && options.method !== 'GET' && skipBody === false;
105111
if (bodyAllowed) {
106-
Object.assign(options, makeRequestBody(req));
112+
Object.assign(options, makeRequestBody(req, bodySizeLimit));
107113
}
108114

109115
const request = new Request(url, options);
@@ -322,7 +328,7 @@ function makeRequestHeaders(req: NodeRequest): Headers {
322328
return headers;
323329
}
324330

325-
function makeRequestBody(req: NodeRequest): RequestInit {
331+
function makeRequestBody(req: NodeRequest, bodySizeLimit?: number): RequestInit {
326332
if (req.body !== undefined) {
327333
if (typeof req.body === 'string' && req.body.length > 0) {
328334
return { body: Buffer.from(req.body) };
@@ -338,27 +344,55 @@ function makeRequestBody(req: NodeRequest): RequestInit {
338344
req.body !== null &&
339345
typeof (req.body as any)[Symbol.asyncIterator] !== 'undefined'
340346
) {
341-
return asyncIterableToBodyProps(req.body as AsyncIterable<any>);
347+
return asyncIterableToBodyProps(req.body as AsyncIterable<any>, bodySizeLimit);
342348
}
343349
}
344350

345351
// Return default body.
346-
return asyncIterableToBodyProps(req);
352+
return asyncIterableToBodyProps(req, bodySizeLimit);
347353
}
348354

349-
function asyncIterableToBodyProps(iterable: AsyncIterable<any>): RequestInit {
355+
function asyncIterableToBodyProps(
356+
iterable: AsyncIterable<any>,
357+
bodySizeLimit?: number,
358+
): RequestInit {
359+
const source = bodySizeLimit != null ? limitAsyncIterable(iterable, bodySizeLimit) : iterable;
350360
return {
351361
// Node uses undici for the Request implementation. Undici accepts
352362
// a non-standard async iterable for the body.
353363
// @ts-expect-error
354-
body: iterable,
364+
body: source,
355365
// The duplex property is required when using a ReadableStream or async
356366
// iterable for the body. The type definitions do not include the duplex
357367
// property because they are not up-to-date.
358368
duplex: 'half',
359369
};
360370
}
361371

372+
/**
373+
* Wraps an async iterable with a size limit. If the total bytes received
374+
* exceed the limit, an error is thrown.
375+
*/
376+
async function* limitAsyncIterable(
377+
iterable: AsyncIterable<any>,
378+
limit: number,
379+
): AsyncGenerator<any> {
380+
let received = 0;
381+
for await (const chunk of iterable) {
382+
const byteLength =
383+
chunk instanceof Uint8Array
384+
? chunk.byteLength
385+
: typeof chunk === 'string'
386+
? Buffer.byteLength(chunk)
387+
: 0;
388+
received += byteLength;
389+
if (received > limit) {
390+
throw new Error(`Body size limit exceeded: received more than ${limit} bytes`);
391+
}
392+
yield chunk;
393+
}
394+
}
395+
362396
function getAbortControllerCleanup(req?: NodeRequest): (() => void) | undefined {
363397
if (!req) return undefined;
364398
const cleanup = Reflect.get(req, nodeRequestAbortControllerCleanupSymbol);

packages/astro/test/units/app/node.test.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,107 @@ describe('node', () => {
855855
});
856856
});
857857

858+
describe('body size limit', () => {
859+
it('rejects request body that exceeds the configured bodySizeLimit', async () => {
860+
const { Readable } = await import('node:stream');
861+
// Create a stream that produces data exceeding the limit
862+
const limit = 1024; // 1KB limit
863+
const chunks = [];
864+
// Create 2KB of data (exceeds 1KB limit)
865+
for (let i = 0; i < 4; i++) {
866+
chunks.push(Buffer.alloc(512, 0x41));
867+
}
868+
const stream = Readable.from(chunks);
869+
const req = {
870+
...mockNodeRequest,
871+
method: 'POST',
872+
headers: {
873+
...mockNodeRequest.headers,
874+
'content-type': 'application/octet-stream',
875+
},
876+
socket: mockNodeRequest.socket,
877+
[Symbol.asyncIterator]: stream[Symbol.asyncIterator].bind(stream),
878+
};
879+
880+
const request = createRequest(req, { bodySizeLimit: limit });
881+
882+
// The request should be created, but reading the body should fail
883+
await assert.rejects(
884+
async () => {
885+
const reader = request.body.getReader();
886+
while (true) {
887+
const { done } = await reader.read();
888+
if (done) break;
889+
}
890+
},
891+
(err) => {
892+
assert.ok(err.message.includes('Body size limit exceeded'));
893+
return true;
894+
},
895+
);
896+
});
897+
898+
it('allows request body within the configured bodySizeLimit', async () => {
899+
const { Readable } = await import('node:stream');
900+
const limit = 2048; // 2KB limit
901+
const data = Buffer.alloc(1024, 0x42); // 1KB of data (within limit)
902+
const stream = Readable.from([data]);
903+
const req = {
904+
...mockNodeRequest,
905+
method: 'POST',
906+
headers: {
907+
...mockNodeRequest.headers,
908+
'content-type': 'application/octet-stream',
909+
},
910+
socket: mockNodeRequest.socket,
911+
[Symbol.asyncIterator]: stream[Symbol.asyncIterator].bind(stream),
912+
};
913+
914+
const request = createRequest(req, { bodySizeLimit: limit });
915+
916+
// Reading the body should succeed
917+
const reader = request.body.getReader();
918+
const chunks = [];
919+
while (true) {
920+
const { done, value } = await reader.read();
921+
if (done) break;
922+
chunks.push(value);
923+
}
924+
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
925+
assert.equal(totalSize, 1024);
926+
});
927+
928+
it('does not enforce body size limit when bodySizeLimit is not set', async () => {
929+
const { Readable } = await import('node:stream');
930+
// Create 2KB of data with no limit configured
931+
const data = Buffer.alloc(2048, 0x43);
932+
const stream = Readable.from([data]);
933+
const req = {
934+
...mockNodeRequest,
935+
method: 'POST',
936+
headers: {
937+
...mockNodeRequest.headers,
938+
'content-type': 'application/octet-stream',
939+
},
940+
socket: mockNodeRequest.socket,
941+
[Symbol.asyncIterator]: stream[Symbol.asyncIterator].bind(stream),
942+
};
943+
944+
const request = createRequest(req);
945+
946+
// Reading the body should succeed without limit
947+
const reader = request.body.getReader();
948+
const chunks = [];
949+
while (true) {
950+
const { done, value } = await reader.read();
951+
if (done) break;
952+
chunks.push(value);
953+
}
954+
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
955+
assert.equal(totalSize, 2048);
956+
});
957+
});
958+
858959
describe('abort signal', () => {
859960
it('aborts the request.signal when the underlying socket closes', () => {
860961
const socket = new EventEmitter();

packages/integrations/node/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr
7878
host: _config.server.host,
7979
port: _config.server.port,
8080
staticHeaders: userOptions.staticHeaders ?? false,
81+
bodySizeLimit: userOptions.bodySizeLimit ?? 1024 * 1024 * 1024,
8182
}),
8283
],
8384
},

packages/integrations/node/src/serve-app.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,18 @@ export function createAppHandler(app: BaseApp, options: Options): RequestHandler
7575
return new Response(null, { status: 404 });
7676
};
7777

78+
// Use the configured body size limit. A value of 0 or Infinity disables the limit.
79+
const effectiveBodySizeLimit =
80+
options.bodySizeLimit === 0 || options.bodySizeLimit === Number.POSITIVE_INFINITY
81+
? undefined
82+
: options.bodySizeLimit;
83+
7884
return async (req, res, next, locals) => {
7985
let request: Request;
8086
try {
8187
request = createRequest(req, {
8288
allowedDomains: app.getAllowedDomains?.() ?? [],
89+
bodySizeLimit: effectiveBodySizeLimit,
8390
port: options.port,
8491
});
8592
} catch (err) {

packages/integrations/node/src/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ export interface UserOptions {
2020
* - The CSP header of the static pages is added when CSP support is enabled.
2121
*/
2222
staticHeaders?: boolean;
23+
24+
/**
25+
* Maximum allowed request body size in bytes. Requests with bodies larger than
26+
* this limit will throw an error when the body is consumed.
27+
*
28+
* Set to `Infinity` or `0` to disable the limit.
29+
*
30+
* @default {1073741824} 1GB
31+
*/
32+
bodySizeLimit?: number;
2333
}
2434

2535
export interface Options extends UserOptions {
@@ -28,6 +38,7 @@ export interface Options extends UserOptions {
2838
server: string;
2939
client: string;
3040
staticHeaders: boolean;
41+
bodySizeLimit: number;
3142
}
3243

3344
export type RequestHandler = (...args: RequestHandlerParams) => void | Promise<void>;

0 commit comments

Comments
 (0)