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
3 changes: 3 additions & 0 deletions src/http/AsyncHTTP.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ fn make_client<'a>(
compress: None,
compressed_request_body: Vec::new(),
compressed_body_len: 0,
buffered_sendfile_body: Vec::new(),
}
}

Expand Down Expand Up @@ -760,6 +761,8 @@ impl<'a> AsyncHTTP<'a> {
// populated by the clone (`on_start` → `client.start`); it
// owns the decompressor / compressed_body buffers.
drop(core::mem::take(&mut client.state));
// After `state`: its `original_request_body` borrows this.
drop(core::mem::take(&mut client.buffered_sendfile_body));
}
let elapsed = (*this).elapsed;
bun_core::scoped_log!(AsyncHTTP, "onAsyncHTTPCallback: {:?}", elapsed);
Expand Down
2 changes: 2 additions & 0 deletions src/http/HTTPThread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,8 @@ impl HttpThread {
ctx.deref();
}
drop(core::mem::take(&mut client.state));
// After `state`: its `original_request_body` borrows this.
drop(core::mem::take(&mut client.buffered_sendfile_body));
if let Some(f) = release.release_at_shutdown {
f(release.ctx);
}
Expand Down
15 changes: 14 additions & 1 deletion src/http/InternalState.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ use crate::Error;
use bun_core::MutableString;
use bun_core::Output;

use crate::{CertificateInfo, Decompressor, Encoding, HTTPRequestBody, HTTPResponseMetadata};
use crate::{
CertificateInfo, Decompressor, Encoding, HTTPRequestBody, HTTPResponseMetadata, SendFile,
};

bun_core::define_scoped_log!(log, HTTPInternalState, hidden);

Expand Down Expand Up @@ -35,6 +37,11 @@ pub struct InternalState<'a> {
// outlives-holder invariant (the backing `original_request_body` is a
// sibling field, so it lives exactly as long as this struct).
pub(crate) request_body: bun_ptr::RawSlice<u8>,
/// Send cursor for a `Sendfile` body, the counterpart of `request_body`
/// for `Bytes`: `SendFile::write` advances this copy, so
/// `original_request_body` keeps describing the whole file range and a
/// redirect can hand it to `start()` again.
pub(crate) sendfile: Option<SendFile>,
pub(crate) original_request_body: HTTPRequestBody<'a>,
pub(crate) request_sent_len: usize,
pub(crate) fail: Option<Error>,
Expand Down Expand Up @@ -128,6 +135,7 @@ impl Default for InternalState<'_> {
content_length: None,
total_body_received: 0,
request_body: bun_ptr::RawSlice::EMPTY,
sendfile: None,
original_request_body: HTTPRequestBody::Bytes(b""),
request_sent_len: 0,
fail: None,
Expand All @@ -143,9 +151,14 @@ impl Default for InternalState<'_> {
impl<'a> InternalState<'a> {
pub(crate) fn init(body: HTTPRequestBody<'a>) -> InternalState<'a> {
let request_body = bun_ptr::RawSlice::new(body.slice());
let sendfile = match &body {
HTTPRequestBody::Sendfile(sendfile) => Some(*sendfile),
_ => None,
};
InternalState {
original_request_body: body,
request_body,
sendfile,
compressed_body: MutableString::init_empty(),
response_message_buffer: MutableString::init_empty(),
decoded_body: MutableString::init_empty(),
Expand Down
21 changes: 21 additions & 0 deletions src/http/SendFile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ use bun_core::feature_flags;
use bun_sys::{self, Fd};
use bun_url::URL;

/// A request body sent with `sendfile(2)`. The fd is owned by the JS-side
/// `FetchTasklet`, which keeps it open until the request has delivered its
/// final result, so every redirect hop can send the same range again.
#[derive(Copy, Clone)]
pub struct SendFile {
pub fd: Fd,
Expand All @@ -22,6 +25,24 @@ impl SendFile {
url.is_http() && url.href.len() > 0
}

/// The `content_size` bytes this body advertises as its Content-Length,
/// read into memory for a hop that cannot `sendfile(2)` (see
/// `HTTPClient::buffer_sendfile_body_for_tls`). Shorter if the file
/// shrank since the request was built; the hop's Content-Length is the
/// returned length, so the request stays well-formed either way.
pub(crate) fn read_to_vec(&self) -> crate::Result<Vec<u8>> {
let mut bytes: Vec<u8> = Vec::new();
bytes
.try_reserve_exact(self.content_size)
.map_err(|_| bun_alloc::AllocError)?;
bytes.resize(self.content_size, 0);
let read = bun_sys::File::borrow(&self.fd)
.pread_all(&mut bytes, self.offset as u64)
.map_err(bun_errno::SystemErrno::from)?;
bytes.truncate(read);
Ok(bytes)
}

// Takes the resolved fd directly rather than the socket; callers pass
// `socket.fd()`.
pub(crate) fn write(&mut self, socket_fd: Fd) -> Status {
Expand Down
101 changes: 73 additions & 28 deletions src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,14 @@ pub struct HTTPClient<'a> {
/// Compressed length for `Content-Length`; 0 when `compress` is None or
/// the body hasn't been compressed yet.
pub(crate) compressed_body_len: usize,
/// A `Sendfile` body read into memory because a hop has to send it over
/// TLS (see [`buffer_sendfile_body_for_tls`]); `state.original_request_body`
/// borrows it as `Bytes` from then on, so it is filled at most once per
/// request and, like `compressed_request_body`, freed only when the
/// HTTP-thread clone is torn down.
///
/// [`buffer_sendfile_body_for_tls`]: Self::buffer_sendfile_body_for_tls
pub(crate) buffered_sendfile_body: Vec<u8>,
}

impl<'a> HTTPClient<'a> {
Expand Down Expand Up @@ -2128,9 +2136,9 @@ impl<'a> HTTPClient<'a> {
if in_progress
&& self.allow_retry
&& self.method.is_idempotent()
// Only a Bytes body can be rebuilt from `original_request_body`.
// Stream/Sendfile bodies are consumed as they are written, so a
// retry would silently replay a truncated request.
// Only an in-memory body is retried. A Stream body in particular is
// consumed as it is written, so retrying it would silently replay a
// truncated request.
&& matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_))
&& self.state.response_stage != ResponseStage::Body
&& self.state.response_stage != ResponseStage::BodyChunk
Expand Down Expand Up @@ -2600,17 +2608,7 @@ impl<'a> HTTPClient<'a> {
// double-free when the original later runs `clear_data()`. Forget the
// clone's view; the original is the sole owner.
let _ = core::mem::ManuallyDrop::new(core::mem::take(&mut self.unix_socket_path));
// TODO: what we do with stream body?
let request_body: &[u8] = if self.state.flags.resend_request_body_on_redirect
&& matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_))
{
match &self.state.original_request_body {
HTTPRequestBody::Bytes(b) => b,
_ => unreachable!(),
}
} else {
b""
};
let request_body = self.request_body_for_redirect();

self.state.response_message_buffer = MutableString::default();

Expand Down Expand Up @@ -2681,7 +2679,26 @@ impl<'a> HTTPClient<'a> {
self.flags.protocol = Protocol::Http1_1;
self.reevaluate_proxy_for_redirect();

self.start(HTTPRequestBody::Bytes(request_body));
self.start(request_body);
}

/// The body for the hop a redirect is about to start. The send path never
/// advances `original_request_body` (its cursors are `state.request_body`
/// and `state.sendfile`), so a `Bytes` or `Sendfile` body goes to `start()`
/// again unchanged. A `Stream` body only gets this far on a 303, which
/// `handle_response_metadata` has already turned into a bodiless GET; every
/// other status fails it with `RequestBodyNotReusable` instead.
///
/// Must run before `state.reset()`, which clears both the flag and the body.
fn request_body_for_redirect(&self) -> HTTPRequestBody<'a> {
if !self.state.flags.resend_request_body_on_redirect {
return HTTPRequestBody::Bytes(b"");
}
match self.state.original_request_body {
HTTPRequestBody::Bytes(bytes) => HTTPRequestBody::Bytes(bytes),
HTTPRequestBody::Sendfile(sendfile) => HTTPRequestBody::Sendfile(sendfile),
HTTPRequestBody::Stream(_) => HTTPRequestBody::Bytes(b""),
}
}

/// Re-resolve `http_proxy` against the post-redirect `self.url`. The
Expand Down Expand Up @@ -2752,6 +2769,12 @@ impl<'a> HTTPClient<'a> {
return;
}

if let Err(err) = self.buffer_sendfile_body_for_tls::<IS_SSL>() {
self.fail(err);
self.complete_connecting_process();
return;
}

// protocol: "http2" is documented as HTTPS-only (h2c is out of scope).
// Every h2 consumer is gated on the SSL const-generic, so without this
// an http:// request would silently fall through to HTTP/1.1.
Expand Down Expand Up @@ -2890,6 +2913,32 @@ impl<'a> HTTPClient<'a> {
self.complete_connecting_process();
}

/// `sendfile(2)` copies the file straight onto the socket's fd, so a
/// `Sendfile` body needs a plaintext socket with no CONNECT tunnel inside
/// it; `on_writable` panics otherwise. `fetch()` only builds one for such a
/// request, but a redirect to `https://` (or an `https://` proxy taken from
/// the environment) can route it onto a TLS hop. For that hop, read the
/// file into `buffered_sendfile_body` and send it as `Bytes`, which is how
/// `fetch()` sends a file to an `https://` URL in the first place.
fn buffer_sendfile_body_for_tls<const IS_SSL: bool>(&mut self) -> crate::Result<()> {
let HTTPRequestBody::Sendfile(sendfile) = self.state.original_request_body else {
return Ok(());
};
let tunnels_through_proxy = self.http_proxy.is_some() && self.url.is_https();
if !IS_SSL && !tunnels_through_proxy {
return Ok(());
}
debug_assert!(self.buffered_sendfile_body.is_empty());
self.buffered_sendfile_body = sendfile.read_to_vec()?;
// SAFETY: `buffered_sendfile_body` is only assigned here, and this runs
// at most once per request: the body is `Bytes` from now on, including
// the copies `request_body_for_redirect` hands to later hops. The Vec
// lives on `self`, which outlives every `InternalState` that borrows it.
let bytes: &'a [u8] = unsafe { bun_ptr::detach_lifetime(&self.buffered_sendfile_body) };
self.state = InternalState::init(HTTPRequestBody::Bytes(bytes));
Ok(())
}

/// Body length for `Content-Length` — the compressed length once
/// [`compress_body_for_send`] has run, otherwise the original.
#[inline]
Expand Down Expand Up @@ -3374,7 +3423,7 @@ impl<'a> HTTPClient<'a> {
self.set_timeout(&socket);
}

match &mut self.state.original_request_body {
match self.state.original_request_body {
HTTPRequestBody::Bytes(_) => {
let to_send = self.request_body();
if !to_send.is_empty() {
Expand All @@ -3400,13 +3449,18 @@ impl<'a> HTTPClient<'a> {
// flush without adding any new data
self.flush_stream::<IS_SSL>(socket);
}
HTTPRequestBody::Sendfile(sendfile) => {
HTTPRequestBody::Sendfile(_) => {
if IS_SSL {
panic!(
"sendfile is only supported without SSL. This code should never have been reached!"
);
}

let sendfile = self
.state
.sendfile
.as_mut()
.expect("InternalState::init seats the cursor for a Sendfile body");
// sendfile.write() takes the raw fd, not the socket handle.
match sendfile.write(socket.fd()) {
#[cfg(not(windows))]
Expand Down Expand Up @@ -4322,16 +4376,7 @@ impl<'a> HTTPClient<'a> {
// with the JS-thread original (created via `ptr::read`); dropping it
// here double-frees once the original runs `clear_data()`.
let _ = core::mem::ManuallyDrop::new(core::mem::take(&mut self.unix_socket_path));
let request_body: &[u8] = if self.state.flags.resend_request_body_on_redirect
&& matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_))
{
match &self.state.original_request_body {
HTTPRequestBody::Bytes(b) => b,
_ => unreachable!(),
}
} else {
b""
};
let request_body = self.request_body_for_redirect();
self.state.response_message_buffer = MutableString::default();
self.remaining_redirect_count = self.remaining_redirect_count.saturating_sub(1);
self.flags.redirected = true;
Expand All @@ -4347,7 +4392,7 @@ impl<'a> HTTPClient<'a> {
self.flags.proxy_tunneling = false;
self.flags.protocol = Protocol::Http1_1;
self.reevaluate_proxy_for_redirect();
self.start(HTTPRequestBody::Bytes(request_body));
self.start(request_body);
}

pub(crate) fn progress_update_h3(&mut self) {
Expand Down
62 changes: 61 additions & 1 deletion test/js/bun/http/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import axios from "axios";
import type { Server } from "bun";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isASAN, tls as tlsCert } from "harness";
import { bunEnv, bunExe, isASAN, tempDir, tls as tlsCert } from "harness";
import { HttpsProxyAgent } from "https-proxy-agent";
import { once } from "node:events";
import net from "node:net";
import { join } from "node:path";
import tls from "node:tls";
async function createProxyServer(is_tls: boolean) {
const serverArgs = [];
Expand Down Expand Up @@ -228,6 +229,65 @@ for (const proxy_tls of [false, true]) {
}
}

// fetch() uploads a Bun.file() of 32 KiB or more with sendfile(2), which needs a
// plaintext connection, and decides that before a proxy from the environment
// is applied. The HTTP client has to fall back to uploading the file as
// ordinary bytes whenever the connection it ends up on is TLS (an https://
// proxy) or a CONNECT tunnel (a redirect onto an https:// origin through a
// proxy); it used to abort with "sendfile is only supported without SSL" in
// the first case and send an empty body in the second.
describe("Bun.file() body that would use sendfile, with a proxy from the environment", () => {
const SIZE = 64 * 1024;

// Subprocess: the proxy environment is read when the process starts.
async function uploadFromChild(url: string, proxyEnv: Record<string, string>) {
using dir = tempDir("proxy-sendfile-body", { "body.bin": Buffer.alloc(SIZE, "a") });
const env = { ...bunEnv };
for (const key of PROXY_ENV_KEYS) delete env[key];
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const res = await fetch(${JSON.stringify(url)}, {
method: "POST",
body: Bun.file(${JSON.stringify(join(String(dir), "body.bin"))}),
tls: { rejectUnauthorized: false },
});
console.log(res.status, (await res.text()).length);`,
],
env: { ...env, ...proxyEnv },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}

test.concurrent("is uploaded through a TLS proxy", async () => {
expect(await uploadFromChild(String(httpServer.url), { http_proxy: httpsProxyServer.url })).toEqual({
stdout: `200 ${SIZE}\n`,
stderr: "",
exitCode: 0,
});
});

test.concurrent("is re-uploaded through a CONNECT tunnel when the origin redirects to https", async () => {
using origin = Bun.serve({
port: 0,
async fetch(req) {
await req.arrayBuffer();
return new Response(null, { status: 307, headers: { Location: String(httpsServer.url) } });
},
});
const proxyEnv = { http_proxy: httpProxyServer.url, https_proxy: httpProxyServer.url };
expect(await uploadFromChild(String(origin.url), proxyEnv)).toEqual({
stdout: `200 ${SIZE}\n`,
stderr: "",
exitCode: 0,
});
});
});

for (const server_tls of [false, true]) {
describe.concurrent(`proxy can handle redirects with ${server_tls ? "TLS" : "non-TLS"} server`, () => {
test("with empty body #12007", async () => {
Expand Down
Loading
Loading