Skip to content
Closed
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
40 changes: 40 additions & 0 deletions src/bun.js/api/server/RequestContext.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 .{
Expand Down
53 changes: 32 additions & 21 deletions src/bun.js/webcore/prompt.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +56 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The _ = reader.takeByte() catch {} after reading \r (line 58, and 5 other locations) performs a blocking read. In raw terminal mode (the REPL scenario this PR fixes), Enter sends only \r with no trailing \n, so this read blocks until the user presses a second key — partially defeating the fix. The follow-up read should only consume the next byte if data is already buffered (non-blocking peek), not issue an unconditional blocking read.

Extended reasoning...

What the bug is

The PR adds _ = reader.takeByte() catch {} (or readByte) after every \r detection to consume an optional trailing \n for Windows CRLF line endings. However, takeByte() and readByte() are blocking calls — they issue a read() syscall that blocks until at least one byte is available. In raw terminal mode (the exact REPL scenario this PR is designed to fix), pressing Enter sends only \r with no trailing \n, so the follow-up read blocks waiting for the next byte from stdin.

The specific code path

In alert() at lines 56-60:

if (byte == 
) {
    // Consume optional trailing \n for Windows \r\n line endings
    _ = reader.takeByte() catch {};
    break;
}

The reader wraps a std.fs.File (stdin) via readerStreaming with a 1-byte buffer. When all buffered data has been consumed (only \r was available), takeByte() calls the underlying read() syscall. The catch {} only handles read errors (e.g., EOF on a pipe), not blocking — a blocking read simply never returns.

Why the REPL triggers this

The REPL puts the terminal in raw mode at startup (bun.tty.setMode(0, .raw) in repl.zig:757). The raw mode configuration sets VMIN=1, VTIME=0 (confirmed in wtf-bindings.cpp:90), meaning read() blocks until at least 1 byte is available. When the user presses Enter in raw mode, only \r is sent — there is no trailing \n. So after consuming \r, the takeByte() call blocks indefinitely waiting for the next keystroke.

Step-by-step proof

  1. User starts bun repl (terminal enters raw mode)
  2. User types alert("hello") and submits it
  3. Bun prints hello [Enter] and waits for input
  4. User presses Enter, which sends \r (just one byte in raw mode)
  5. The while loop reads byte = \r at line 54
  6. Enters the if (byte == ) branch at line 56
  7. Line 58: _ = reader.takeByte() catch {} issues a blocking read() syscall
  8. No data is available (raw mode only sent \r), so read() blocks
  9. The user must press a second key to unblock the read (that key is silently discarded)

Before this PR, alert() hung indefinitely (infinite loop waiting for \n). After this PR, it hangs until a second keypress — an improvement but still broken.

Why the tests pass

All tests use piped stdin with proc.stdin.end() after writing \r. When the pipe is closed, the follow-up takeByte() gets EOF, which is caught by catch {}. But in a real REPL session, stdin is an open terminal file descriptor — it never returns EOF, so the read blocks.

How to fix

The fix should only consume the next byte if it is already available in the reader's internal buffer (a non-blocking peek). For example, after reading \r, check if the streaming reader has any buffered data before attempting to read the trailing byte. If no data is buffered, skip the read. This handles both cases correctly: bare \r in raw REPL mode (no blocking read) and \r\n in Windows cooked mode (consumes the \n from the buffer since both bytes arrive together). The same fix is needed at all six locations: alert() line 58, confirm() lines 121/137/147, both readUntilDelimiter helpers at lines 176/199, and prompt() at line 289.

}

// 8. Invoke WebDriver BiDi user prompt closed with this and true.
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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
Expand Down
Loading