diff --git a/src/bun.js/api/server/RequestContext.zig b/src/bun.js/api/server/RequestContext.zig index 1b635b5144bb..b82818f796bb 100644 --- a/src/bun.js/api/server/RequestContext.zig +++ b/src/bun.js/api/server/RequestContext.zig @@ -59,6 +59,9 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, request_body: ?*WebCore.Body.Value.HiveRef = null, request_body_buf: std.ArrayListUnmanaged(u8) = .{}, request_body_content_len: usize = 0, + /// Tracks total bytes received for chunked/streaming request bodies + /// to enforce max_request_body_size limits. + streamed_body_bytes_received: usize = 0, sink: ?*ResponseStream.JSSink = null, byte_stream: ?*jsc.WebCore.ByteStream = null, @@ -1369,6 +1372,27 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, } return false; } + /// Rejects a request that has exceeded max_request_body_size during + /// chunked/streaming transfer. Sends a 413 response and closes the + /// connection. The ReadableStream (if active) is errored via + /// endWithoutBody → endRequestStreaming → Body.Value.toErrorInstance, + /// which handles both the pending body promise and the stream. + fn rejectRequestBodyAsEntityTooLarge(this: *RequestContext, resp: *App.Response) void { + ctxLog("rejectRequestBodyAsEntityTooLarge", .{}); + + // Signal the abort so request.signal.aborted becomes true + this.setSignalAborted(.ConnectionClosed); + + if (!this.flags.has_written_status) { + resp.writeStatus("413 Request Entity Too Large"); + this.flags.has_written_status = true; + } + // endWithoutBody calls endRequestStreaming which errors the + // ReadableStream via Body.Value.toErrorInstance when the body + // is still in .Locked state. + this.endWithoutBody(true); + } + fn detachResponse(this: *RequestContext) void { this.request_body_buf.clearAndFree(bun.default_allocator); @@ -2379,6 +2403,17 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, const vm = this.server.?.vm; const globalThis = this.server.?.globalThis; + // Enforce max_request_body_size for chunked/streaming requests. + // Content-Length requests are checked upfront, but chunked requests + // must be validated as data arrives. + this.streamed_body_bytes_received += chunk.len; + if (this.server) |server| { + if (this.streamed_body_bytes_received > server.config.max_request_body_size) { + this.rejectRequestBodyAsEntityTooLarge(resp); + return; + } + } + // After the user does request.body, // if they then do .text(), .arrayBuffer(), etc // we can no longer hold the strong reference from the body value ref. @@ -2481,6 +2516,11 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, // This means we have received part of the body but not the whole thing if (this.request_body_buf.items.len > 0) { + // Initialize the streaming byte counter with the pre-buffered + // amount so that the max_request_body_size check in + // onBufferedBodyChunk accounts for data already received before + // the handler started consuming the stream. + this.streamed_body_bytes_received = this.request_body_buf.items.len; var emptied = this.request_body_buf; this.request_body_buf = .{}; return .{ diff --git a/src/bun.js/webcore/prompt.zig b/src/bun.js/webcore/prompt.zig index 747cd935fd65..bc13a4b0de4e 100644 --- a/src/bun.js/webcore/prompt.zig +++ b/src/bun.js/webcore/prompt.zig @@ -53,6 +53,11 @@ fn alert(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSErr while (true) { const byte = reader.takeByte() catch break; if (byte == '\n') break; + if (byte == '\r') { + // Consume optional trailing \n for Windows \r\n line endings + _ = reader.takeByte() catch {}; + break; + } } // 8. Invoke WebDriver BiDi user prompt closed with this and true. @@ -112,13 +117,9 @@ fn confirm(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSE switch (first_byte) { '\n' => return .false, '\r' => { - const next_byte = reader.takeByte() catch { - // They may have said yes, but the stdin is invalid. - return .false; - }; - if (next_byte == '\n') { - return .false; - } + // Consume optional trailing \n for Windows \r\n line endings + _ = reader.takeByte() catch {}; + return .false; }, 'y', 'Y' => { const next_byte = reader.takeByte() catch { @@ -132,20 +133,20 @@ fn confirm(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSE // otherwise, the user responded negatively: return false. return .true; } else if (next_byte == '\r') { - //Check Windows style - const second_byte = reader.takeByte() catch { - return .false; - }; - if (second_byte == '\n') { - return .true; - } + // Consume optional trailing \n for Windows \r\n line endings + _ = reader.takeByte() catch {}; + return .true; } }, else => {}, } while (reader.takeByte()) |b| { - if (b == '\n' or b == '\r') break; + if (b == '\n') break; + if (b == '\r') { + _ = reader.takeByte() catch {}; + break; + } } else |_| {} // 8. If the user responded positively, return true; otherwise, the user @@ -172,6 +173,11 @@ pub const prompt = struct { if (byte == delimiter) { return; } + if (byte == '\r') { + // Consume optional trailing \n for Windows \r\n line endings + _ = reader.readByte() catch {}; + return; + } array_list.appendAssumeCapacity(byte); } @@ -190,6 +196,11 @@ pub const prompt = struct { if (byte == delimiter) { return; } + if (byte == '\r') { + // Consume optional trailing \n for Windows \r\n line endings + _ = reader.readByte() catch {}; + return; + } try array_list.append(byte); } @@ -263,7 +274,6 @@ pub const prompt = struct { // 7. Pause while waiting for the user's response. const reader = bun.Output.buffered_stdin.reader(); - var second_byte: ?u8 = null; const first_byte = reader.readByte() catch { // 8. Let result be null if the user aborts, or otherwise the string // that the user responded with. @@ -274,10 +284,12 @@ pub const prompt = struct { // 8. Let result be null if the user aborts, or otherwise the string // that the user responded with. return default; - } else if (first_byte == '\r') { - const second = reader.readByte() catch return .null; - second_byte = second; - if (second == '\n') return default; + } + + if (first_byte == '\r') { + // Consume optional trailing \n for Windows \r\n line endings + _ = reader.readByte() catch {}; + return default; } var input = std.array_list.Managed(u8).initCapacity(allocator, 2048) catch { @@ -288,7 +300,6 @@ pub const prompt = struct { defer input.deinit(); input.appendAssumeCapacity(first_byte); - if (second_byte) |second| input.appendAssumeCapacity(second); // All of this code basically just first tries to load the input into a // buffer of size 2048. If that is too small, then increase the buffer diff --git a/test/js/bun/http/serve-chunked-413.test.ts b/test/js/bun/http/serve-chunked-413.test.ts new file mode 100644 index 000000000000..b024cd4aa984 --- /dev/null +++ b/test/js/bun/http/serve-chunked-413.test.ts @@ -0,0 +1,230 @@ +import { expect, test } from "bun:test"; + +// Test that chunked requests exceeding maxRequestBodySize are properly +// rejected with 413 and that ReadableStreams are errored (not left hanging). + +test("chunked request exceeding maxRequestBodySize gets 413", async () => { + using server = Bun.serve({ + port: 0, + maxRequestBodySize: 64, + async fetch(req) { + // Just try to consume the body as text (buffered path) + try { + const body = await req.text(); + return new Response(`OK: ${body.length}`); + } catch (e: any) { + return new Response(`Error: ${e.message}`, { status: 500 }); + } + }, + }); + + // Use a raw TCP connection to send chunked transfer-encoding + // Send a request that exceeds 64 bytes via chunked encoding + const conn = await Bun.connect({ + hostname: server.hostname, + port: server.port, + socket: { + data(socket, data) { + socket.data.chunks.push(Buffer.from(data)); + const text = Buffer.concat(socket.data.chunks).toString(); + if (text.includes("\r\n\r\n") || text.includes("0\r\n\r\n")) { + socket.data.resolve(text); + } + }, + open(socket) {}, + close(socket) { + // If the connection is closed before we get a response, resolve with what we have + const text = Buffer.concat(socket.data.chunks).toString(); + socket.data.resolve(text); + }, + error(socket, err) { + socket.data.reject(err); + }, + connectError(socket, err) { + socket.data.reject(err); + }, + }, + data: { + chunks: [] as Buffer[], + resolve: null as any, + reject: null as any, + }, + }); + + const { promise, resolve, reject } = Promise.withResolvers(); + conn.data.resolve = resolve; + conn.data.reject = reject; + + // Send chunked request that exceeds 64 byte limit + // First chunk: 40 bytes (under limit) + // Second chunk: 40 bytes (total 80 bytes, exceeds 64 byte limit) + const request = + "POST / HTTP/1.1\r\n" + + `Host: ${server.hostname}:${server.port}\r\n` + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "28\r\n" + // 0x28 = 40 in hex + "A".repeat(40) + + "\r\n" + + "28\r\n" + // 0x28 = 40 in hex + "B".repeat(40) + + "\r\n" + + "0\r\n" + + "\r\n"; + + conn.write(request); + conn.flush(); + + const response = await promise; + expect(response).toContain("413"); + conn.end(); +}); + +test("chunked request via ReadableStream gets errored on 413", async () => { + using server = Bun.serve({ + port: 0, + maxRequestBodySize: 64, + async fetch(req) { + // Consume body as ReadableStream + const reader = req.body!.getReader(); + try { + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); + return new Response(`OK: ${totalLength}`); + } catch (e: any) { + // The read() should reject with an error, not hang indefinitely + return new Response(`Error: ${e.message}`, { status: 500 }); + } + }, + }); + + const conn = await Bun.connect({ + hostname: server.hostname, + port: server.port, + socket: { + data(socket, data) { + socket.data.chunks.push(Buffer.from(data)); + const text = Buffer.concat(socket.data.chunks).toString(); + if (text.includes("\r\n\r\n")) { + socket.data.resolve(text); + } + }, + open(socket) {}, + close(socket) { + const text = Buffer.concat(socket.data.chunks).toString(); + socket.data.resolve(text); + }, + error(socket, err) { + socket.data.reject(err); + }, + connectError(socket, err) { + socket.data.reject(err); + }, + }, + data: { + chunks: [] as Buffer[], + resolve: null as any, + reject: null as any, + }, + }); + + const { promise, resolve, reject } = Promise.withResolvers(); + conn.data.resolve = resolve; + conn.data.reject = reject; + + // Send chunked request that exceeds 64 byte limit + const request = + "POST / HTTP/1.1\r\n" + + `Host: ${server.hostname}:${server.port}\r\n` + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "28\r\n" + + "A".repeat(40) + + "\r\n" + + "28\r\n" + + "B".repeat(40) + + "\r\n" + + "0\r\n" + + "\r\n"; + + conn.write(request); + conn.flush(); + + const response = await promise; + // The server should respond with 413, not hang + expect(response).toContain("413"); + conn.end(); +}); + +test("chunked request within maxRequestBodySize succeeds", async () => { + using server = Bun.serve({ + port: 0, + maxRequestBodySize: 128, + async fetch(req) { + const body = await req.text(); + return new Response(`OK: ${body.length}`); + }, + }); + + const conn = await Bun.connect({ + hostname: server.hostname, + port: server.port, + socket: { + data(socket, data) { + socket.data.chunks.push(Buffer.from(data)); + const text = Buffer.concat(socket.data.chunks).toString(); + if (text.includes("\r\n\r\n")) { + socket.data.resolve(text); + } + }, + open(socket) {}, + close(socket) { + const text = Buffer.concat(socket.data.chunks).toString(); + socket.data.resolve(text); + }, + error(socket, err) { + socket.data.reject(err); + }, + connectError(socket, err) { + socket.data.reject(err); + }, + }, + data: { + chunks: [] as Buffer[], + resolve: null as any, + reject: null as any, + }, + }); + + const { promise, resolve, reject } = Promise.withResolvers(); + conn.data.resolve = resolve; + conn.data.reject = reject; + + // Send chunked request within 128 byte limit (total 80 bytes) + const request = + "POST / HTTP/1.1\r\n" + + `Host: ${server.hostname}:${server.port}\r\n` + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "28\r\n" + + "A".repeat(40) + + "\r\n" + + "28\r\n" + + "B".repeat(40) + + "\r\n" + + "0\r\n" + + "\r\n"; + + conn.write(request); + conn.flush(); + + const response = await promise; + expect(response).toContain("200"); + expect(response).toContain("OK: 80"); + conn.end(); +}); diff --git a/test/regression/issue/27542.test.ts b/test/regression/issue/27542.test.ts new file mode 100644 index 000000000000..b8bafc2191e9 --- /dev/null +++ b/test/regression/issue/27542.test.ts @@ -0,0 +1,206 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// Test that alert(), confirm(), and prompt() accept \r (carriage return) as +// a line ending. In raw terminal mode (e.g. the REPL), pressing Enter sends +// \r instead of \n. Previously these functions only checked for \n, causing +// them to hang indefinitely in the REPL. + +test("alert() accepts \\r as line ending", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "alert('Hello'); console.error('done')"], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + // Send \r (carriage return) instead of \n to simulate raw terminal mode + proc.stdin.write("\r"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("[Enter]"); + expect(stderr).toBe("done\n"); + expect(exitCode).toBe(0); +}); + +test("confirm() accepts \\r as line ending (default no)", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "const r = confirm('OK?'); console.error(r)"], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + // Bare \r should be treated as Enter (default = no) + proc.stdin.write("\r"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe("false\n"); + expect(exitCode).toBe(0); +}); + +test("confirm() accepts y + \\r as yes", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "const r = confirm('OK?'); console.error(r)"], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + // y followed by \r should be treated as "yes" + proc.stdin.write("y\r"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe("true\n"); + expect(exitCode).toBe(0); +}); + +test("prompt() accepts \\r as line ending (returns default)", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "const r = prompt('Name?', 'default'); console.error(r)"], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + // Bare \r should be treated as Enter (return default value) + proc.stdin.write("\r"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe("default\n"); + expect(exitCode).toBe(0); +}); + +test("prompt() accepts input + \\r as line ending", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "const r = prompt('Name?'); console.error(r)"], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + // Text followed by \r should return the text + proc.stdin.write("hello\r"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe("hello\n"); + expect(exitCode).toBe(0); +}); + +// Tests for \r\n (Windows cooked console mode) to ensure the trailing \n is consumed +// and doesn't leak into subsequent calls. + +test("confirm() accepts \\r\\n as line ending (default no)", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "const r = confirm('OK?'); console.error(r)"], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + // Send \r\n (Windows cooked mode) - should be treated as Enter (default = no) + proc.stdin.write("\r\n"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe("false\n"); + expect(exitCode).toBe(0); +}); + +test("confirm() accepts y + \\r\\n as yes", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "const r = confirm('OK?'); console.error(r)"], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + // y followed by \r\n should be treated as "yes" + proc.stdin.write("y\r\n"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe("true\n"); + expect(exitCode).toBe(0); +}); + +test("sequential prompt() calls with \\r\\n don't skip", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + "const a = prompt('first?', 'def1'); const b = prompt('second?', 'def2'); console.error(a + ',' + b)", + ], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + // Send \r\n for first prompt (returns default), then "hello\r\n" for second + proc.stdin.write("\r\n"); + proc.stdin.write("hello\r\n"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe("def1,hello\n"); + expect(exitCode).toBe(0); +}); + +test("prompt() accepts \\r\\n as line ending (returns default)", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "const r = prompt('Name?', 'default'); console.error(r)"], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + // Send \r\n (Windows cooked mode) - should return default + proc.stdin.write("\r\n"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe("default\n"); + expect(exitCode).toBe(0); +}); + +test("prompt() accepts input + \\r\\n as line ending", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "const r = prompt('Name?'); console.error(r)"], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + // Text followed by \r\n should return the text + proc.stdin.write("hello\r\n"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe("hello\n"); + expect(exitCode).toBe(0); +});