From 7d8f79061a4ee7345100fad1f3494e663f0d90c8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:36:37 +0000 Subject: [PATCH 01/29] Bun.serve: support directory tree routes via { dir: "..." } Mounting a directory at a wildcard route now serves its contents as static files: Bun.serve({ routes: { "/static/*": { dir: "./public" }, }, }); The request path after the prefix is percent-decoded once, scrubbed of NUL and backslash, lexically cleaned of "."/"..", and opened relative to the root directory. On Linux the open is openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS), so any resolution step (including symlink hops) that would leave the root is rejected by the kernel; other platforms fall back to openat() with the same lexical containment. Responses reuse the FileResponseStream path that FileRoute already uses (sendfile on Linux), with Content-Type from the file extension, Last-Modified, a weak W/"size-mtime" ETag, If-None-Match / If-Modified-Since / If-Match / If-Unmodified-Since handling, and single Range support. A small fixed-size per-path StatHash cache avoids reformatting the Last-Modified date on repeat hits. Directory requests serve index.html; misses yield to the next route. { dir, style } continues to select the framework router; { dir } alone is the new static directory route. --- packages/bun-types/serve.d.ts | 31 +- src/runtime/server/DirectoryRoute.rs | 654 ++++++++++++++++++ src/runtime/server/ServerConfig.rs | 21 + src/runtime/server/mod.rs | 33 + src/runtime/server/server_body.rs | 29 +- src/sys/linux_syscall.rs | 2 +- .../bun/http/serve-directory-routes.test.ts | 387 +++++++++++ 7 files changed, 1147 insertions(+), 10 deletions(-) create mode 100644 src/runtime/server/DirectoryRoute.rs create mode 100644 test/js/bun/http/serve-directory-routes.test.ts diff --git a/packages/bun-types/serve.d.ts b/packages/bun-types/serve.d.ts index f733b770170d..4e7cf4743a34 100644 --- a/packages/bun-types/serve.d.ts +++ b/packages/bun-types/serve.d.ts @@ -579,7 +579,36 @@ declare module "bun" { type Handler = (request: Req, server: S) => MaybePromise; - type BaseRouteValue = Response | false | HTMLBundle | BunFile; + /** + * Serve a directory tree at a URL prefix. + * + * The route path **must** end in `/*`. The part of the request URL after + * the prefix is percent-decoded once, cleaned of `.`/`..` segments, and + * opened relative to `dir`. On Linux the open uses + * `openat2(RESOLVE_BENEATH)`, so symlinks that would escape `dir` are + * rejected by the kernel. + * + * Responses carry `Content-Type` (from the file extension), + * `Last-Modified`, a weak `ETag`, and support single-range `Range` + * requests. Requests that resolve to a directory are served + * `index.html` from that directory. Missing files fall through to the + * next matching route (or `fetch`). + * + * @example + * ```ts + * Bun.serve({ + * routes: { + * "/static/*": { dir: "./public" }, + * }, + * }); + * ``` + */ + interface DirectoryRouteOptions { + /** Path to the directory to serve. */ + dir: string; + } + + type BaseRouteValue = Response | false | HTMLBundle | BunFile | DirectoryRouteOptions; type Routes = { [Path in R]: diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs new file mode 100644 index 000000000000..564f9ac132c2 --- /dev/null +++ b/src/runtime/server/DirectoryRoute.rs @@ -0,0 +1,654 @@ +//! Serve a directory tree at a URL prefix: `"/static/*": { dir: "./public" }`. +//! +//! Path resolution: percent-decode the request path once, reject NUL and `\`, +//! lexically collapse `.`/`..` (capped at the root), then on Linux open with +//! `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` so symlinks cannot +//! escape the root. Other platforms fall back to `openat(dirfd, rel)` and the +//! lexical clean is the only containment. Once opened the file is served via +//! `FileResponseStream` (same path as `FileRoute`): `Last-Modified`, weak ETag +//! (`W/"size-mtime"`), `If-None-Match`/`If-Modified-Since`, and single-range +//! `Range` handling all apply. + +use core::cell::Cell; +use core::ffi::c_void; +use core::mem::size_of; + +use bun_core::strings; +use bun_http::Method; +use bun_http_types::ETag; +use bun_io::{Closer, FileType}; +use bun_resolver::fs::StatHash; +use bun_sys::{self, Fd}; +use bun_uws::{AnyRequest, AnyResponse}; + +use crate::server::file_response_stream::StartOptions as FileResponseStreamOptions; +use crate::server::jsc::{JSGlobalObject, JsResult}; +use crate::server::{AnyServer, FileResponseStream, HTTPStatusText, RangeRequest, write_status}; + +bun_output::declare_scope!(DirectoryRoute, hidden); + +/// Per-path `StatHash` cache. Indexed by `xxhash(subpath) % N`; on collision +/// the slot is overwritten. Keeps the formatted `Last-Modified` string around +/// so repeat hits on unchanged files skip the date formatter. +const STAT_CACHE_SLOTS: usize = 1024; + +struct StatCacheEntry { + path_hash: u64, + stat_hash: StatHash, +} + +#[derive(bun_ptr::CellRefCounted)] +#[ref_count(destroy = DirectoryRoute::deinit)] +pub struct DirectoryRoute { + ref_count: Cell, + server: Cell>, + /// Open directory fd for the root. All per-request opens are relative to + /// this (openat2 `RESOLVE_BENEATH` on Linux). + root_fd: Fd, + /// Absolute path to the root directory (diagnostics and non-Linux fallback). + root_path: Box<[u8]>, + /// URL prefix this route is mounted at, without the trailing `*` and with + /// a trailing `/` — e.g. `"/static/"` for a `"/static/*"` route, `"/"` for + /// `"/*"`. `req.url()` is stripped against this to obtain the subpath. + url_prefix: Box<[u8]>, + stat_cache: Box<[Cell]>, +} + +impl DirectoryRoute { + #[inline] + pub fn set_server(&self, server: Option) { + self.server.set(server); + } + + pub fn memory_cost(&self) -> usize { + size_of::() + + self.root_path.len() + + self.url_prefix.len() + + self.stat_cache.len() * size_of::>() + } + + /// Open `root` and construct the route. `url_prefix` must end in `/`. + pub fn create( + global: &JSGlobalObject, + root: &[u8], + url_prefix: &[u8], + ) -> JsResult<*mut DirectoryRoute> { + debug_assert!(url_prefix.last() == Some(&b'/')); + + let root_fd = match bun_sys::open_a( + root, + bun_sys::O::DIRECTORY | bun_sys::O::CLOEXEC | bun_sys::O::RDONLY, + 0, + ) { + Ok(fd) => fd, + Err(err) => { + use bun_sys_jsc::ErrorJsc; + return Err(global.throw_value(err.to_js(global)?)); + } + }; + + let mut stat_cache = Vec::with_capacity(STAT_CACHE_SLOTS); + for _ in 0..STAT_CACHE_SLOTS { + stat_cache.push(Cell::new(StatCacheEntry { + path_hash: 0, + stat_hash: StatHash::default(), + })); + } + + Ok(bun_core::heap::into_raw(Box::new(DirectoryRoute { + ref_count: Cell::new(1), + server: Cell::new(None), + root_fd, + root_path: root.to_vec().into_boxed_slice(), + url_prefix: url_prefix.to_vec().into_boxed_slice(), + stat_cache: stat_cache.into_boxed_slice(), + }))) + } + + fn deinit(this: *mut DirectoryRoute) { + // SAFETY: `this` was allocated via heap::into_raw in `create` and the + // intrusive ref_count has reached 0. + unsafe { + #[cfg(windows)] + Closer::close((*this).root_fd, bun_sys::windows::libuv::Loop::get()); + #[cfg(not(windows))] + Closer::close((*this).root_fd, ()); + drop(bun_core::heap::take(this)); + } + } + + #[allow(clippy::not_unsafe_ptr_arg_deref)] + pub fn on_head_request(this: *mut DirectoryRoute, req: AnyRequest, resp: AnyResponse) { + // SAFETY: forwarded with the same precondition as `on_request`. + unsafe { Self::on(this, req, resp, Method::HEAD) }; + } + + #[allow(clippy::not_unsafe_ptr_arg_deref)] + pub fn on_request(this: *mut DirectoryRoute, req: AnyRequest, resp: AnyResponse) { + let method = Method::find(req.method()).unwrap_or(Method::GET); + // SAFETY: `this` is a live heap DirectoryRoute — intrusive ref held by + // the route table; only reached from the uWS route callback. + unsafe { Self::on(this, req, resp, method) }; + } + + /// # Safety + /// `this_ptr` must point to a live heap `DirectoryRoute` for the duration + /// of this call. The `ref_()` taken below keeps it alive until + /// `on_response_complete`. All mutation through `this` goes via `Cell`. + pub unsafe fn on( + this_ptr: *mut DirectoryRoute, + mut req: AnyRequest, + resp: AnyResponse, + method: Method, + ) { + // SAFETY: see fn-level Safety doc. + let this = unsafe { &*this_ptr }; + debug_assert!(this.server.get().is_some()); + this.ref_(); + if let Some(mut server) = this.server.get() { + server.on_pending_request(); + resp.timeout(server.config().idle_timeout); + } + + let mut decode_buf = bun_paths::path_buffer_pool::get(); + let mut path_buf = bun_paths::path_buffer_pool::get(); + let rel_len = match resolve_subpath( + req.url(), + &this.url_prefix, + &mut decode_buf.0[..], + &mut path_buf.0[..], + ) { + Some(n) => n, + None => { + bun_output::scoped_log!( + DirectoryRoute, + "reject {}", + bstr::BStr::new(req.url()) + ); + req.set_yield(true); + Self::on_response_complete(this_ptr, resp); + return; + } + }; + drop(decode_buf); + let rel: &[u8] = &path_buf.0[..rel_len]; + + let (fd, is_index) = match this.open_subpath(rel) { + Some(pair) => pair, + None => { + bun_output::scoped_log!(DirectoryRoute, "miss {}", bstr::BStr::new(rel)); + req.set_yield(true); + Self::on_response_complete(this_ptr, resp); + return; + } + }; + + // Every non-streaming return below hits this guard: closes the fd and + // releases the route ref. The streaming path clears it immediately + // before handing ownership to `FileResponseStream`. + let mut fd_guard = scopeguard::guard(true, move |owned| { + if owned { + #[cfg(windows)] + Closer::close(fd, bun_sys::windows::libuv::Loop::get()); + #[cfg(not(windows))] + Closer::close(fd, ()); + Self::on_response_complete(this_ptr, resp); + } + }); + + let stat = match bun_sys::fstat(fd) { + Ok(s) => s, + Err(_) => { + req.set_yield(true); + return; + } + }; + + let mode = stat.st_mode as bun_sys::Mode; + if bun_sys::S::ISDIR(mode) || !bun_sys::S::ISREG(mode) { + req.set_yield(true); + return; + } + + let size: u64 = u64::try_from(stat.st_size.max(0)).expect("int cast"); + + let path_hash = bun_wyhash::hash(rel); + let slot = &this.stat_cache[(path_hash as usize) % STAT_CACHE_SLOTS]; + let mut entry = slot.replace(StatCacheEntry { + path_hash: 0, + stat_hash: StatHash::default(), + }); + if entry.path_hash != path_hash { + entry.path_hash = path_hash; + entry.stat_hash = StatHash::default(); + } + entry.stat_hash.hash(&stat, rel); + let last_modified_ms = entry.stat_hash.last_modified_u64; + // 64-byte stack buffer: `Last-Modified` is exactly 29 bytes; the ETag + // is at most `W/"` + 16 + `-` + 16 + `"` = 36 bytes. + let mut lm_buf = [0u8; 32]; + let last_modified: Option<&[u8]> = entry.stat_hash.last_modified().map(|s| { + lm_buf[..s.len()].copy_from_slice(s); + &lm_buf[..s.len()] + }); + slot.set(entry); + + let mut etag_buf = [0u8; 40]; + let etag = format_weak_etag(&mut etag_buf, size, last_modified_ms); + + // RFC 9110 §13.2.2 precedence: If-Match → If-Unmodified-Since → + // If-None-Match → If-Modified-Since → Range. + let range: RangeRequest::Result = if method == Method::GET || method == Method::HEAD { + RangeRequest::from_request(&req, size) + } else { + RangeRequest::Result::None + }; + + let status_code: u16 = 'brk: { + if method == Method::HEAD || method == Method::GET { + if let Some(im) = req.header(b"if-match").filter(|v| !v.is_empty()) { + if !ETag::if_match(Some(etag), im) { + break 'brk 412; + } + } else if let Some(ius) = req + .header(b"if-unmodified-since") + .and_then(crate::jsc_hooks::parse_http_date) + { + if last_modified_ms > 0 && last_modified_ms / 1000 > ius / 1000 { + break 'brk 412; + } + } + + if let Some(inm) = req.header(b"if-none-match").filter(|v| !v.is_empty()) { + if ETag::if_none_match(etag, inm) { + break 'brk 304; + } + } else if let Some(ims) = req + .header(b"if-modified-since") + .and_then(crate::jsc_hooks::parse_http_date) + { + if last_modified_ms > 0 && last_modified_ms / 1000 <= ims / 1000 { + break 'brk 304; + } + } + } + + if matches!(range, RangeRequest::Result::Unsatisfiable) { + break 'brk 416; + } + if matches!(range, RangeRequest::Result::Satisfiable { .. }) { + break 'brk 206; + } + 200 + }; + + req.set_yield(false); + + write_any_status(resp, status_code); + resp.write_mark(); + + let ext: &[u8] = if is_index { b"html" } else { extension_for_mime(rel) }; + let mime = bun_http_types::MimeType::by_extension(ext); + resp.write_header(b"content-type", &mime.value); + if let Some(lm) = last_modified { + resp.write_header(b"last-modified", lm); + } + resp.write_header(b"etag", etag); + if let Some(srv) = this.server.get() { + if let Some(alt) = srv.h3_alt_svc() { + resp.write_header(b"alt-svc", alt); + } + } + + if HTTPStatusText::is_null_body(status_code) { + resp.end_without_body(resp.should_close_connection()); + return; + } + if status_code == 412 { + resp.end(b"", resp.should_close_connection()); + return; + } + + let (body_offset, body_len): (u64, Option) = match range { + RangeRequest::Result::Satisfiable { start, end } => { + let mut crbuf = [0u8; RangeRequest::CONTENT_RANGE_BUF]; + resp.write_header( + b"content-range", + RangeRequest::format_content_range(&mut crbuf, range, Some(size)), + ); + resp.write_header(b"accept-ranges", b"bytes"); + (start, Some(end - start + 1)) + } + RangeRequest::Result::Unsatisfiable => { + let mut crbuf = [0u8; RangeRequest::CONTENT_RANGE_BUF]; + resp.write_header( + b"content-range", + RangeRequest::format_content_range(&mut crbuf, range, Some(size)), + ); + resp.write_header(b"accept-ranges", b"bytes"); + resp.end(b"", resp.should_close_connection()); + return; + } + RangeRequest::Result::None => { + resp.write_header(b"accept-ranges", b"bytes"); + (0, Some(size)) + } + }; + + if !resp.state().has_written_content_length_header() { + resp.write_header_int(b"content-length", body_len.unwrap_or(size)); + resp.mark_wrote_content_length_header(); + } + + if method == Method::HEAD { + resp.end_without_body(resp.should_close_connection()); + return; + } + + bun_output::scoped_log!( + DirectoryRoute, + "serve {} ({} bytes)", + bstr::BStr::new(rel), + size + ); + + *fd_guard = false; + FileResponseStream::start(&FileResponseStreamOptions { + fd, + auto_close: true, + resp, + vm: bun_ptr::BackRef::new(this.server.get().unwrap().vm()), + file_type: FileType::File, + pollable: false, + offset: body_offset, + length: body_len, + idle_timeout: this.server.get().unwrap().config().idle_timeout, + ctx: this_ptr.cast::(), + on_complete: on_stream_complete, + on_abort: None, + on_error: on_stream_error, + }); + } + + /// Open `rel` beneath `root_fd`. Tries `index.html` when `rel` resolves to + /// a directory (the caller has already normalized `rel` and guaranteed no + /// `..` components). Returns `(fd, served_index_html)`. + fn open_subpath(&self, rel: &[u8]) -> Option<(Fd, bool)> { + if rel.is_empty() || rel == b"." { + return self.open_beneath(b"index.html").map(|fd| (fd, true)); + } + let fd = self.open_beneath(rel)?; + match bun_sys::fstat(fd) { + Ok(s) if bun_sys::S::ISDIR(s.st_mode as bun_sys::Mode) => { + #[cfg(windows)] + Closer::close(fd, bun_sys::windows::libuv::Loop::get()); + #[cfg(not(windows))] + Closer::close(fd, ()); + let mut buf = bun_paths::path_buffer_pool::get(); + let sub = &mut buf.0[..]; + if rel.len() + 1 + b"index.html".len() >= sub.len() { + return None; + } + sub[..rel.len()].copy_from_slice(rel); + sub[rel.len()] = b'/'; + sub[rel.len() + 1..rel.len() + 1 + b"index.html".len()] + .copy_from_slice(b"index.html"); + self.open_beneath(&sub[..rel.len() + 1 + b"index.html".len()]) + .map(|fd| (fd, true)) + } + Ok(_) => Some((fd, false)), + Err(_) => { + #[cfg(windows)] + Closer::close(fd, bun_sys::windows::libuv::Loop::get()); + #[cfg(not(windows))] + Closer::close(fd, ()); + None + } + } + } + + /// Open `rel` relative to `root_fd`. On Linux this is + /// `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`: the kernel rejects + /// any resolution step (including symlink hops) that escapes the root. + /// On other platforms the only containment is the lexical `..` strip done + /// by the caller in `resolve_subpath`. + fn open_beneath(&self, rel: &[u8]) -> Option { + let mut buf = bun_paths::path_buffer_pool::get(); + let zrel = bun_paths::resolve_path::z(rel, &mut *buf); + let flags = bun_sys::O::RDONLY | bun_sys::O::CLOEXEC | bun_sys::O::NONBLOCK; + #[cfg(any(target_os = "linux", target_os = "android"))] + { + bun_sys::openat2_beneath(self.root_fd, zrel, flags, 0).ok() + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + bun_sys::openat(self.root_fd, zrel, flags, 0).ok() + } + } + + fn on_response_complete(this: *mut DirectoryRoute, resp: AnyResponse) { + resp.clear_aborted(); + resp.clear_on_writable(); + resp.clear_timeout(); + // SAFETY: `this` is live (ref held by caller); `deref()` may free it. + unsafe { + if let Some(mut server) = (*this).server.get() { + server.on_static_request_complete(); + } + Self::deref(this); + } + } +} + +fn on_stream_complete(ctx: *mut c_void, resp: AnyResponse) { + DirectoryRoute::on_response_complete(ctx.cast::(), resp); +} + +fn on_stream_error(ctx: *mut c_void, resp: AnyResponse, _err: bun_sys::Error) { + DirectoryRoute::on_response_complete(ctx.cast::(), resp); +} + +fn write_any_status(resp: AnyResponse, status: u16) { + match resp { + AnyResponse::SSL(r) => write_status::(r, status), + AnyResponse::TCP(r) => write_status::(r, status), + AnyResponse::H3(r) => { + let mut b = bun_core::fmt::ItoaBuf::new(); + let s = bun_core::fmt::itoa(&mut b, status); + bun_opaque::opaque_deref_mut(r).write_status(s); + } + } +} + +/// Resolve the URL path into a root-relative filesystem subpath. +/// +/// Percent-decodes into `scratch`, writes the cleaned result into `out`, and +/// returns its length. `None` on any rejection: malformed percent-escape, NUL +/// byte, backslash, path that normalizes above the root, or prefix mismatch. +/// +/// Steps: +/// 1. Strip `url_prefix` (which always ends in `/`). +/// 2. Percent-decode the remainder **once**. +/// 3. Reject NUL and `\`. +/// 4. Lexically collapse `//`, `.`, `..` (capped at the root). +/// +/// The result never begins with `/` and never contains `..` as a component. +fn resolve_subpath( + url: &[u8], + url_prefix: &[u8], + scratch: &mut [u8], + out: &mut [u8], +) -> Option { + let after_prefix = if strings::starts_with(url, url_prefix) { + &url[url_prefix.len()..] + } else if url.len() + 1 == url_prefix.len() && url == &url_prefix[..url_prefix.len() - 1] { + // `"/static"` against prefix `"/static/"` — treat as the root. + b"" + } else { + return None; + }; + + if after_prefix.len() > scratch.len() { + return None; + } + + let decoded_len = match bun_url::PercentEncoding::decode_into( + &mut scratch[..after_prefix.len()], + after_prefix, + ) { + Ok(n) => n as usize, + Err(_) => return None, + }; + let decoded = &scratch[..decoded_len]; + + for &b in decoded { + if b == 0 || b == b'\\' { + return None; + } + } + + // Lexical clean: collapse `//`, drop `.`, apply `..` up to the root. Any + // `..` that would climb above the root is rejected outright rather than + // clamped so an off-by-one elsewhere can't silently expose the root. + let mut w: usize = 0; + let mut i: usize = 0; + while i < decoded_len { + while i < decoded_len && decoded[i] == b'/' { + i += 1; + } + let start = i; + while i < decoded_len && decoded[i] != b'/' { + i += 1; + } + let seg = &decoded[start..i]; + if seg.is_empty() || seg == b"." { + continue; + } + if seg == b".." { + if w == 0 { + return None; + } + while w > 0 && out[w - 1] != b'/' { + w -= 1; + } + if w > 0 { + w -= 1; + } + continue; + } + if w > 0 { + if w >= out.len() { + return None; + } + out[w] = b'/'; + w += 1; + } + if w + seg.len() > out.len() { + return None; + } + out[w..w + seg.len()].copy_from_slice(seg); + w += seg.len(); + } + + Some(w) +} + +/// `W/"-"` — matches the nginx/send scheme so CDNs +/// that special-case weak validators behave the same. +fn format_weak_etag(buf: &mut [u8; 40], size: u64, mtime_ms: u64) -> &[u8] { + use core::fmt::Write as _; + let mut c = bun_core::fmt::SliceCursor::new(&mut buf[..]); + let _ = write!(c, "W/\"{:x}-{:x}\"", size, mtime_ms / 1000); + let n = c.at; + &buf[..n] +} + +/// Extension without the leading dot, or `b""`. +fn extension_for_mime(path: &[u8]) -> &[u8] { + let ext = bun_paths::extension(path); + if ext.first() == Some(&b'.') { + &ext[1..] + } else { + ext + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resolve(url: &[u8], prefix: &[u8]) -> Option> { + let mut scratch = [0u8; 4096]; + let mut out = [0u8; 4096]; + resolve_subpath(url, prefix, &mut scratch, &mut out).map(|n| out[..n].to_vec()) + } + + #[test] + fn resolve_basic() { + assert_eq!(resolve(b"/static/a.txt", b"/static/").as_deref(), Some(&b"a.txt"[..])); + assert_eq!(resolve(b"/static/a/b.txt", b"/static/").as_deref(), Some(&b"a/b.txt"[..])); + assert_eq!(resolve(b"/a.txt", b"/").as_deref(), Some(&b"a.txt"[..])); + assert_eq!(resolve(b"/", b"/").as_deref(), Some(&b""[..])); + assert_eq!(resolve(b"/static", b"/static/").as_deref(), Some(&b""[..])); + assert_eq!(resolve(b"/static/", b"/static/").as_deref(), Some(&b""[..])); + } + + #[test] + fn resolve_percent() { + assert_eq!( + resolve(b"/static/hello%20world.txt", b"/static/").as_deref(), + Some(&b"hello world.txt"[..]) + ); + assert_eq!( + resolve(b"/static/a%2Fb.txt", b"/static/").as_deref(), + Some(&b"a/b.txt"[..]) + ); + // malformed % + assert_eq!(resolve(b"/static/a%2.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/a%", b"/static/"), None); + // double-encoded '..' stays literal after one decode + assert_eq!( + resolve(b"/static/%252e%252e/etc", b"/static/").as_deref(), + Some(&b"%2e%2e/etc"[..]) + ); + } + + #[test] + fn resolve_traversal() { + assert_eq!(resolve(b"/static/../etc/passwd", b"/static/"), None); + assert_eq!(resolve(b"/static/..%2Fetc", b"/static/"), None); + assert_eq!(resolve(b"/static/%2e%2e/etc", b"/static/"), None); + assert_eq!(resolve(b"/static/a/../../etc", b"/static/"), None); + assert_eq!( + resolve(b"/static/a/../b.txt", b"/static/").as_deref(), + Some(&b"b.txt"[..]) + ); + assert_eq!( + resolve(b"/static/a/./b.txt", b"/static/").as_deref(), + Some(&b"a/b.txt"[..]) + ); + assert_eq!( + resolve(b"/static/a//b.txt", b"/static/").as_deref(), + Some(&b"a/b.txt"[..]) + ); + // `....` is not `..` + assert_eq!( + resolve(b"/static/..../etc", b"/static/").as_deref(), + Some(&b"..../etc"[..]) + ); + } + + #[test] + fn resolve_nul_backslash() { + assert_eq!(resolve(b"/static/a%00.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/a\\b.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/a%5Cb.txt", b"/static/"), None); + } + + #[test] + fn etag_format() { + let mut buf = [0u8; 40]; + assert_eq!(format_weak_etag(&mut buf, 0, 0), b"W/\"0-0\""); + assert_eq!(format_weak_etag(&mut buf, 1234, 5678_000), b"W/\"4d2-162e\""); + } +} diff --git a/src/runtime/server/ServerConfig.rs b/src/runtime/server/ServerConfig.rs index 191a5136a37a..fcabc27135c4 100644 --- a/src/runtime/server/ServerConfig.rs +++ b/src/runtime/server/ServerConfig.rs @@ -534,6 +534,27 @@ impl StaticRouteLike for super::FileRoute { } } +impl StaticRouteLike for super::DirectoryRoute { + unsafe fn set_server(this: *mut Self, server: AnyServer) { + // SAFETY: caller guarantees `this` is live. + unsafe { (*this).set_server(Some(server)) }; + } + unsafe fn on_request( + this: *mut Self, + req: bun_uws_sys::AnyRequest, + resp: bun_uws_sys::AnyResponse, + ) { + Self::on_request(this, req, resp) + } + unsafe fn on_head_request( + this: *mut Self, + req: bun_uws_sys::AnyRequest, + resp: bun_uws_sys::AnyResponse, + ) { + Self::on_head_request(this, req, resp) + } +} + impl StaticRouteLike for super::html_bundle::Route { unsafe fn set_server(this: *mut Self, server: AnyServer) { // SAFETY: caller guarantees `this` is live. diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index c47f970f7c72..1bdd78af123a 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -77,6 +77,10 @@ pub use static_route::StaticRoute; pub mod file_route; pub use file_route::FileRoute; +#[path = "DirectoryRoute.rs"] +pub mod directory_route; +pub use directory_route::DirectoryRoute; + #[path = "FileResponseStream.rs"] pub mod file_response_stream; pub use file_response_stream::FileResponseStream; @@ -144,6 +148,8 @@ pub enum AnyRoute { Static(core::ptr::NonNull), /// Serve a file from disk File(core::ptr::NonNull), + /// Serve a directory tree — `"/static/*": { dir: "./public" }` + Directory(core::ptr::NonNull), /// Bundle an HTML import — `import html from "./index.html"; "/": html` Html(bun_ptr::RefPtr), /// Use file-system routing — `"/*": { dir: …, style: "nextjs-pages" }` @@ -161,6 +167,7 @@ impl AnyRoute { match self { AnyRoute::Static(p) => bun_ptr::BackRef::from(*p).memory_cost(), AnyRoute::File(p) => bun_ptr::BackRef::from(*p).memory_cost(), + AnyRoute::Directory(p) => bun_ptr::BackRef::from(*p).memory_cost(), AnyRoute::Html(r) => r.data().memory_cost(), AnyRoute::FrameworkRouter(_) => { core::mem::size_of::() @@ -172,6 +179,7 @@ impl AnyRoute { match self { AnyRoute::Static(p) => bun_ptr::BackRef::from(*p).ref_(), AnyRoute::File(p) => bun_ptr::BackRef::from(*p).ref_(), + AnyRoute::Directory(p) => bun_ptr::BackRef::from(*p).ref_(), AnyRoute::Html(r) => { // SAFETY: RefPtr keeps the pointee live while held in the route table. unsafe { bun_ptr::RefCount::::ref_(r.as_ptr()) }; @@ -185,6 +193,8 @@ impl AnyRoute { AnyRoute::Static(p) => unsafe { StaticRoute::deref_(p.as_ptr()) }, // SAFETY: see above. AnyRoute::File(p) => unsafe { FileRoute::deref(p.as_ptr()) }, + // SAFETY: see above. + AnyRoute::Directory(p) => unsafe { DirectoryRoute::deref(p.as_ptr()) }, AnyRoute::Html(r) => r.deref(), AnyRoute::FrameworkRouter(_) => {} // not reference counted } @@ -2370,6 +2380,29 @@ impl NewServer { } } } + AnyRoute::Directory(p) => { + server_config::apply_static_route::( + any_server, + app, + p.as_ptr(), + &entry.path, + entry.method, + path_has_user_head_route, + ); + if Self::HAS_H3 { + if let Some(h3_app) = self.h3_app { + server_config::apply_static_route_h3::( + any_server, + // S008: `h3::App` is an `opaque_ffi!` ZST — safe deref. + bun_opaque::opaque_deref_mut(h3_app), + p.as_ptr(), + &entry.path, + entry.method, + path_has_user_head_route, + ); + } + } + } AnyRoute::Html(r) => { server_config::apply_static_route::( any_server, diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index b14647a9bc51..7b9fb5bb7d98 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -772,20 +772,33 @@ impl AnyRoute { if let Some(dir) = argument.get_optional_slice(global, b"dir")? { let relative_root = init_ctx.js_string_allocations.track(dir); - let style: FrameworkRouter::Style = - if let Some(style_js) = argument.get(global, b"style")? { - FrameworkRouter::Style::from_js(style_js, global)? - } else { - FrameworkRouter::Style::NextjsPages - }; - // Style impls Drop; `?` drops it on the error path. - if !strings::ends_with(path, b"/*") { return Err(global.throw_invalid_arguments(format_args!( "To mount a directory, make sure the path ends in `/*`" ))); } + let style_js = argument.get(global, b"style")?; + if style_js.is_none() { + // `{ dir }` without `style` serves the directory tree + // verbatim; `{ dir, style }` opts into framework routing. + let url_prefix: &[u8] = if path.len() == 2 { + b"/" + } else { + &path[..path.len() - 1] + }; + let route = + super::DirectoryRoute::create(global, relative_root, url_prefix)?; + return Ok(Some(AnyRoute::Directory( + NonNull::new(route) + .expect("DirectoryRoute::create returns a fresh heap allocation"), + ))); + } + + let style: FrameworkRouter::Style = + FrameworkRouter::Style::from_js(style_js.unwrap(), global)?; + // Style impls Drop; `?` drops it on the error path. + // trim the /* // NOTE: `FileSystemRouterType` fields are `Cow<'static,[u8]>`. // Rather diff --git a/src/sys/linux_syscall.rs b/src/sys/linux_syscall.rs index 433cbb830ab3..777b5955c6c2 100644 --- a/src/sys/linux_syscall.rs +++ b/src/sys/linux_syscall.rs @@ -100,7 +100,7 @@ pub(crate) fn openat2_beneath(dir: Fd, path: &ZStr, flags: i32, mode: Mode) -> R path.as_cstr(), oflags, mode, - rustix::fs::ResolveFlags::BENEATH, + rustix::fs::ResolveFlags::BENEATH | rustix::fs::ResolveFlags::NO_MAGICLINKS, ) }) .map(own_fd) diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts new file mode 100644 index 000000000000..53a41ced7c5b --- /dev/null +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -0,0 +1,387 @@ +import { serve, type Server } from "bun"; +import { afterEach, describe, expect, it } from "bun:test"; +import { symlinkSync } from "fs"; +import { isLinux, tempDir } from "harness"; +import { join } from "path"; + +describe("Bun.serve() directory routes", () => { + let server: Server | undefined; + + afterEach(() => { + server?.stop(true); + server = undefined; + }); + + it("serves files from a directory at /*", async () => { + using dir = tempDir("serve-dir-root", { + "public/index.html": "

Hello World

", + "public/style.css": "body { margin: 0; }", + "public/script.js": "console.log('hello');", + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const html = await fetch(`${server.url}index.html`); + expect(html.status).toBe(200); + expect(html.headers.get("content-type")).toContain("text/html"); + expect(await html.text()).toBe("

Hello World

"); + + const css = await fetch(`${server.url}style.css`); + expect(css.status).toBe(200); + expect(css.headers.get("content-type")).toContain("text/css"); + expect(await css.text()).toBe("body { margin: 0; }"); + + const js = await fetch(`${server.url}script.js`); + expect(js.status).toBe(200); + expect(js.headers.get("content-type")).toContain("javascript"); + expect(await js.text()).toBe("console.log('hello');"); + }); + + it("serves nested directories", async () => { + using dir = tempDir("serve-dir-nested", { + "public/assets/images/logo.svg": "", + "public/assets/styles/main.css": "body { color: red; }", + "public/js/app.js": "const x = 1;", + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + expect(await (await fetch(`${server.url}assets/images/logo.svg`)).text()).toBe(""); + expect(await (await fetch(`${server.url}assets/styles/main.css`)).text()).toBe("body { color: red; }"); + expect(await (await fetch(`${server.url}js/app.js`)).text()).toBe("const x = 1;"); + }); + + it("serves from a custom prefix", async () => { + using dir = tempDir("serve-dir-prefix", { + "assets/file.txt": "Hello from assets", + "assets/sub/deep.txt": "deep", + }); + + server = serve({ + port: 0, + routes: { "/static/*": { dir: join(String(dir), "assets") } }, + fetch: () => new Response("fallback", { status: 404 }), + }); + + const res = await fetch(`${server.url}static/file.txt`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("Hello from assets"); + + const deep = await fetch(`${server.url}static/sub/deep.txt`); + expect(deep.status).toBe(200); + expect(await deep.text()).toBe("deep"); + }); + + it("serves index.html for directory requests", async () => { + using dir = tempDir("serve-dir-index", { + "public/index.html": "

root

", + "public/sub/index.html": "

sub

", + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const root = await fetch(`${server.url}`); + expect(root.status).toBe(200); + expect(root.headers.get("content-type")).toContain("text/html"); + expect(await root.text()).toBe("

root

"); + + const sub = await fetch(`${server.url}sub/`); + expect(sub.status).toBe(200); + expect(await sub.text()).toBe("

sub

"); + + const subNoSlash = await fetch(`${server.url}sub`); + expect(subNoSlash.status).toBe(200); + expect(await subNoSlash.text()).toBe("

sub

"); + }); + + it("falls through to fetch for missing files", async () => { + using dir = tempDir("serve-dir-404", { + "public/exists.txt": "yes", + }); + + server = serve({ + port: 0, + routes: { "/static/*": { dir: join(String(dir), "public") } }, + fetch: () => new Response("fallback", { status: 404 }), + }); + + const hit = await fetch(`${server.url}static/exists.txt`); + expect(hit.status).toBe(200); + expect(await hit.text()).toBe("yes"); + + const miss = await fetch(`${server.url}static/nope.txt`); + expect(miss.status).toBe(404); + expect(await miss.text()).toBe("fallback"); + }); + + it("supports HEAD", async () => { + using dir = tempDir("serve-dir-head", { + "public/large.txt": Buffer.alloc(10000, "x").toString(), + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const res = await fetch(`${server.url}large.txt`, { method: "HEAD" }); + expect(res.status).toBe(200); + expect(res.headers.get("content-length")).toBe("10000"); + expect(await res.text()).toBe(""); + }); + + it("sends Last-Modified and a weak ETag", async () => { + using dir = tempDir("serve-dir-lm", { + "public/data.json": '{"key":"value"}', + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const res = await fetch(`${server.url}data.json`); + expect(res.status).toBe(200); + expect(res.headers.get("last-modified")).toBeTruthy(); + const etag = res.headers.get("etag"); + expect(etag).toMatch(/^W\/"[0-9a-f]+-[0-9a-f]+"$/); + expect(res.headers.get("accept-ranges")).toBe("bytes"); + }); + + it("honors If-None-Match with 304", async () => { + using dir = tempDir("serve-dir-inm", { + "public/data.json": '{"key":"value"}', + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const first = await fetch(`${server.url}data.json`); + const etag = first.headers.get("etag")!; + expect(etag).toBeTruthy(); + + const second = await fetch(`${server.url}data.json`, { + headers: { "if-none-match": etag }, + }); + expect(second.status).toBe(304); + expect(await second.text()).toBe(""); + }); + + it("honors If-Modified-Since with 304", async () => { + using dir = tempDir("serve-dir-ims", { + "public/data.json": '{"key":"value"}', + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const first = await fetch(`${server.url}data.json`); + const lm = first.headers.get("last-modified")!; + expect(lm).toBeTruthy(); + + const second = await fetch(`${server.url}data.json`, { + headers: { "if-modified-since": lm }, + }); + expect(second.status).toBe(304); + + const stale = await fetch(`${server.url}data.json`, { + headers: { "if-modified-since": "Sat, 01 Jan 2000 00:00:00 GMT" }, + }); + expect(stale.status).toBe(200); + expect(await stale.text()).toBe('{"key":"value"}'); + }); + + it("handles Range requests", async () => { + using dir = tempDir("serve-dir-range", { + "public/data.bin": "0123456789", + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const res = await fetch(`${server.url}data.bin`, { + headers: { range: "bytes=2-5" }, + }); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toBe("bytes 2-5/10"); + expect(await res.text()).toBe("2345"); + + const unsat = await fetch(`${server.url}data.bin`, { + headers: { range: "bytes=100-200" }, + }); + expect(unsat.status).toBe(416); + expect(unsat.headers.get("content-range")).toBe("bytes */10"); + }); + + it("rejects path traversal", async () => { + using dir = tempDir("serve-dir-traversal", { + "secret.txt": "SECRET", + "public/ok.txt": "ok", + }); + + server = serve({ + port: 0, + routes: { "/static/*": { dir: join(String(dir), "public") } }, + fetch: () => new Response("fallback", { status: 404 }), + }); + + expect(await (await fetch(`${server.url}static/ok.txt`)).text()).toBe("ok"); + + // fetch() normalizes `..` in the URL client-side, so send the raw bytes + // over a socket to exercise the server's resolver. + async function raw(path: string): Promise<{ status: number; body: string }> { + const { promise, resolve } = Promise.withResolvers(); + let buf = ""; + const sock = await Bun.connect({ + hostname: "127.0.0.1", + port: server!.port, + socket: { + data(_s, chunk) { + buf += chunk.toString("latin1"); + }, + close() { + resolve(buf); + }, + error() { + resolve(buf); + }, + }, + }); + sock.write(`GET ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`); + const full = await promise; + const status = parseInt(full.slice(9, 12), 10); + const body = full.split("\r\n\r\n").slice(1).join("\r\n\r\n"); + return { status, body }; + } + + for (const p of [ + "/static/../secret.txt", + "/static/..%2Fsecret.txt", + "/static/%2e%2e/secret.txt", + "/static/%2e%2e%2fsecret.txt", + "/static/ok.txt/../../secret.txt", + "/static/a%00.txt", + "/static/a%5Cb.txt", + ]) { + const { status, body } = await raw(p); + expect(body).not.toContain("SECRET"); + expect([404, 400]).toContain(status); + } + + // Double-encoded `..` should decode once to `%2e%2e` and miss on disk. + const dbl = await raw("/static/%252e%252e/secret.txt"); + expect(dbl.body).not.toContain("SECRET"); + }); + + it.skipIf(!isLinux)("rejects symlink escapes on Linux via RESOLVE_BENEATH", async () => { + using dir = tempDir("serve-dir-symlink", { + "secret.txt": "SECRET", + "public/ok.txt": "ok", + "public/inside.txt": "inside", + }); + + const root = String(dir); + symlinkSync(join(root, "secret.txt"), join(root, "public", "escape")); + // RESOLVE_BENEATH rejects absolute symlink targets (they resolve from `/`, + // which is outside the root); an in-root symlink must be relative. + symlinkSync("inside.txt", join(root, "public", "alias")); + + server = serve({ + port: 0, + routes: { "/static/*": { dir: join(root, "public") } }, + fetch: () => new Response("fallback", { status: 404 }), + }); + + // Symlink that stays inside the root is allowed. + const inside = await fetch(`${server.url}static/alias`); + expect(inside.status).toBe(200); + expect(await inside.text()).toBe("inside"); + + // Symlink that escapes the root is rejected by the kernel. + const escape = await fetch(`${server.url}static/escape`); + expect(await escape.text()).not.toContain("SECRET"); + expect(escape.status).toBe(404); + }); + + it("percent-decodes file names", async () => { + using dir = tempDir("serve-dir-pct", { + "public/hello world.txt": "hi", + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const res = await fetch(`${server.url}hello%20world.txt`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("hi"); + }); + + it("supports multiple directory routes", async () => { + using dir = tempDir("serve-dir-multi", { + "a/one.txt": "one", + "b/two.txt": "two", + }); + + server = serve({ + port: 0, + routes: { + "/a/*": { dir: join(String(dir), "a") }, + "/b/*": { dir: join(String(dir), "b") }, + }, + }); + + expect(await (await fetch(`${server.url}a/one.txt`)).text()).toBe("one"); + expect(await (await fetch(`${server.url}b/two.txt`)).text()).toBe("two"); + }); + + it("rejects non-wildcard paths", () => { + using dir = tempDir("serve-dir-nowild", { "public/x.txt": "x" }); + expect(() => + serve({ + port: 0, + routes: { "/static": { dir: join(String(dir), "public") } }, + }), + ).toThrow(/ends in `\/\*`/); + }); + + it("throws if the directory does not exist", () => { + expect(() => + serve({ + port: 0, + routes: { "/static/*": { dir: "/nonexistent/path/that/does/not/exist" } }, + }), + ).toThrow(); + }); + + it("is reflected in server.routes", async () => { + // Ensure DirectoryRoute is wired through AnyRoute introspection without + // crashing (guards the match arms added for the new variant). + using dir = tempDir("serve-dir-introspect", { "public/x.txt": "x" }); + server = serve({ + port: 0, + routes: { "/static/*": { dir: join(String(dir), "public") } }, + }); + expect(await (await fetch(`${server.url}static/x.txt`)).text()).toBe("x"); + server.reload({ + routes: { "/static/*": { dir: join(String(dir), "public") } }, + }); + expect(await (await fetch(`${server.url}static/x.txt`)).text()).toBe("x"); + }); +}); From e212640eb3239057b9554b7d925915711f464edc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:38:31 +0000 Subject: [PATCH 02/29] [autofix.ci] apply automated fixes --- src/runtime/server/DirectoryRoute.rs | 27 ++++++++++++++++++--------- src/runtime/server/server_body.rs | 10 ++++------ 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 564f9ac132c2..5b44bdacf3a8 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -160,11 +160,7 @@ impl DirectoryRoute { ) { Some(n) => n, None => { - bun_output::scoped_log!( - DirectoryRoute, - "reject {}", - bstr::BStr::new(req.url()) - ); + bun_output::scoped_log!(DirectoryRoute, "reject {}", bstr::BStr::new(req.url())); req.set_yield(true); Self::on_response_complete(this_ptr, resp); return; @@ -287,7 +283,11 @@ impl DirectoryRoute { write_any_status(resp, status_code); resp.write_mark(); - let ext: &[u8] = if is_index { b"html" } else { extension_for_mime(rel) }; + let ext: &[u8] = if is_index { + b"html" + } else { + extension_for_mime(rel) + }; let mime = bun_http_types::MimeType::by_extension(ext); resp.write_header(b"content-type", &mime.value); if let Some(lm) = last_modified { @@ -585,8 +585,14 @@ mod tests { #[test] fn resolve_basic() { - assert_eq!(resolve(b"/static/a.txt", b"/static/").as_deref(), Some(&b"a.txt"[..])); - assert_eq!(resolve(b"/static/a/b.txt", b"/static/").as_deref(), Some(&b"a/b.txt"[..])); + assert_eq!( + resolve(b"/static/a.txt", b"/static/").as_deref(), + Some(&b"a.txt"[..]) + ); + assert_eq!( + resolve(b"/static/a/b.txt", b"/static/").as_deref(), + Some(&b"a/b.txt"[..]) + ); assert_eq!(resolve(b"/a.txt", b"/").as_deref(), Some(&b"a.txt"[..])); assert_eq!(resolve(b"/", b"/").as_deref(), Some(&b""[..])); assert_eq!(resolve(b"/static", b"/static/").as_deref(), Some(&b""[..])); @@ -649,6 +655,9 @@ mod tests { fn etag_format() { let mut buf = [0u8; 40]; assert_eq!(format_weak_etag(&mut buf, 0, 0), b"W/\"0-0\""); - assert_eq!(format_weak_etag(&mut buf, 1234, 5678_000), b"W/\"4d2-162e\""); + assert_eq!( + format_weak_etag(&mut buf, 1234, 5678_000), + b"W/\"4d2-162e\"" + ); } } diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 7b9fb5bb7d98..e73bdd54fea9 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -787,12 +787,10 @@ impl AnyRoute { } else { &path[..path.len() - 1] }; - let route = - super::DirectoryRoute::create(global, relative_root, url_prefix)?; - return Ok(Some(AnyRoute::Directory( - NonNull::new(route) - .expect("DirectoryRoute::create returns a fresh heap allocation"), - ))); + let route = super::DirectoryRoute::create(global, relative_root, url_prefix)?; + return Ok(Some(AnyRoute::Directory(NonNull::new(route).expect( + "DirectoryRoute::create returns a fresh heap allocation", + )))); } let style: FrameworkRouter::Style = From d5911d5660b4c4e3879807d511978c287dfdcb42 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:03:14 +0000 Subject: [PATCH 03/29] DirectoryRoute: address review feedback - Share the RFC 9110 precondition ladder, Content-Range writer, and status writer between FileRoute and DirectoryRoute (new pub(crate) helpers in file_route.rs). - Use bun_sys::File for fd ownership instead of scopeguard; the request ref is released via a small Drop guard rather than open-coded unsafe. - Drop duplicated unsafe scaffolding: on() is now a safe &self method dispatched via BackRef from the uWS trampolines. - Switch from RESOLVE_BENEATH to RESOLVE_IN_ROOT | NO_MAGICLINKS and add an ENOSYS/EPERM/EINVAL fallback to plain openat on kernels without openat2. - Reuse resolve_path::normalize_string_buf for the .. clean instead of a hand-rolled loop. - Strip the query string before resolving (req.url() is uWS getFullUrl()). - Convert Windows openat HANDLE fds to libuv fds before streaming. - Drop the unused root_path field; trim comments. --- packages/bun-types/serve.d.ts | 4 +- src/runtime/server/DirectoryRoute.rs | 553 ++++++------------ src/runtime/server/FileRoute.rs | 205 ++++--- src/sys/lib.rs | 27 + src/sys/linux_syscall.rs | 17 + .../bun/http/serve-directory-routes.test.ts | 42 +- 6 files changed, 356 insertions(+), 492 deletions(-) diff --git a/packages/bun-types/serve.d.ts b/packages/bun-types/serve.d.ts index 4e7cf4743a34..f2b5ed5d4810 100644 --- a/packages/bun-types/serve.d.ts +++ b/packages/bun-types/serve.d.ts @@ -585,8 +585,8 @@ declare module "bun" { * The route path **must** end in `/*`. The part of the request URL after * the prefix is percent-decoded once, cleaned of `.`/`..` segments, and * opened relative to `dir`. On Linux the open uses - * `openat2(RESOLVE_BENEATH)`, so symlinks that would escape `dir` are - * rejected by the kernel. + * `openat2(RESOLVE_IN_ROOT)`, so symlinks that would escape `dir` are + * clamped by the kernel. * * Responses carry `Content-Type` (from the file extension), * `Last-Modified`, a weak `ETag`, and support single-range `Range` diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 5b44bdacf3a8..2d78fd5527c4 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -1,35 +1,26 @@ //! Serve a directory tree at a URL prefix: `"/static/*": { dir: "./public" }`. -//! -//! Path resolution: percent-decode the request path once, reject NUL and `\`, -//! lexically collapse `.`/`..` (capped at the root), then on Linux open with -//! `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` so symlinks cannot -//! escape the root. Other platforms fall back to `openat(dirfd, rel)` and the -//! lexical clean is the only containment. Once opened the file is served via -//! `FileResponseStream` (same path as `FileRoute`): `Last-Modified`, weak ETag -//! (`W/"size-mtime"`), `If-None-Match`/`If-Modified-Since`, and single-range -//! `Range` handling all apply. use core::cell::Cell; use core::ffi::c_void; use core::mem::size_of; +use core::ptr::NonNull; use bun_core::strings; use bun_http::Method; -use bun_http_types::ETag; -use bun_io::{Closer, FileType}; +use bun_io::FileType; +use bun_paths::resolve_path; use bun_resolver::fs::StatHash; -use bun_sys::{self, Fd}; +use bun_sys::{self, Fd, File}; use bun_uws::{AnyRequest, AnyResponse}; use crate::server::file_response_stream::StartOptions as FileResponseStreamOptions; +use crate::server::file_route::{status_for_preconditions, write_any_status, write_content_range}; use crate::server::jsc::{JSGlobalObject, JsResult}; -use crate::server::{AnyServer, FileResponseStream, HTTPStatusText, RangeRequest, write_status}; +use crate::server::{AnyServer, FileResponseStream, HTTPStatusText, RangeRequest}; bun_output::declare_scope!(DirectoryRoute, hidden); -/// Per-path `StatHash` cache. Indexed by `xxhash(subpath) % N`; on collision -/// the slot is overwritten. Keeps the formatted `Last-Modified` string around -/// so repeat hits on unchanged files skip the date formatter. +/// `wyhash(subpath) % N` direct-mapped StatHash cache; collisions overwrite. const STAT_CACHE_SLOTS: usize = 1024; struct StatCacheEntry { @@ -37,19 +28,22 @@ struct StatCacheEntry { stat_hash: StatHash, } +impl Default for StatCacheEntry { + fn default() -> Self { + Self { + path_hash: 0, + stat_hash: StatHash::default(), + } + } +} + #[derive(bun_ptr::CellRefCounted)] #[ref_count(destroy = DirectoryRoute::deinit)] pub struct DirectoryRoute { ref_count: Cell, server: Cell>, - /// Open directory fd for the root. All per-request opens are relative to - /// this (openat2 `RESOLVE_BENEATH` on Linux). - root_fd: Fd, - /// Absolute path to the root directory (diagnostics and non-Linux fallback). - root_path: Box<[u8]>, - /// URL prefix this route is mounted at, without the trailing `*` and with - /// a trailing `/` — e.g. `"/static/"` for a `"/static/*"` route, `"/"` for - /// `"/*"`. `req.url()` is stripped against this to obtain the subpath. + root_fd: Cell, + /// Mount prefix with trailing `/` (`"/static/"`, or `"/"` for `"/*"`). url_prefix: Box<[u8]>, stat_cache: Box<[Cell]>, } @@ -62,7 +56,6 @@ impl DirectoryRoute { pub fn memory_cost(&self) -> usize { size_of::() - + self.root_path.len() + self.url_prefix.len() + self.stat_cache.len() * size_of::>() } @@ -89,197 +82,102 @@ impl DirectoryRoute { let mut stat_cache = Vec::with_capacity(STAT_CACHE_SLOTS); for _ in 0..STAT_CACHE_SLOTS { - stat_cache.push(Cell::new(StatCacheEntry { - path_hash: 0, - stat_hash: StatHash::default(), - })); + stat_cache.push(Cell::new(StatCacheEntry::default())); } Ok(bun_core::heap::into_raw(Box::new(DirectoryRoute { ref_count: Cell::new(1), server: Cell::new(None), - root_fd, - root_path: root.to_vec().into_boxed_slice(), + root_fd: Cell::new(root_fd), url_prefix: url_prefix.to_vec().into_boxed_slice(), stat_cache: stat_cache.into_boxed_slice(), }))) } fn deinit(this: *mut DirectoryRoute) { - // SAFETY: `this` was allocated via heap::into_raw in `create` and the - // intrusive ref_count has reached 0. - unsafe { - #[cfg(windows)] - Closer::close((*this).root_fd, bun_sys::windows::libuv::Loop::get()); - #[cfg(not(windows))] - Closer::close((*this).root_fd, ()); - drop(bun_core::heap::take(this)); - } + // SAFETY: heap-allocated in `create`; refcount has reached 0. + let this = unsafe { bun_core::heap::take(this) }; + drop(File::from_fd(this.root_fd.get())); } #[allow(clippy::not_unsafe_ptr_arg_deref)] pub fn on_head_request(this: *mut DirectoryRoute, req: AnyRequest, resp: AnyResponse) { - // SAFETY: forwarded with the same precondition as `on_request`. - unsafe { Self::on(this, req, resp, Method::HEAD) }; + bun_ptr::BackRef::from(NonNull::new(this).unwrap()).on(req, resp, Method::HEAD); } #[allow(clippy::not_unsafe_ptr_arg_deref)] pub fn on_request(this: *mut DirectoryRoute, req: AnyRequest, resp: AnyResponse) { let method = Method::find(req.method()).unwrap_or(Method::GET); - // SAFETY: `this` is a live heap DirectoryRoute — intrusive ref held by - // the route table; only reached from the uWS route callback. - unsafe { Self::on(this, req, resp, method) }; + bun_ptr::BackRef::from(NonNull::new(this).unwrap()).on(req, resp, method); } - /// # Safety - /// `this_ptr` must point to a live heap `DirectoryRoute` for the duration - /// of this call. The `ref_()` taken below keeps it alive until - /// `on_response_complete`. All mutation through `this` goes via `Cell`. - pub unsafe fn on( - this_ptr: *mut DirectoryRoute, - mut req: AnyRequest, - resp: AnyResponse, - method: Method, - ) { - // SAFETY: see fn-level Safety doc. - let this = unsafe { &*this_ptr }; - debug_assert!(this.server.get().is_some()); - this.ref_(); - if let Some(mut server) = this.server.get() { + fn on(&self, mut req: AnyRequest, resp: AnyResponse, method: Method) { + debug_assert!(self.server.get().is_some()); + self.ref_(); + let guard = ResponseGuard { + route: NonNull::from(self), + resp, + }; + if let Some(mut server) = self.server.get() { server.on_pending_request(); resp.timeout(server.config().idle_timeout); } let mut decode_buf = bun_paths::path_buffer_pool::get(); let mut path_buf = bun_paths::path_buffer_pool::get(); - let rel_len = match resolve_subpath( + let Some(rel_len) = resolve_subpath( req.url(), - &this.url_prefix, + &self.url_prefix, &mut decode_buf.0[..], &mut path_buf.0[..], - ) { - Some(n) => n, - None => { - bun_output::scoped_log!(DirectoryRoute, "reject {}", bstr::BStr::new(req.url())); - req.set_yield(true); - Self::on_response_complete(this_ptr, resp); - return; - } + ) else { + bun_output::scoped_log!(DirectoryRoute, "reject {}", bstr::BStr::new(req.url())); + req.set_yield(true); + return; }; drop(decode_buf); let rel: &[u8] = &path_buf.0[..rel_len]; - let (fd, is_index) = match this.open_subpath(rel) { - Some(pair) => pair, - None => { - bun_output::scoped_log!(DirectoryRoute, "miss {}", bstr::BStr::new(rel)); - req.set_yield(true); - Self::on_response_complete(this_ptr, resp); - return; - } + let Some((file, is_index)) = self.open_subpath(rel) else { + bun_output::scoped_log!(DirectoryRoute, "miss {}", bstr::BStr::new(rel)); + req.set_yield(true); + return; }; - // Every non-streaming return below hits this guard: closes the fd and - // releases the route ref. The streaming path clears it immediately - // before handing ownership to `FileResponseStream`. - let mut fd_guard = scopeguard::guard(true, move |owned| { - if owned { - #[cfg(windows)] - Closer::close(fd, bun_sys::windows::libuv::Loop::get()); - #[cfg(not(windows))] - Closer::close(fd, ()); - Self::on_response_complete(this_ptr, resp); - } - }); - - let stat = match bun_sys::fstat(fd) { - Ok(s) => s, - Err(_) => { - req.set_yield(true); - return; - } + let Ok(stat) = file.stat() else { + req.set_yield(true); + return; }; - let mode = stat.st_mode as bun_sys::Mode; - if bun_sys::S::ISDIR(mode) || !bun_sys::S::ISREG(mode) { + if !bun_sys::S::ISREG(mode) { req.set_yield(true); return; } let size: u64 = u64::try_from(stat.st_size.max(0)).expect("int cast"); - let path_hash = bun_wyhash::hash(rel); - let slot = &this.stat_cache[(path_hash as usize) % STAT_CACHE_SLOTS]; - let mut entry = slot.replace(StatCacheEntry { - path_hash: 0, - stat_hash: StatHash::default(), - }); - if entry.path_hash != path_hash { - entry.path_hash = path_hash; - entry.stat_hash = StatHash::default(); - } - entry.stat_hash.hash(&stat, rel); - let last_modified_ms = entry.stat_hash.last_modified_u64; - // 64-byte stack buffer: `Last-Modified` is exactly 29 bytes; the ETag - // is at most `W/"` + 16 + `-` + 16 + `"` = 36 bytes. - let mut lm_buf = [0u8; 32]; - let last_modified: Option<&[u8]> = entry.stat_hash.last_modified().map(|s| { - lm_buf[..s.len()].copy_from_slice(s); - &lm_buf[..s.len()] - }); - slot.set(entry); + let (last_modified_ms, lm_buf, lm_len) = self.stat_cache_lookup(rel, &stat); + let last_modified = (lm_len > 0).then(|| &lm_buf[..lm_len]); let mut etag_buf = [0u8; 40]; let etag = format_weak_etag(&mut etag_buf, size, last_modified_ms); - // RFC 9110 §13.2.2 precedence: If-Match → If-Unmodified-Since → - // If-None-Match → If-Modified-Since → Range. - let range: RangeRequest::Result = if method == Method::GET || method == Method::HEAD { + let range = if method == Method::GET || method == Method::HEAD { RangeRequest::from_request(&req, size) } else { RangeRequest::Result::None }; - let status_code: u16 = 'brk: { - if method == Method::HEAD || method == Method::GET { - if let Some(im) = req.header(b"if-match").filter(|v| !v.is_empty()) { - if !ETag::if_match(Some(etag), im) { - break 'brk 412; - } - } else if let Some(ius) = req - .header(b"if-unmodified-since") - .and_then(crate::jsc_hooks::parse_http_date) - { - if last_modified_ms > 0 && last_modified_ms / 1000 > ius / 1000 { - break 'brk 412; - } - } - - if let Some(inm) = req.header(b"if-none-match").filter(|v| !v.is_empty()) { - if ETag::if_none_match(etag, inm) { - break 'brk 304; - } - } else if let Some(ims) = req - .header(b"if-modified-since") - .and_then(crate::jsc_hooks::parse_http_date) - { - if last_modified_ms > 0 && last_modified_ms / 1000 <= ims / 1000 { - break 'brk 304; - } - } - } - - if matches!(range, RangeRequest::Result::Unsatisfiable) { - break 'brk 416; - } - if matches!(range, RangeRequest::Result::Satisfiable { .. }) { - break 'brk 206; - } - 200 - }; + let status_code = status_for_preconditions( + &req, + method, + 200, + Some(etag), + (last_modified_ms > 0).then_some(last_modified_ms), + range, + ); req.set_yield(false); - write_any_status(resp, status_code); resp.write_mark(); @@ -288,13 +186,12 @@ impl DirectoryRoute { } else { extension_for_mime(rel) }; - let mime = bun_http_types::MimeType::by_extension(ext); - resp.write_header(b"content-type", &mime.value); + resp.write_header(b"content-type", &bun_http_types::MimeType::by_extension(ext).value); if let Some(lm) = last_modified { resp.write_header(b"last-modified", lm); } resp.write_header(b"etag", etag); - if let Some(srv) = this.server.get() { + if let Some(srv) = self.server.get() { if let Some(alt) = srv.h3_alt_svc() { resp.write_header(b"alt-svc", alt); } @@ -309,34 +206,23 @@ impl DirectoryRoute { return; } - let (body_offset, body_len): (u64, Option) = match range { - RangeRequest::Result::Satisfiable { start, end } => { - let mut crbuf = [0u8; RangeRequest::CONTENT_RANGE_BUF]; - resp.write_header( - b"content-range", - RangeRequest::format_content_range(&mut crbuf, range, Some(size)), - ); - resp.write_header(b"accept-ranges", b"bytes"); - (start, Some(end - start + 1)) + let (body_offset, body_len): (u64, u64) = match range { + RangeRequest::Result::Satisfiable { .. } => { + write_content_range(resp, range, size).unwrap() } RangeRequest::Result::Unsatisfiable => { - let mut crbuf = [0u8; RangeRequest::CONTENT_RANGE_BUF]; - resp.write_header( - b"content-range", - RangeRequest::format_content_range(&mut crbuf, range, Some(size)), - ); - resp.write_header(b"accept-ranges", b"bytes"); + write_content_range(resp, range, size); resp.end(b"", resp.should_close_connection()); return; } RangeRequest::Result::None => { resp.write_header(b"accept-ranges", b"bytes"); - (0, Some(size)) + (0, size) } }; if !resp.state().has_written_content_length_header() { - resp.write_header_int(b"content-length", body_len.unwrap_or(size)); + resp.write_header_int(b"content-length", body_len); resp.mark_wrote_content_length_header(); } @@ -352,137 +238,141 @@ impl DirectoryRoute { size ); - *fd_guard = false; + let server = self.server.get().unwrap(); FileResponseStream::start(&FileResponseStreamOptions { - fd, + fd: file.into_raw(), auto_close: true, resp, - vm: bun_ptr::BackRef::new(this.server.get().unwrap().vm()), + vm: bun_ptr::BackRef::new(server.vm()), file_type: FileType::File, pollable: false, offset: body_offset, - length: body_len, - idle_timeout: this.server.get().unwrap().config().idle_timeout, - ctx: this_ptr.cast::(), + length: Some(body_len), + idle_timeout: server.config().idle_timeout, + ctx: guard.into_ctx(), on_complete: on_stream_complete, on_abort: None, on_error: on_stream_error, }); } - /// Open `rel` beneath `root_fd`. Tries `index.html` when `rel` resolves to - /// a directory (the caller has already normalized `rel` and guaranteed no - /// `..` components). Returns `(fd, served_index_html)`. - fn open_subpath(&self, rel: &[u8]) -> Option<(Fd, bool)> { + /// Returns `(fd, served_index_html)`; tries `index.html` for directories. + fn open_subpath(&self, rel: &[u8]) -> Option<(File, bool)> { if rel.is_empty() || rel == b"." { - return self.open_beneath(b"index.html").map(|fd| (fd, true)); + return self.open_beneath(b"index.html").map(|f| (f, true)); } - let fd = self.open_beneath(rel)?; - match bun_sys::fstat(fd) { + let file = self.open_beneath(rel)?; + match file.stat() { Ok(s) if bun_sys::S::ISDIR(s.st_mode as bun_sys::Mode) => { - #[cfg(windows)] - Closer::close(fd, bun_sys::windows::libuv::Loop::get()); - #[cfg(not(windows))] - Closer::close(fd, ()); + drop(file); let mut buf = bun_paths::path_buffer_pool::get(); - let sub = &mut buf.0[..]; - if rel.len() + 1 + b"index.html".len() >= sub.len() { - return None; - } - sub[..rel.len()].copy_from_slice(rel); - sub[rel.len()] = b'/'; - sub[rel.len() + 1..rel.len() + 1 + b"index.html".len()] - .copy_from_slice(b"index.html"); - self.open_beneath(&sub[..rel.len() + 1 + b"index.html".len()]) - .map(|fd| (fd, true)) - } - Ok(_) => Some((fd, false)), - Err(_) => { - #[cfg(windows)] - Closer::close(fd, bun_sys::windows::libuv::Loop::get()); - #[cfg(not(windows))] - Closer::close(fd, ()); - None + let joined = resolve_path::join_string_buf::( + &mut buf.0[..], + &[rel, b"index.html"], + ); + self.open_beneath(joined).map(|f| (f, true)) } + Ok(_) => Some((file, false)), + Err(_) => None, } } - /// Open `rel` relative to `root_fd`. On Linux this is - /// `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`: the kernel rejects - /// any resolution step (including symlink hops) that escapes the root. - /// On other platforms the only containment is the lexical `..` strip done - /// by the caller in `resolve_subpath`. - fn open_beneath(&self, rel: &[u8]) -> Option { + /// `openat2(RESOLVE_IN_ROOT|NO_MAGICLINKS)` on Linux, `openat` elsewhere. + fn open_beneath(&self, rel: &[u8]) -> Option { let mut buf = bun_paths::path_buffer_pool::get(); - let zrel = bun_paths::resolve_path::z(rel, &mut *buf); + let zrel = resolve_path::z(rel, &mut *buf); let flags = bun_sys::O::RDONLY | bun_sys::O::CLOEXEC | bun_sys::O::NONBLOCK; #[cfg(any(target_os = "linux", target_os = "android"))] - { - bun_sys::openat2_beneath(self.root_fd, zrel, flags, 0).ok() - } + let fd = bun_sys::openat2_in_root(self.root_fd.get(), zrel, flags, 0).ok()?; #[cfg(not(any(target_os = "linux", target_os = "android")))] - { - bun_sys::openat(self.root_fd, zrel, flags, 0).ok() + let fd = bun_sys::openat(self.root_fd.get(), zrel, flags, 0).ok()?; + // Windows `openat` returns a HANDLE; `FileResponseStream` needs a + // libuv fd. `make_lib_uv_owned` is a no-op on POSIX. + use bun_sys::FdExt; + fd.make_lib_uv_owned_for_syscall(bun_sys::Tag::open, bun_sys::ErrorCase::CloseOnFail) + .ok() + .map(File::from_fd) + } + + fn stat_cache_lookup(&self, rel: &[u8], stat: &bun_sys::Stat) -> (u64, [u8; 32], usize) { + let path_hash = bun_wyhash::hash(rel); + let slot = &self.stat_cache[(path_hash as usize) % STAT_CACHE_SLOTS]; + let mut entry = slot.replace(StatCacheEntry::default()); + if entry.path_hash != path_hash { + entry = StatCacheEntry::default(); + entry.path_hash = path_hash; } + entry.stat_hash.hash(stat, rel); + let ms = entry.stat_hash.last_modified_u64; + let mut buf = [0u8; 32]; + let len = entry + .stat_hash + .last_modified() + .map(|s| { + buf[..s.len()].copy_from_slice(s); + s.len() + }) + .unwrap_or(0); + slot.set(entry); + (ms, buf, len) } - fn on_response_complete(this: *mut DirectoryRoute, resp: AnyResponse) { + fn on_response_complete(this: NonNull, resp: AnyResponse) { resp.clear_aborted(); resp.clear_on_writable(); resp.clear_timeout(); - // SAFETY: `this` is live (ref held by caller); `deref()` may free it. - unsafe { - if let Some(mut server) = (*this).server.get() { - server.on_static_request_complete(); - } - Self::deref(this); + if let Some(mut server) = bun_ptr::BackRef::from(this).server.get() { + server.on_static_request_complete(); } + // SAFETY: intrusive refcount; `ref_()` in `on()` pairs with this. + unsafe { Self::deref(this.as_ptr()) }; } } -fn on_stream_complete(ctx: *mut c_void, resp: AnyResponse) { - DirectoryRoute::on_response_complete(ctx.cast::(), resp); +/// Releases the route ref (and file, if any) on every non-streaming return. +struct ResponseGuard { + route: NonNull, + resp: AnyResponse, } -fn on_stream_error(ctx: *mut c_void, resp: AnyResponse, _err: bun_sys::Error) { - DirectoryRoute::on_response_complete(ctx.cast::(), resp); +impl ResponseGuard { + fn into_ctx(self) -> *mut c_void { + let ctx = self.route.as_ptr().cast::(); + core::mem::forget(self); + ctx + } } -fn write_any_status(resp: AnyResponse, status: u16) { - match resp { - AnyResponse::SSL(r) => write_status::(r, status), - AnyResponse::TCP(r) => write_status::(r, status), - AnyResponse::H3(r) => { - let mut b = bun_core::fmt::ItoaBuf::new(); - let s = bun_core::fmt::itoa(&mut b, status); - bun_opaque::opaque_deref_mut(r).write_status(s); - } +impl Drop for ResponseGuard { + fn drop(&mut self) { + DirectoryRoute::on_response_complete(self.route, self.resp); } } -/// Resolve the URL path into a root-relative filesystem subpath. -/// -/// Percent-decodes into `scratch`, writes the cleaned result into `out`, and -/// returns its length. `None` on any rejection: malformed percent-escape, NUL -/// byte, backslash, path that normalizes above the root, or prefix mismatch. -/// -/// Steps: -/// 1. Strip `url_prefix` (which always ends in `/`). -/// 2. Percent-decode the remainder **once**. -/// 3. Reject NUL and `\`. -/// 4. Lexically collapse `//`, `.`, `..` (capped at the root). -/// -/// The result never begins with `/` and never contains `..` as a component. +fn on_stream_complete(ctx: *mut c_void, resp: AnyResponse) { + DirectoryRoute::on_response_complete(NonNull::new(ctx.cast()).unwrap(), resp); +} + +fn on_stream_error(ctx: *mut c_void, resp: AnyResponse, _err: bun_sys::Error) { + DirectoryRoute::on_response_complete(NonNull::new(ctx.cast()).unwrap(), resp); +} + +/// Strip `url_prefix`, percent-decode once, reject NUL/`\`, normalize `.`/`..`; +/// `None` if the result would escape the root. Writes into `out`. fn resolve_subpath( url: &[u8], url_prefix: &[u8], scratch: &mut [u8], out: &mut [u8], ) -> Option { + // `req.url()` is uWS `getFullUrl()`, which includes the query string. + let url = match strings::index_of_char(url, b'?') { + Some(i) => &url[..i as usize], + None => url, + }; let after_prefix = if strings::starts_with(url, url_prefix) { &url[url_prefix.len()..] } else if url.len() + 1 == url_prefix.len() && url == &url_prefix[..url_prefix.len() - 1] { - // `"/static"` against prefix `"/static/"` — treat as the root. b"" } else { return None; @@ -492,69 +382,35 @@ fn resolve_subpath( return None; } - let decoded_len = match bun_url::PercentEncoding::decode_into( + let decoded_len = bun_url::PercentEncoding::decode_into( &mut scratch[..after_prefix.len()], after_prefix, - ) { - Ok(n) => n as usize, - Err(_) => return None, - }; - let decoded = &scratch[..decoded_len]; + ) + .ok()? as usize; + let mut start = 0; + while start < decoded_len && scratch[start] == b'/' { + start += 1; + } + let decoded = &scratch[start..decoded_len]; for &b in decoded { if b == 0 || b == b'\\' { return None; } } - - // Lexical clean: collapse `//`, drop `.`, apply `..` up to the root. Any - // `..` that would climb above the root is rejected outright rather than - // clamped so an off-by-one elsewhere can't silently expose the root. - let mut w: usize = 0; - let mut i: usize = 0; - while i < decoded_len { - while i < decoded_len && decoded[i] == b'/' { - i += 1; - } - let start = i; - while i < decoded_len && decoded[i] != b'/' { - i += 1; - } - let seg = &decoded[start..i]; - if seg.is_empty() || seg == b"." { - continue; - } - if seg == b".." { - if w == 0 { - return None; - } - while w > 0 && out[w - 1] != b'/' { - w -= 1; - } - if w > 0 { - w -= 1; - } - continue; - } - if w > 0 { - if w >= out.len() { - return None; - } - out[w] = b'/'; - w += 1; - } - if w + seg.len() > out.len() { - return None; - } - out[w..w + seg.len()].copy_from_slice(seg); - w += seg.len(); + if decoded.is_empty() { + return Some(0); } - Some(w) + let norm = + resolve_path::normalize_string_buf::(decoded, out); + if norm == b".." || strings::starts_with(norm, b"../") { + return None; + } + Some(norm.len()) } -/// `W/"-"` — matches the nginx/send scheme so CDNs -/// that special-case weak validators behave the same. +/// `W/"-"` (nginx/send scheme). fn format_weak_etag(buf: &mut [u8; 40], size: u64, mtime_ms: u64) -> &[u8] { use core::fmt::Write as _; let mut c = bun_core::fmt::SliceCursor::new(&mut buf[..]); @@ -563,14 +419,9 @@ fn format_weak_etag(buf: &mut [u8; 40], size: u64, mtime_ms: u64) -> &[u8] { &buf[..n] } -/// Extension without the leading dot, or `b""`. fn extension_for_mime(path: &[u8]) -> &[u8] { let ext = bun_paths::extension(path); - if ext.first() == Some(&b'.') { - &ext[1..] - } else { - ext - } + ext.strip_prefix(b".").unwrap_or(ext) } #[cfg(test)] @@ -585,38 +436,14 @@ mod tests { #[test] fn resolve_basic() { - assert_eq!( - resolve(b"/static/a.txt", b"/static/").as_deref(), - Some(&b"a.txt"[..]) - ); - assert_eq!( - resolve(b"/static/a/b.txt", b"/static/").as_deref(), - Some(&b"a/b.txt"[..]) - ); + assert_eq!(resolve(b"/static/a.txt", b"/static/").as_deref(), Some(&b"a.txt"[..])); + assert_eq!(resolve(b"/static/a/b.txt", b"/static/").as_deref(), Some(&b"a/b.txt"[..])); assert_eq!(resolve(b"/a.txt", b"/").as_deref(), Some(&b"a.txt"[..])); assert_eq!(resolve(b"/", b"/").as_deref(), Some(&b""[..])); assert_eq!(resolve(b"/static", b"/static/").as_deref(), Some(&b""[..])); assert_eq!(resolve(b"/static/", b"/static/").as_deref(), Some(&b""[..])); - } - - #[test] - fn resolve_percent() { - assert_eq!( - resolve(b"/static/hello%20world.txt", b"/static/").as_deref(), - Some(&b"hello world.txt"[..]) - ); - assert_eq!( - resolve(b"/static/a%2Fb.txt", b"/static/").as_deref(), - Some(&b"a/b.txt"[..]) - ); - // malformed % - assert_eq!(resolve(b"/static/a%2.txt", b"/static/"), None); - assert_eq!(resolve(b"/static/a%", b"/static/"), None); - // double-encoded '..' stays literal after one decode - assert_eq!( - resolve(b"/static/%252e%252e/etc", b"/static/").as_deref(), - Some(&b"%2e%2e/etc"[..]) - ); + assert_eq!(resolve(b"/static/a.txt?v=1", b"/static/").as_deref(), Some(&b"a.txt"[..])); + assert_eq!(resolve(b"/static?x", b"/static/").as_deref(), Some(&b""[..])); } #[test] @@ -625,39 +452,9 @@ mod tests { assert_eq!(resolve(b"/static/..%2Fetc", b"/static/"), None); assert_eq!(resolve(b"/static/%2e%2e/etc", b"/static/"), None); assert_eq!(resolve(b"/static/a/../../etc", b"/static/"), None); - assert_eq!( - resolve(b"/static/a/../b.txt", b"/static/").as_deref(), - Some(&b"b.txt"[..]) - ); - assert_eq!( - resolve(b"/static/a/./b.txt", b"/static/").as_deref(), - Some(&b"a/b.txt"[..]) - ); - assert_eq!( - resolve(b"/static/a//b.txt", b"/static/").as_deref(), - Some(&b"a/b.txt"[..]) - ); - // `....` is not `..` - assert_eq!( - resolve(b"/static/..../etc", b"/static/").as_deref(), - Some(&b"..../etc"[..]) - ); - } - - #[test] - fn resolve_nul_backslash() { + assert_eq!(resolve(b"/static/a/../b.txt", b"/static/").as_deref(), Some(&b"b.txt"[..])); + assert_eq!(resolve(b"/static/a//b.txt", b"/static/").as_deref(), Some(&b"a/b.txt"[..])); assert_eq!(resolve(b"/static/a%00.txt", b"/static/"), None); - assert_eq!(resolve(b"/static/a\\b.txt", b"/static/"), None); assert_eq!(resolve(b"/static/a%5Cb.txt", b"/static/"), None); } - - #[test] - fn etag_format() { - let mut buf = [0u8; 40]; - assert_eq!(format_weak_etag(&mut buf, 0, 0), b"W/\"0-0\""); - assert_eq!( - format_weak_etag(&mut buf, 1234, 5678_000), - b"W/\"4d2-162e\"" - ); - } } diff --git a/src/runtime/server/FileRoute.rs b/src/runtime/server/FileRoute.rs index 88691374f5f4..fd42b97bdc49 100644 --- a/src/runtime/server/FileRoute.rs +++ b/src/runtime/server/FileRoute.rs @@ -17,7 +17,7 @@ use crate::node::types::PathOrFileDescriptor; use crate::server::file_response_stream::StartOptions as FileResponseStreamOptions; use crate::server::jsc::{JSGlobalObject, JSValue, JsResult, VirtualMachine}; -use crate::server::{AnyServer, FileResponseStream, HTTPStatusText, RangeRequest, write_status}; +use crate::server::{AnyServer, FileResponseStream, HTTPStatusText, RangeRequest}; use crate::webcore::blob::store::Data as StoreData; use crate::webcore::body::Value as BodyValue; use crate::webcore::{Blob, FetchHeaders, Response}; @@ -274,16 +274,7 @@ impl FileRoute { } fn write_status_code(&self, status: u16, resp: AnyResponse) { - match resp { - AnyResponse::SSL(r) => write_status::(r, status), - AnyResponse::TCP(r) => write_status::(r, status), - AnyResponse::H3(r) => { - let mut b = bun_core::fmt::ItoaBuf::new(); - let s = bun_core::fmt::itoa(&mut b, status); - // S008: `h3::Response` is an `opaque_ffi!` ZST — safe deref. - bun_opaque::opaque_deref_mut(r).write_status(s); - } - } + write_any_status(resp, status); } /// # Safety @@ -386,16 +377,6 @@ impl FileRoute { } }); - // `parse_http_date` maps a parse failure to `None`, so a - // malformed If-Modified-Since header degrades to "serve the file - // unconditionally" — the RFC 9110 §13.1.3-correct behaviour. - // - // LAYERING: the parse step lives HERE (T6) because it needs `bun_jsc` — - // so `bun_uws_sys` (T0) carries no upward hook. - let input_if_modified_since_date: Option = req - .header(b"if-modified-since") - .and_then(crate::jsc_hooks::parse_http_date); - let (can_serve_file, size, file_type, pollable): (bool, u64, FileType, bool) = 'brk: { let stat = match bun_sys::fstat(fd) { Ok(s) => s, @@ -448,75 +429,18 @@ impl FileRoute { RangeRequest::Result::None }; - let status_code: u16 = 'brk: { - // RFC 9110 §13.2.2: preconditions evaluate before Range, in order: - // (1) If-Match, else (2) If-Unmodified-Since; then (3) If-None-Match, - // else (4) If-Modified-Since. Steps 1/2 yield 412 on failure and must - // run before steps 3/4 can yield 304. - if (method == Method::HEAD || method == Method::GET) && this.status_code == 200 { - // Step 1: If-Match (strong comparison). - if let Some(im) = req.header(b"if-match").filter(|v| !v.is_empty()) { - let etag = this.headers.get(b"etag").filter(|v| !v.is_empty()); - if !ETag::if_match(etag, im) { - break 'brk 412; - } - // Step 2: If-Unmodified-Since (only when If-Match is absent). - } else if let Some(ius) = req - .header(b"if-unmodified-since") - .and_then(crate::jsc_hooks::parse_http_date) - { - let Ok(lmd) = this.last_modified_date() else { - return; - }; - if let Some(lm) = lmd { - if lm / 1000 > ius / 1000 { - break 'brk 412; - } - } - } - } - - if method == Method::HEAD || method == Method::GET { - if let Some(inm) = req.header(b"if-none-match").filter(|v| !v.is_empty()) { - if this.status_code == 200 { - let matched = match this.headers.get(b"etag").filter(|v| !v.is_empty()) { - Some(etag) => ETag::if_none_match(etag, inm), - // No stored ETag: only `*` can match (RFC 9110 - // §13.1.2 — any current representation). - None => strings::trim(inm, b" \t") == b"*", - }; - if matched { - break 'brk 304; - } - } - // If-None-Match present but did not match: condition is - // true, fall through to Range/200 without consulting - // If-Modified-Since. - } else if let Some(requested_if_modified_since) = input_if_modified_since_date { - let Ok(lmd) = this.last_modified_date() else { - return; - }; // TODO: properly propagate exception upwards - if let Some(actual_last_modified_at) = lmd { - // Compare at second precision: the Last-Modified header we - // emit is second-granular (HTTP-date), so a sub-second - // mtime would otherwise never satisfy `<=` against the - // client's echoed value. - if actual_last_modified_at / 1000 <= requested_if_modified_since / 1000 { - break 'brk 304; - } - } - } - } - - if matches!(range, RangeRequest::Result::Unsatisfiable) { - break 'brk 416; - } - if matches!(range, RangeRequest::Result::Satisfiable { .. }) { - break 'brk 206; - } - - this.status_code + let etag = this.headers.get(b"etag").filter(|v| !v.is_empty()); + let Ok(last_modified_ms) = this.last_modified_date() else { + return; }; + let status_code = status_for_preconditions( + &req, + method, + this.status_code, + etag, + last_modified_ms, + range, + ); req.set_yield(false); @@ -540,22 +464,12 @@ impl FileRoute { } let (body_offset, body_len): (u64, Option) = match range { - RangeRequest::Result::Satisfiable { start, end } => { - let mut crbuf = [0u8; RangeRequest::CONTENT_RANGE_BUF]; - resp.write_header( - b"content-range", - RangeRequest::format_content_range(&mut crbuf, range, Some(size)), - ); - resp.write_header(b"accept-ranges", b"bytes"); - (this.blob.offset.get() + start, Some(end - start + 1)) + RangeRequest::Result::Satisfiable { .. } => { + let (start, len) = write_content_range(resp, range, size).unwrap(); + (this.blob.offset.get() + start, Some(len)) } RangeRequest::Result::Unsatisfiable => { - let mut crbuf = [0u8; RangeRequest::CONTENT_RANGE_BUF]; - resp.write_header( - b"content-range", - RangeRequest::format_content_range(&mut crbuf, range, Some(size)), - ); - resp.write_header(b"accept-ranges", b"bytes"); + write_content_range(resp, range, size); resp.end(b"", resp.should_close_connection()); return; } @@ -624,3 +538,88 @@ fn on_stream_complete(ctx: *mut c_void, resp: AnyResponse) { fn on_stream_error(ctx: *mut c_void, resp: AnyResponse, _err: bun_sys::Error) { FileRoute::on_response_complete(ctx.cast::(), resp); } + +/// RFC 9110 §13.2.2 precondition evaluation for a GET/HEAD file response. +/// Returns the status the response should carry after applying If-Match / +/// If-Unmodified-Since / If-None-Match / If-Modified-Since / Range. +pub(crate) fn status_for_preconditions( + req: &AnyRequest, + method: Method, + base_status: u16, + etag: Option<&[u8]>, + last_modified_ms: Option, + range: RangeRequest::Result, +) -> u16 { + if (method == Method::HEAD || method == Method::GET) && base_status == 200 { + if let Some(im) = req.header(b"if-match").filter(|v| !v.is_empty()) { + if !ETag::if_match(etag, im) { + return 412; + } + } else if let Some(ius) = req + .header(b"if-unmodified-since") + .and_then(crate::jsc_hooks::parse_http_date) + { + if let Some(lm) = last_modified_ms { + if lm / 1000 > ius / 1000 { + return 412; + } + } + } + + if let Some(inm) = req.header(b"if-none-match").filter(|v| !v.is_empty()) { + let matched = match etag { + Some(etag) => ETag::if_none_match(etag, inm), + None => strings::trim(inm, b" \t") == b"*", + }; + if matched { + return 304; + } + } else if let Some(ims) = req + .header(b"if-modified-since") + .and_then(crate::jsc_hooks::parse_http_date) + { + if let Some(lm) = last_modified_ms { + if lm / 1000 <= ims / 1000 { + return 304; + } + } + } + } + + match range { + RangeRequest::Result::Unsatisfiable => 416, + RangeRequest::Result::Satisfiable { .. } => 206, + RangeRequest::Result::None => base_status, + } +} + +/// Write a 206/416 `Content-Range` header plus `Accept-Ranges: bytes`, and +/// return the `(offset, length)` to stream for a 206, or `None` for 416. +pub(crate) fn write_content_range( + resp: AnyResponse, + range: RangeRequest::Result, + size: u64, +) -> Option<(u64, u64)> { + let mut crbuf = [0u8; RangeRequest::CONTENT_RANGE_BUF]; + resp.write_header( + b"content-range", + RangeRequest::format_content_range(&mut crbuf, range, Some(size)), + ); + resp.write_header(b"accept-ranges", b"bytes"); + match range { + RangeRequest::Result::Satisfiable { start, end } => Some((start, end - start + 1)), + _ => None, + } +} + +pub(crate) fn write_any_status(resp: AnyResponse, status: u16) { + match resp { + AnyResponse::SSL(r) => crate::server::write_status::(r, status), + AnyResponse::TCP(r) => crate::server::write_status::(r, status), + AnyResponse::H3(r) => { + let mut b = bun_core::fmt::ItoaBuf::new(); + let s = bun_core::fmt::itoa(&mut b, status); + bun_opaque::opaque_deref_mut(r).write_status(s); + } + } +} diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 90d4cdbac33f..8f84dcff7b43 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -1935,6 +1935,33 @@ mod posix_impl { super::linux_syscall::openat2_beneath(dir, path, flags, mode) .map_err(|e| Error::from_code_int(e, Tag::open).with_path(path.as_bytes())) } + /// `openat2(RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS)`: resolves `path` as + /// if `dir` were `/`. Falls back to plain `openat` on kernels without + /// `openat2` (or when seccomp blocks it), caching the unavailability. + #[cfg(any(target_os = "linux", target_os = "android"))] + pub fn openat2_in_root(dir: impl AsFd, path: &ZStr, flags: i32, mode: Mode) -> Maybe { + use core::sync::atomic::{AtomicBool, Ordering}; + static UNAVAILABLE: AtomicBool = AtomicBool::new(false); + + let dir = dir.as_fd(); + if !UNAVAILABLE.load(Ordering::Relaxed) { + match super::linux_syscall::openat2_in_root(dir, path, flags, mode) { + Ok(fd) => return Ok(fd), + Err(e) + if matches!( + e, + libc::ENOSYS | libc::EPERM | libc::EINVAL | libc::E2BIG + ) => + { + UNAVAILABLE.store(true, Ordering::Relaxed); + } + Err(e) => { + return Err(Error::from_code_int(e, Tag::open).with_path(path.as_bytes())); + } + } + } + openat(dir, path, flags, mode) + } pub fn close(fd: Fd) -> Maybe<()> { // Call close ONCE; never retry on EINTR (Linux may have already // released the fd, retrying would close someone else's). Only EBADF surfaces. diff --git a/src/sys/linux_syscall.rs b/src/sys/linux_syscall.rs index 777b5955c6c2..bdc280de9d0b 100644 --- a/src/sys/linux_syscall.rs +++ b/src/sys/linux_syscall.rs @@ -106,6 +106,23 @@ pub(crate) fn openat2_beneath(dir: Fd, path: &ZStr, flags: i32, mode: Mode) -> R .map(own_fd) } +#[inline] +pub(crate) fn openat2_in_root(dir: Fd, path: &ZStr, flags: i32, mode: Mode) -> Result { + let oflags = rustix::fs::OFlags::from_bits_retain(flags as u32); + let mode = rustix::fs::Mode::from_raw_mode(mode); + let dir = dir.as_borrowed_fd(); + retry(|| { + rustix::fs::openat2( + dir, + path.as_cstr(), + oflags, + mode, + rustix::fs::ResolveFlags::IN_ROOT | rustix::fs::ResolveFlags::NO_MAGICLINKS, + ) + }) + .map(own_fd) +} + #[inline] pub(crate) fn read(fd: Fd, buf: &mut [u8]) -> Result { let fd = fd.as_borrowed_fd(); diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index 53a41ced7c5b..eac4d0fcba25 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -288,7 +288,7 @@ describe("Bun.serve() directory routes", () => { expect(dbl.body).not.toContain("SECRET"); }); - it.skipIf(!isLinux)("rejects symlink escapes on Linux via RESOLVE_BENEATH", async () => { + it.skipIf(!isLinux)("rejects symlink escapes on Linux via RESOLVE_IN_ROOT", async () => { using dir = tempDir("serve-dir-symlink", { "secret.txt": "SECRET", "public/ok.txt": "ok", @@ -296,9 +296,11 @@ describe("Bun.serve() directory routes", () => { }); const root = String(dir); - symlinkSync(join(root, "secret.txt"), join(root, "public", "escape")); - // RESOLVE_BENEATH rejects absolute symlink targets (they resolve from `/`, - // which is outside the root); an in-root symlink must be relative. + // Absolute target: IN_ROOT resolves it against dirfd, so this looks for + // public/ which doesn't exist. + symlinkSync(join(root, "secret.txt"), join(root, "public", "escape-abs")); + // Relative target climbing out: `..` is clamped at the root. + symlinkSync("../secret.txt", join(root, "public", "escape-rel")); symlinkSync("inside.txt", join(root, "public", "alias")); server = serve({ @@ -312,10 +314,12 @@ describe("Bun.serve() directory routes", () => { expect(inside.status).toBe(200); expect(await inside.text()).toBe("inside"); - // Symlink that escapes the root is rejected by the kernel. - const escape = await fetch(`${server.url}static/escape`); - expect(await escape.text()).not.toContain("SECRET"); - expect(escape.status).toBe(404); + // Symlinks that escape the root are clamped by the kernel. + for (const name of ["escape-abs", "escape-rel"]) { + const res = await fetch(`${server.url}static/${name}`); + expect(await res.text()).not.toContain("SECRET"); + expect(res.status).toBe(404); + } }); it("percent-decodes file names", async () => { @@ -333,6 +337,26 @@ describe("Bun.serve() directory routes", () => { expect(await res.text()).toBe("hi"); }); + it("ignores the query string", async () => { + using dir = tempDir("serve-dir-query", { + "public/app.js": "ok", + "public/index.html": "root", + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const res = await fetch(`${server.url}app.js?v=abc123`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + + const root = await fetch(`${server.url}?foo=bar`); + expect(root.status).toBe(200); + expect(await root.text()).toBe("root"); + }); + it("supports multiple directory routes", async () => { using dir = tempDir("serve-dir-multi", { "a/one.txt": "one", @@ -367,7 +391,7 @@ describe("Bun.serve() directory routes", () => { port: 0, routes: { "/static/*": { dir: "/nonexistent/path/that/does/not/exist" } }, }), - ).toThrow(); + ).toThrow(expect.objectContaining({ code: "ENOENT" })); }); it("is reflected in server.routes", async () => { From 3f6ebb2dddbed31a329c7670cbdfd1fb349a904c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:05:37 +0000 Subject: [PATCH 04/29] [autofix.ci] apply automated fixes --- src/runtime/server/DirectoryRoute.rs | 48 ++++++++++++++++++++-------- src/sys/lib.rs | 7 +--- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 2d78fd5527c4..d91dd1497ee8 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -186,7 +186,10 @@ impl DirectoryRoute { } else { extension_for_mime(rel) }; - resp.write_header(b"content-type", &bun_http_types::MimeType::by_extension(ext).value); + resp.write_header( + b"content-type", + &bun_http_types::MimeType::by_extension(ext).value, + ); if let Some(lm) = last_modified { resp.write_header(b"last-modified", lm); } @@ -382,11 +385,9 @@ fn resolve_subpath( return None; } - let decoded_len = bun_url::PercentEncoding::decode_into( - &mut scratch[..after_prefix.len()], - after_prefix, - ) - .ok()? as usize; + let decoded_len = + bun_url::PercentEncoding::decode_into(&mut scratch[..after_prefix.len()], after_prefix) + .ok()? as usize; let mut start = 0; while start < decoded_len && scratch[start] == b'/' { @@ -402,8 +403,9 @@ fn resolve_subpath( return Some(0); } - let norm = - resolve_path::normalize_string_buf::(decoded, out); + let norm = resolve_path::normalize_string_buf::( + decoded, out, + ); if norm == b".." || strings::starts_with(norm, b"../") { return None; } @@ -436,14 +438,26 @@ mod tests { #[test] fn resolve_basic() { - assert_eq!(resolve(b"/static/a.txt", b"/static/").as_deref(), Some(&b"a.txt"[..])); - assert_eq!(resolve(b"/static/a/b.txt", b"/static/").as_deref(), Some(&b"a/b.txt"[..])); + assert_eq!( + resolve(b"/static/a.txt", b"/static/").as_deref(), + Some(&b"a.txt"[..]) + ); + assert_eq!( + resolve(b"/static/a/b.txt", b"/static/").as_deref(), + Some(&b"a/b.txt"[..]) + ); assert_eq!(resolve(b"/a.txt", b"/").as_deref(), Some(&b"a.txt"[..])); assert_eq!(resolve(b"/", b"/").as_deref(), Some(&b""[..])); assert_eq!(resolve(b"/static", b"/static/").as_deref(), Some(&b""[..])); assert_eq!(resolve(b"/static/", b"/static/").as_deref(), Some(&b""[..])); - assert_eq!(resolve(b"/static/a.txt?v=1", b"/static/").as_deref(), Some(&b"a.txt"[..])); - assert_eq!(resolve(b"/static?x", b"/static/").as_deref(), Some(&b""[..])); + assert_eq!( + resolve(b"/static/a.txt?v=1", b"/static/").as_deref(), + Some(&b"a.txt"[..]) + ); + assert_eq!( + resolve(b"/static?x", b"/static/").as_deref(), + Some(&b""[..]) + ); } #[test] @@ -452,8 +466,14 @@ mod tests { assert_eq!(resolve(b"/static/..%2Fetc", b"/static/"), None); assert_eq!(resolve(b"/static/%2e%2e/etc", b"/static/"), None); assert_eq!(resolve(b"/static/a/../../etc", b"/static/"), None); - assert_eq!(resolve(b"/static/a/../b.txt", b"/static/").as_deref(), Some(&b"b.txt"[..])); - assert_eq!(resolve(b"/static/a//b.txt", b"/static/").as_deref(), Some(&b"a/b.txt"[..])); + assert_eq!( + resolve(b"/static/a/../b.txt", b"/static/").as_deref(), + Some(&b"b.txt"[..]) + ); + assert_eq!( + resolve(b"/static/a//b.txt", b"/static/").as_deref(), + Some(&b"a/b.txt"[..]) + ); assert_eq!(resolve(b"/static/a%00.txt", b"/static/"), None); assert_eq!(resolve(b"/static/a%5Cb.txt", b"/static/"), None); } diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 8f84dcff7b43..53987dd16f6d 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -1947,12 +1947,7 @@ mod posix_impl { if !UNAVAILABLE.load(Ordering::Relaxed) { match super::linux_syscall::openat2_in_root(dir, path, flags, mode) { Ok(fd) => return Ok(fd), - Err(e) - if matches!( - e, - libc::ENOSYS | libc::EPERM | libc::EINVAL | libc::E2BIG - ) => - { + Err(e) if matches!(e, libc::ENOSYS | libc::EPERM | libc::EINVAL | libc::E2BIG) => { UNAVAILABLE.store(true, Ordering::Relaxed); } Err(e) => { From 5139769cc737e4431bbf710aa8155d9b6f32839b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:17:05 +0000 Subject: [PATCH 05/29] DirectoryRoute: statCache option, cache-exhaustion and adversarial tests - {dir, statCache: false} disables the per-path StatHash cache (saves ~60KB per route); default remains true. - Test that 1500 distinct files serve correctly across two full passes so every cache slot is evicted and reused, and that conditionals still work afterwards. - describe.each over statCache true/false for the conditional-request path. - Concurrent burst (128 parallel requests on one file). - Adversarial: deep .. chains that cancel/escape, collapsed slashes, percent-encoded UTF-8, near-8KB path, oversized Range start. --- packages/bun-types/serve.d.ts | 6 + src/runtime/server/DirectoryRoute.rs | 19 ++- src/runtime/server/server_body.rs | 10 +- .../bun/http/serve-directory-routes.test.ts | 137 ++++++++++++++++++ 4 files changed, 167 insertions(+), 5 deletions(-) diff --git a/packages/bun-types/serve.d.ts b/packages/bun-types/serve.d.ts index f2b5ed5d4810..8f505a0c7d0b 100644 --- a/packages/bun-types/serve.d.ts +++ b/packages/bun-types/serve.d.ts @@ -606,6 +606,12 @@ declare module "bun" { interface DirectoryRouteOptions { /** Path to the directory to serve. */ dir: string; + /** + * Cache formatted `Last-Modified` strings per path so repeated requests + * for an unchanged file skip the date formatter. Uses ~60KB per route. + * @default true + */ + statCache?: boolean; } type BaseRouteValue = Response | false | HTMLBundle | BunFile | DirectoryRouteOptions; diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index d91dd1497ee8..797fa72237a4 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -65,6 +65,7 @@ impl DirectoryRoute { global: &JSGlobalObject, root: &[u8], url_prefix: &[u8], + enable_stat_cache: bool, ) -> JsResult<*mut DirectoryRoute> { debug_assert!(url_prefix.last() == Some(&b'/')); @@ -80,8 +81,9 @@ impl DirectoryRoute { } }; - let mut stat_cache = Vec::with_capacity(STAT_CACHE_SLOTS); - for _ in 0..STAT_CACHE_SLOTS { + let slots = if enable_stat_cache { STAT_CACHE_SLOTS } else { 0 }; + let mut stat_cache = Vec::with_capacity(slots); + for _ in 0..slots { stat_cache.push(Cell::new(StatCacheEntry::default())); } @@ -298,8 +300,18 @@ impl DirectoryRoute { } fn stat_cache_lookup(&self, rel: &[u8], stat: &bun_sys::Stat) -> (u64, [u8; 32], usize) { + let mut buf = [0u8; 32]; + if self.stat_cache.is_empty() { + let mut sh = StatHash::default(); + sh.hash(stat, rel); + let len = sh.last_modified().map(|s| { + buf[..s.len()].copy_from_slice(s); + s.len() + }); + return (sh.last_modified_u64, buf, len.unwrap_or(0)); + } let path_hash = bun_wyhash::hash(rel); - let slot = &self.stat_cache[(path_hash as usize) % STAT_CACHE_SLOTS]; + let slot = &self.stat_cache[(path_hash as usize) % self.stat_cache.len()]; let mut entry = slot.replace(StatCacheEntry::default()); if entry.path_hash != path_hash { entry = StatCacheEntry::default(); @@ -307,7 +319,6 @@ impl DirectoryRoute { } entry.stat_hash.hash(stat, rel); let ms = entry.stat_hash.last_modified_u64; - let mut buf = [0u8; 32]; let len = entry .stat_hash .last_modified() diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index e73bdd54fea9..dafe138173ad 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -787,7 +787,15 @@ impl AnyRoute { } else { &path[..path.len() - 1] }; - let route = super::DirectoryRoute::create(global, relative_root, url_prefix)?; + let stat_cache = argument + .get_boolean_loose(global, b"statCache")? + .unwrap_or(true); + let route = super::DirectoryRoute::create( + global, + relative_root, + url_prefix, + stat_cache, + )?; return Ok(Some(AnyRoute::Directory(NonNull::new(route).expect( "DirectoryRoute::create returns a fresh heap allocation", )))); diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index eac4d0fcba25..09b6f1346970 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -394,6 +394,143 @@ describe("Bun.serve() directory routes", () => { ).toThrow(expect.objectContaining({ code: "ENOENT" })); }); + it("serves correctly with more unique paths than stat-cache slots", async () => { + const N = 1500; + const files: Record = {}; + for (let i = 0; i < N; i++) files[`public/f${i}.txt`] = `v${i}`; + using dir = tempDir("serve-dir-exhaust", files); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + // Walk the full set twice so every slot is guaranteed to have been + // evicted and reused between the two visits to any given path. + for (let pass = 0; pass < 2; pass++) { + const batch = 64; + for (let base = 0; base < N; base += batch) { + const chunk = Array.from({ length: Math.min(batch, N - base) }, (_, j) => base + j); + const bodies = await Promise.all(chunk.map(i => fetch(`${server!.url}f${i}.txt`).then(r => r.text()))); + expect(bodies).toEqual(chunk.map(i => `v${i}`)); + } + } + + // After eviction churn, conditionals on a hot path still work. + const first = await fetch(`${server.url}f0.txt`); + const lm = first.headers.get("last-modified")!; + const etag = first.headers.get("etag")!; + expect((await fetch(`${server.url}f0.txt`, { headers: { "if-modified-since": lm } })).status).toBe(304); + expect((await fetch(`${server.url}f0.txt`, { headers: { "if-none-match": etag } })).status).toBe(304); + }, 30_000); + + describe.each([true, false])("statCache: %p", statCache => { + it("serves and honors conditionals", async () => { + using dir = tempDir(`serve-dir-cache-${statCache}`, { + "public/a.txt": "a", + }); + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public"), statCache } }, + }); + + const res = await fetch(`${server.url}a.txt`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("a"); + const lm = res.headers.get("last-modified")!; + expect(lm).toBeTruthy(); + expect(res.headers.get("etag")).toMatch(/^W\/"/); + + const cond = await fetch(`${server.url}a.txt`, { headers: { "if-modified-since": lm } }); + expect(cond.status).toBe(304); + }); + }); + + it("handles a concurrent burst on one file", async () => { + using dir = tempDir("serve-dir-burst", { + "public/hot.txt": "hot", + }); + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const results = await Promise.all( + Array.from({ length: 128 }, () => fetch(`${server!.url}hot.txt`).then(r => r.text())), + ); + expect(results.every(r => r === "hot")).toBe(true); + }); + + it("rejects adversarial inputs", async () => { + using dir = tempDir("serve-dir-adversarial", { + "secret.txt": "SECRET", + "public/ok.txt": "ok", + "public/a/b/c/d/e/f/g/h/target.txt": "deep", + "public/\u00e9.txt": "utf8", + }); + + server = serve({ + port: 0, + routes: { "/static/*": { dir: join(String(dir), "public") } }, + fetch: () => new Response("fallback", { status: 404 }), + }); + + async function raw(path: string): Promise<{ status: number; body: string }> { + const { promise, resolve } = Promise.withResolvers(); + let buf = ""; + const sock = await Bun.connect({ + hostname: "127.0.0.1", + port: server!.port, + socket: { + data(_s, chunk) { + buf += chunk.toString("latin1"); + }, + close() { + resolve(buf); + }, + error() { + resolve(buf); + }, + }, + }); + sock.write(`GET ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`); + const full = await promise; + const status = parseInt(full.slice(9, 12), 10); + const body = full.split("\r\n\r\n").slice(1).join("\r\n\r\n"); + return { status, body }; + } + + // Deep `..` chain that cancels out stays inside the root. + const deep = await raw("/static/a/b/c/d/e/f/g/h/../../../../../../../../a/b/c/d/e/f/g/h/target.txt"); + expect(deep.status).toBe(200); + expect(deep.body).toContain("deep"); + + // Deep `..` chain that escapes is rejected. + const escape = await raw("/static/a/b/c/d/e/f/g/h/../../../../../../../../../secret.txt"); + expect(escape.body).not.toContain("SECRET"); + expect(escape.status).toBe(404); + + // Many consecutive slashes collapse. + const slashes = await raw("/static////////ok.txt"); + expect(slashes.status).toBe(200); + expect(slashes.body).toContain("ok"); + + // Percent-encoded UTF-8 filename. + const utf8 = await fetch(`${server.url}static/%C3%A9.txt`); + expect(utf8.status).toBe(200); + expect(await utf8.text()).toBe("utf8"); + + // Very long path (yields, does not crash). + const long = await raw("/static/" + Buffer.alloc(8000, "a").toString()); + expect([404, 400, 414]).toContain(long.status); + + // Oversized Range start must not overflow. + const hugeRange = await fetch(`${server.url}static/ok.txt`, { + headers: { range: "bytes=999999999999999999999999-" }, + }); + expect([200, 416]).toContain(hugeRange.status); + }); + it("is reflected in server.routes", async () => { // Ensure DirectoryRoute is wired through AnyRoute introspection without // crashing (guards the match arms added for the new variant). From 11b07d30ed07c1e92893e40d1da0b923f02043d9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:19:15 +0000 Subject: [PATCH 06/29] [autofix.ci] apply automated fixes --- src/runtime/server/DirectoryRoute.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 797fa72237a4..389bc597d828 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -81,7 +81,11 @@ impl DirectoryRoute { } }; - let slots = if enable_stat_cache { STAT_CACHE_SLOTS } else { 0 }; + let slots = if enable_stat_cache { + STAT_CACHE_SLOTS + } else { + 0 + }; let mut stat_cache = Vec::with_capacity(slots); for _ in 0..slots { stat_cache.push(Cell::new(StatCacheEntry::default())); From c432c4d4df17915e946fa0c116a2015995861b65 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:34:37 +0000 Subject: [PATCH 07/29] DirectoryRoute: 404 on miss, single fstat, PATH_MAX bound, precedence tests - Misses now write a plain 404 instead of yielding. Yielding from a "/*" directory route closed the connection because set_routes registers no fallback handler when "/*" is covered; a direct 404 matches nginx/caddy and works at any mount prefix. - open_subpath returns (File, Stat, is_index) so the hot path issues one fstat instead of two, and the ISREG check moves alongside it. - resolve_subpath rejects inputs at or above the buffer length, and resolve_path::z() now bounds at >= MAX_PATH_BYTES so a PATH_MAX-length subpath can no longer reach output[MAX_PATH_BYTES] = 0. - Adversarial test now probes 1023/1024/1025/4095/4096/4097/8000-byte subpaths and verifies the server keeps responding afterwards. - New tests: overlapping route precedence (exact/handler/narrower wildcard beat the directory wildcard), case-sensitivity on a case-sensitive filesystem, and describe.each over "/static/*" and "/*" for the miss path. --- packages/bun-types/serve.d.ts | 3 +- src/paths/resolve_path.rs | 2 +- src/runtime/server/DirectoryRoute.rs | 66 +++++++------- .../bun/http/serve-directory-routes.test.ts | 90 +++++++++++++++---- 4 files changed, 109 insertions(+), 52 deletions(-) diff --git a/packages/bun-types/serve.d.ts b/packages/bun-types/serve.d.ts index 8f505a0c7d0b..159e00ed878d 100644 --- a/packages/bun-types/serve.d.ts +++ b/packages/bun-types/serve.d.ts @@ -591,8 +591,7 @@ declare module "bun" { * Responses carry `Content-Type` (from the file extension), * `Last-Modified`, a weak `ETag`, and support single-range `Range` * requests. Requests that resolve to a directory are served - * `index.html` from that directory. Missing files fall through to the - * next matching route (or `fetch`). + * `index.html` from that directory. Missing files return `404`. * * @example * ```ts diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 416ff9096931..68df995dc1f8 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -33,7 +33,7 @@ fn tl_buf_mut(b: &UnsafeCell<[u8; N]>) -> &'static mut [u8; N] { } pub fn z<'a>(input: &[u8], output: &'a mut PathBuffer) -> &'a ZStr { - if input.len() > MAX_PATH_BYTES { + if input.len() >= MAX_PATH_BYTES { if cfg!(debug_assertions) { panic!("path too long"); } diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 389bc597d828..1137adc890f0 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -138,28 +138,18 @@ impl DirectoryRoute { &mut path_buf.0[..], ) else { bun_output::scoped_log!(DirectoryRoute, "reject {}", bstr::BStr::new(req.url())); - req.set_yield(true); + write_miss(&mut req, resp); return; }; drop(decode_buf); let rel: &[u8] = &path_buf.0[..rel_len]; - let Some((file, is_index)) = self.open_subpath(rel) else { + let Some((file, stat, is_index)) = self.open_subpath(rel) else { bun_output::scoped_log!(DirectoryRoute, "miss {}", bstr::BStr::new(rel)); - req.set_yield(true); + write_miss(&mut req, resp); return; }; - let Ok(stat) = file.stat() else { - req.set_yield(true); - return; - }; - let mode = stat.st_mode as bun_sys::Mode; - if !bun_sys::S::ISREG(mode) { - req.set_yield(true); - return; - } - let size: u64 = u64::try_from(stat.st_size.max(0)).expect("int cast"); let (last_modified_ms, lm_buf, lm_len) = self.stat_cache_lookup(rel, &stat); @@ -265,25 +255,31 @@ impl DirectoryRoute { }); } - /// Returns `(fd, served_index_html)`; tries `index.html` for directories. - fn open_subpath(&self, rel: &[u8]) -> Option<(File, bool)> { + /// Returns `(file, stat, served_index_html)` for a regular file; tries + /// `index.html` for directories. + fn open_subpath(&self, rel: &[u8]) -> Option<(File, bun_sys::Stat, bool)> { + let open_and_stat = |p: &[u8]| -> Option<(File, bun_sys::Stat)> { + let f = self.open_beneath(p)?; + let s = f.stat().ok()?; + Some((f, s)) + }; if rel.is_empty() || rel == b"." { - return self.open_beneath(b"index.html").map(|f| (f, true)); + let (f, s) = open_and_stat(b"index.html")?; + return bun_sys::S::ISREG(s.st_mode as bun_sys::Mode).then_some((f, s, true)); } - let file = self.open_beneath(rel)?; - match file.stat() { - Ok(s) if bun_sys::S::ISDIR(s.st_mode as bun_sys::Mode) => { - drop(file); - let mut buf = bun_paths::path_buffer_pool::get(); - let joined = resolve_path::join_string_buf::( - &mut buf.0[..], - &[rel, b"index.html"], - ); - self.open_beneath(joined).map(|f| (f, true)) - } - Ok(_) => Some((file, false)), - Err(_) => None, + let (file, stat) = open_and_stat(rel)?; + let mode = stat.st_mode as bun_sys::Mode; + if bun_sys::S::ISDIR(mode) { + drop(file); + let mut buf = bun_paths::path_buffer_pool::get(); + let joined = resolve_path::join_string_buf::( + &mut buf.0[..], + &[rel, b"index.html"], + ); + let (f, s) = open_and_stat(joined)?; + return bun_sys::S::ISREG(s.st_mode as bun_sys::Mode).then_some((f, s, true)); } + bun_sys::S::ISREG(mode).then_some((file, stat, false)) } /// `openat2(RESOLVE_IN_ROOT|NO_MAGICLINKS)` on Linux, `openat` elsewhere. @@ -375,6 +371,13 @@ fn on_stream_error(ctx: *mut c_void, resp: AnyResponse, _err: bun_sys::Error) { DirectoryRoute::on_response_complete(NonNull::new(ctx.cast()).unwrap(), resp); } +fn write_miss(req: &mut AnyRequest, resp: AnyResponse) { + req.set_yield(false); + write_any_status(resp, 404); + resp.write_mark(); + resp.end(b"", resp.should_close_connection()); +} + /// Strip `url_prefix`, percent-decode once, reject NUL/`\`, normalize `.`/`..`; /// `None` if the result would escape the root. Writes into `out`. fn resolve_subpath( @@ -396,7 +399,7 @@ fn resolve_subpath( return None; }; - if after_prefix.len() > scratch.len() { + if after_prefix.len() >= scratch.len() { return None; } @@ -418,10 +421,11 @@ fn resolve_subpath( return Some(0); } + let out_len = out.len(); let norm = resolve_path::normalize_string_buf::( decoded, out, ); - if norm == b".." || strings::starts_with(norm, b"../") { + if norm == b".." || strings::starts_with(norm, b"../") || norm.len() >= out_len { return None; } Some(norm.len()) diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index 09b6f1346970..dcef923c142d 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -103,24 +103,29 @@ describe("Bun.serve() directory routes", () => { expect(await subNoSlash.text()).toBe("

sub

"); }); - it("falls through to fetch for missing files", async () => { - using dir = tempDir("serve-dir-404", { - "public/exists.txt": "yes", - }); + describe.each(["/static/*", "/*"] as const)("mounted at %s", prefix => { + const base = prefix === "/*" ? "" : "static/"; + it("returns 404 for missing files", async () => { + using dir = tempDir("serve-dir-404", { + "public/exists.txt": "yes", + }); - server = serve({ - port: 0, - routes: { "/static/*": { dir: join(String(dir), "public") } }, - fetch: () => new Response("fallback", { status: 404 }), - }); + server = serve({ + port: 0, + routes: { [prefix]: { dir: join(String(dir), "public") } }, + fetch: () => new Response("fallback", { status: 200 }), + }); - const hit = await fetch(`${server.url}static/exists.txt`); - expect(hit.status).toBe(200); - expect(await hit.text()).toBe("yes"); + const hit = await fetch(`${server.url}${base}exists.txt`); + expect(hit.status).toBe(200); + expect(await hit.text()).toBe("yes"); - const miss = await fetch(`${server.url}static/nope.txt`); - expect(miss.status).toBe(404); - expect(await miss.text()).toBe("fallback"); + // A miss returns a plain 404 from the directory route itself; the + // fetch handler is not consulted for paths under the mounted prefix. + const miss = await fetch(`${server.url}${base}nope.txt`); + expect(miss.status).toBe(404); + expect(await miss.text()).toBe(""); + }); }); it("supports HEAD", async () => { @@ -461,6 +466,50 @@ describe("Bun.serve() directory routes", () => { expect(results.every(r => r === "hot")).toBe(true); }); + it("yields to more-specific overlapping routes", async () => { + using dir = tempDir("serve-dir-precedence", { + "public/file.txt": "from-dir", + "public/api": "from-dir", + "public/sub/x.txt": "parent", + "public/other.txt": "from-dir", + "inner/x.txt": "inner", + }); + + server = serve({ + port: 0, + routes: { + "/static/file.txt": new Response("exact", { status: 200 }), + "/static/api": () => new Response("handler"), + "/static/sub/*": { dir: join(String(dir), "inner") }, + "/static/*": { dir: join(String(dir), "public") }, + }, + }); + + // Exact static route beats the wildcard directory. + expect(await (await fetch(`${server.url}static/file.txt`)).text()).toBe("exact"); + // Handler route beats the directory. + expect(await (await fetch(`${server.url}static/api`)).text()).toBe("handler"); + // More specific wildcard prefix beats the broader one. + expect(await (await fetch(`${server.url}static/sub/x.txt`)).text()).toBe("inner"); + // Paths only the broad wildcard matches still reach it. + expect(await (await fetch(`${server.url}static/other.txt`)).text()).toBe("from-dir"); + }); + + it.skipIf(!isLinux)("is case-sensitive on a case-sensitive filesystem", async () => { + using dir = tempDir("serve-dir-case", { + "public/File.txt": "upper", + }); + server = serve({ + port: 0, + routes: { "/static/*": { dir: join(String(dir), "public") } }, + fetch: () => new Response("miss", { status: 404 }), + }); + + expect(await (await fetch(`${server.url}static/File.txt`)).text()).toBe("upper"); + expect((await fetch(`${server.url}static/file.txt`)).status).toBe(404); + expect((await fetch(`${server.url}static/FILE.TXT`)).status).toBe(404); + }); + it("rejects adversarial inputs", async () => { using dir = tempDir("serve-dir-adversarial", { "secret.txt": "SECRET", @@ -520,9 +569,14 @@ describe("Bun.serve() directory routes", () => { expect(utf8.status).toBe(200); expect(await utf8.text()).toBe("utf8"); - // Very long path (yields, does not crash). - const long = await raw("/static/" + Buffer.alloc(8000, "a").toString()); - expect([404, 400, 414]).toContain(long.status); + // Paths at and around PATH_MAX (1024 on macOS, 4096 on Linux) must not + // crash the server; they yield or 404. + for (const len of [1023, 1024, 1025, 4095, 4096, 4097, 8000]) { + const r = await raw("/static/" + Buffer.alloc(len, "a").toString()); + expect([404, 400, 414]).toContain(r.status); + } + // Server still responds after the boundary probes. + expect((await fetch(`${server.url}static/ok.txt`)).status).toBe(200); // Oversized Range start must not overflow. const hugeRange = await fetch(`${server.url}static/ok.txt`, { From 61d8e7919ea3c09fd617862fd79c5a78497d7d22 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:45:46 +0000 Subject: [PATCH 08/29] DirectoryRoute tests: hoist raw() helper, shrink cache test; FileRoute: lazy last_modified_date - Hoist the raw-socket helper to describe scope (was defined verbatim in two tests). - Cache-exhaustion test: N 1500->1100, batch 64->128, drop the per-test timeout (still > STAT_CACHE_SLOTS so every slot is evicted). - FileRoute::on: only call last_modified_date() when the request carries If-Modified-Since or If-Unmodified-Since, matching the pre-refactor laziness so a user-set Last-Modified header is not parsed per request. --- src/runtime/server/FileRoute.rs | 11 ++- .../bun/http/serve-directory-routes.test.ts | 85 +++++++------------ 2 files changed, 39 insertions(+), 57 deletions(-) diff --git a/src/runtime/server/FileRoute.rs b/src/runtime/server/FileRoute.rs index fd42b97bdc49..114f7656daa6 100644 --- a/src/runtime/server/FileRoute.rs +++ b/src/runtime/server/FileRoute.rs @@ -430,8 +430,15 @@ impl FileRoute { }; let etag = this.headers.get(b"etag").filter(|v| !v.is_empty()); - let Ok(last_modified_ms) = this.last_modified_date() else { - return; + let last_modified_ms = if req.header(b"if-modified-since").is_some() + || req.header(b"if-unmodified-since").is_some() + { + let Ok(lmd) = this.last_modified_date() else { + return; + }; + lmd + } else { + None }; let status_code = status_for_preconditions( &req, diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index dcef923c142d..d85131d29621 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -12,6 +12,33 @@ describe("Bun.serve() directory routes", () => { server = undefined; }); + // fetch() normalizes `..` client-side, so the traversal/adversarial tests + // send raw request bytes over a socket. + async function raw(path: string): Promise<{ status: number; body: string }> { + const { promise, resolve } = Promise.withResolvers(); + let buf = ""; + const sock = await Bun.connect({ + hostname: "127.0.0.1", + port: server!.port, + socket: { + data(_s, chunk) { + buf += chunk.toString("latin1"); + }, + close() { + resolve(buf); + }, + error() { + resolve(buf); + }, + }, + }); + sock.write(`GET ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`); + const full = await promise; + const status = parseInt(full.slice(9, 12), 10); + const body = full.split("\r\n\r\n").slice(1).join("\r\n\r\n"); + return { status, body }; + } + it("serves files from a directory at /*", async () => { using dir = tempDir("serve-dir-root", { "public/index.html": "

Hello World

", @@ -247,33 +274,6 @@ describe("Bun.serve() directory routes", () => { expect(await (await fetch(`${server.url}static/ok.txt`)).text()).toBe("ok"); - // fetch() normalizes `..` in the URL client-side, so send the raw bytes - // over a socket to exercise the server's resolver. - async function raw(path: string): Promise<{ status: number; body: string }> { - const { promise, resolve } = Promise.withResolvers(); - let buf = ""; - const sock = await Bun.connect({ - hostname: "127.0.0.1", - port: server!.port, - socket: { - data(_s, chunk) { - buf += chunk.toString("latin1"); - }, - close() { - resolve(buf); - }, - error() { - resolve(buf); - }, - }, - }); - sock.write(`GET ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`); - const full = await promise; - const status = parseInt(full.slice(9, 12), 10); - const body = full.split("\r\n\r\n").slice(1).join("\r\n\r\n"); - return { status, body }; - } - for (const p of [ "/static/../secret.txt", "/static/..%2Fsecret.txt", @@ -400,7 +400,7 @@ describe("Bun.serve() directory routes", () => { }); it("serves correctly with more unique paths than stat-cache slots", async () => { - const N = 1500; + const N = 1100; // > STAT_CACHE_SLOTS (1024) const files: Record = {}; for (let i = 0; i < N; i++) files[`public/f${i}.txt`] = `v${i}`; using dir = tempDir("serve-dir-exhaust", files); @@ -413,7 +413,7 @@ describe("Bun.serve() directory routes", () => { // Walk the full set twice so every slot is guaranteed to have been // evicted and reused between the two visits to any given path. for (let pass = 0; pass < 2; pass++) { - const batch = 64; + const batch = 128; for (let base = 0; base < N; base += batch) { const chunk = Array.from({ length: Math.min(batch, N - base) }, (_, j) => base + j); const bodies = await Promise.all(chunk.map(i => fetch(`${server!.url}f${i}.txt`).then(r => r.text()))); @@ -427,7 +427,7 @@ describe("Bun.serve() directory routes", () => { const etag = first.headers.get("etag")!; expect((await fetch(`${server.url}f0.txt`, { headers: { "if-modified-since": lm } })).status).toBe(304); expect((await fetch(`${server.url}f0.txt`, { headers: { "if-none-match": etag } })).status).toBe(304); - }, 30_000); + }); describe.each([true, false])("statCache: %p", statCache => { it("serves and honors conditionals", async () => { @@ -524,31 +524,6 @@ describe("Bun.serve() directory routes", () => { fetch: () => new Response("fallback", { status: 404 }), }); - async function raw(path: string): Promise<{ status: number; body: string }> { - const { promise, resolve } = Promise.withResolvers(); - let buf = ""; - const sock = await Bun.connect({ - hostname: "127.0.0.1", - port: server!.port, - socket: { - data(_s, chunk) { - buf += chunk.toString("latin1"); - }, - close() { - resolve(buf); - }, - error() { - resolve(buf); - }, - }, - }); - sock.write(`GET ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`); - const full = await promise; - const status = parseInt(full.slice(9, 12), 10); - const body = full.split("\r\n\r\n").slice(1).join("\r\n\r\n"); - return { status, body }; - } - // Deep `..` chain that cancels out stays inside the root. const deep = await raw("/static/a/b/c/d/e/f/g/h/../../../../../../../../a/b/c/d/e/f/g/h/target.txt"); expect(deep.status).toBe(200); From c5ac6bf03b6b1ab26eed73f0012dc9916bfbf89b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:51:00 +0000 Subject: [PATCH 09/29] DirectoryRoute: reject :, tighten subpath bound, fix clippy redundant_guard - resolve_subpath rejects : alongside NUL and \. On Windows, openat treats c:/... as a drive-absolute path and file::$DATA as an alternate data stream; rejecting : on all platforms closes both without platform gates. - Cap the normalized subpath so "/index.html" plus the NUL z() appends always fits the PathBuffer, so a near-PATH_MAX directory path cannot reach resolve_path::z with no room. - openat2_in_root: fold the matches! guard into the Err pattern (clippy redundant_guard). - Rename the reload test to describe what it actually checks; add drive-letter and ADS vectors to the traversal test. --- src/runtime/server/DirectoryRoute.rs | 12 +++++++++--- src/sys/lib.rs | 2 +- test/js/bun/http/serve-directory-routes.test.ts | 9 +++++---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 1137adc890f0..dac14ccd6d93 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -413,7 +413,9 @@ fn resolve_subpath( } let decoded = &scratch[start..decoded_len]; for &b in decoded { - if b == 0 || b == b'\\' { + // NUL truncates C strings; `\` and `:` are Windows separators / drive + // prefixes / ADS markers that `openat` on Windows treats as absolute. + if b == 0 || b == b'\\' || b == b':' { return None; } } @@ -421,11 +423,13 @@ fn resolve_subpath( return Some(0); } - let out_len = out.len(); + // Leave room for the NUL `z()` appends and for `"/index.html"` when the + // resolved path turns out to be a directory. + let max_norm = out.len().saturating_sub(b"/index.html\0".len()); let norm = resolve_path::normalize_string_buf::( decoded, out, ); - if norm == b".." || strings::starts_with(norm, b"../") || norm.len() >= out_len { + if norm == b".." || strings::starts_with(norm, b"../") || norm.len() > max_norm { return None; } Some(norm.len()) @@ -485,6 +489,8 @@ mod tests { assert_eq!(resolve(b"/static/..%2Fetc", b"/static/"), None); assert_eq!(resolve(b"/static/%2e%2e/etc", b"/static/"), None); assert_eq!(resolve(b"/static/a/../../etc", b"/static/"), None); + assert_eq!(resolve(b"/static/c:/windows", b"/static/"), None); + assert_eq!(resolve(b"/static/file::$DATA", b"/static/"), None); assert_eq!( resolve(b"/static/a/../b.txt", b"/static/").as_deref(), Some(&b"b.txt"[..]) diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 53987dd16f6d..45450350f79f 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -1947,7 +1947,7 @@ mod posix_impl { if !UNAVAILABLE.load(Ordering::Relaxed) { match super::linux_syscall::openat2_in_root(dir, path, flags, mode) { Ok(fd) => return Ok(fd), - Err(e) if matches!(e, libc::ENOSYS | libc::EPERM | libc::EINVAL | libc::E2BIG) => { + Err(libc::ENOSYS | libc::EPERM | libc::EINVAL | libc::E2BIG) => { UNAVAILABLE.store(true, Ordering::Relaxed); } Err(e) => { diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index d85131d29621..ee65ac2bdf4a 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -282,6 +282,9 @@ describe("Bun.serve() directory routes", () => { "/static/ok.txt/../../secret.txt", "/static/a%00.txt", "/static/a%5Cb.txt", + "/static/c:/windows/win.ini", + "/static/c%3A/windows/win.ini", + "/static/ok.txt::$DATA", ]) { const { status, body } = await raw(p); expect(body).not.toContain("SECRET"); @@ -560,10 +563,8 @@ describe("Bun.serve() directory routes", () => { expect([200, 416]).toContain(hugeRange.status); }); - it("is reflected in server.routes", async () => { - // Ensure DirectoryRoute is wired through AnyRoute introspection without - // crashing (guards the match arms added for the new variant). - using dir = tempDir("serve-dir-introspect", { "public/x.txt": "x" }); + it("survives server.reload()", async () => { + using dir = tempDir("serve-dir-reload", { "public/x.txt": "x" }); server = serve({ port: 0, routes: { "/static/*": { dir: join(String(dir), "public") } }, From 59754809f32e59c452c1ab52e7ed7651cbdcb613 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:03:12 +0000 Subject: [PATCH 10/29] DirectoryRoute: store the full path in stat-cache entries wyhash collisions are possible, so two distinct paths could share a slot and pass the hash-equality check. Compare the stored path bytes instead; the hash is only used to pick the slot. --- src/runtime/server/DirectoryRoute.rs | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index dac14ccd6d93..ebc59afa9009 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -23,20 +23,12 @@ bun_output::declare_scope!(DirectoryRoute, hidden); /// `wyhash(subpath) % N` direct-mapped StatHash cache; collisions overwrite. const STAT_CACHE_SLOTS: usize = 1024; +#[derive(Default)] struct StatCacheEntry { - path_hash: u64, + path: Vec, stat_hash: StatHash, } -impl Default for StatCacheEntry { - fn default() -> Self { - Self { - path_hash: 0, - stat_hash: StatHash::default(), - } - } -} - #[derive(bun_ptr::CellRefCounted)] #[ref_count(destroy = DirectoryRoute::deinit)] pub struct DirectoryRoute { @@ -310,12 +302,13 @@ impl DirectoryRoute { }); return (sh.last_modified_u64, buf, len.unwrap_or(0)); } - let path_hash = bun_wyhash::hash(rel); - let slot = &self.stat_cache[(path_hash as usize) % self.stat_cache.len()]; + let slot = + &self.stat_cache[(bun_wyhash::hash(rel) as usize) % self.stat_cache.len()]; let mut entry = slot.replace(StatCacheEntry::default()); - if entry.path_hash != path_hash { - entry = StatCacheEntry::default(); - entry.path_hash = path_hash; + if entry.path.as_slice() != rel { + entry.path.clear(); + entry.path.extend_from_slice(rel); + entry.stat_hash = StatHash::default(); } entry.stat_hash.hash(stat, rel); let ms = entry.stat_hash.last_modified_u64; From 077ce7b1f8bbe9b5fe7ef6d6208e384c90ef110e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:05:26 +0000 Subject: [PATCH 11/29] [autofix.ci] apply automated fixes --- src/runtime/server/DirectoryRoute.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index ebc59afa9009..4d418770f6d1 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -302,8 +302,7 @@ impl DirectoryRoute { }); return (sh.last_modified_u64, buf, len.unwrap_or(0)); } - let slot = - &self.stat_cache[(bun_wyhash::hash(rel) as usize) % self.stat_cache.len()]; + let slot = &self.stat_cache[(bun_wyhash::hash(rel) as usize) % self.stat_cache.len()]; let mut entry = slot.replace(StatCacheEntry::default()); if entry.path.as_slice() != rel { entry.path.clear(); From b1bafad083bafa49146e5b7ee8f21b405a969e49 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:07:50 +0000 Subject: [PATCH 12/29] Scope NO_MAGICLINKS to openat2_in_root; restore precondition why-comments - openat2_beneath is back to BENEATH only; DirectoryRoute uses the new openat2_in_root, so the flag change was a drive-by on install/bin.rs. - Restore the step-order, If-None-Match *, fall-through, and second- precision rationale comments that were dropped when the precondition ladder moved into status_for_preconditions. The uniform base_status==200 gate is kept (RFC 9110 13.1.1: preconditions apply only when the response would otherwise be 200). --- src/runtime/server/FileRoute.rs | 11 +++++++++-- src/sys/linux_syscall.rs | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/runtime/server/FileRoute.rs b/src/runtime/server/FileRoute.rs index 114f7656daa6..4994b66e35e7 100644 --- a/src/runtime/server/FileRoute.rs +++ b/src/runtime/server/FileRoute.rs @@ -547,8 +547,10 @@ fn on_stream_error(ctx: *mut c_void, resp: AnyResponse, _err: bun_sys::Error) { } /// RFC 9110 §13.2.2 precondition evaluation for a GET/HEAD file response. -/// Returns the status the response should carry after applying If-Match / -/// If-Unmodified-Since / If-None-Match / If-Modified-Since / Range. +/// Order: (1) If-Match, else (2) If-Unmodified-Since; then (3) If-None-Match, +/// else (4) If-Modified-Since. Steps 1/2 yield 412 on failure and must run +/// before steps 3/4 can yield 304. Preconditions only apply when the selected +/// representation would otherwise be 200 (§13.1.1). pub(crate) fn status_for_preconditions( req: &AnyRequest, method: Method, @@ -576,15 +578,20 @@ pub(crate) fn status_for_preconditions( if let Some(inm) = req.header(b"if-none-match").filter(|v| !v.is_empty()) { let matched = match etag { Some(etag) => ETag::if_none_match(etag, inm), + // No stored ETag: only `*` can match (§13.1.2). None => strings::trim(inm, b" \t") == b"*", }; if matched { return 304; } + // Did not match: fall through to Range/200 without consulting IMS. } else if let Some(ims) = req .header(b"if-modified-since") .and_then(crate::jsc_hooks::parse_http_date) { + // Compare at second precision: the Last-Modified we emit is + // second-granular (HTTP-date), so a sub-second mtime would never + // satisfy `<=` against the client's echoed value otherwise. if let Some(lm) = last_modified_ms { if lm / 1000 <= ims / 1000 { return 304; diff --git a/src/sys/linux_syscall.rs b/src/sys/linux_syscall.rs index bdc280de9d0b..40c6c95f8cf4 100644 --- a/src/sys/linux_syscall.rs +++ b/src/sys/linux_syscall.rs @@ -100,7 +100,7 @@ pub(crate) fn openat2_beneath(dir: Fd, path: &ZStr, flags: i32, mode: Mode) -> R path.as_cstr(), oflags, mode, - rustix::fs::ResolveFlags::BENEATH | rustix::fs::ResolveFlags::NO_MAGICLINKS, + rustix::fs::ResolveFlags::BENEATH, ) }) .map(own_fd) From 5255d0bc79e6e55286dd95f2cf3d7d84bb2897a2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:16:02 +0000 Subject: [PATCH 13/29] DirectoryRoute: track stat-cache path heap bytes for memory_cost() StatCacheEntry.path is a Vec; memory_cost() now includes the sum of those capacities via a running counter updated on cache eviction, rather than walking all 1024 slots on every call. --- src/runtime/server/DirectoryRoute.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 4d418770f6d1..170b6b56c50d 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -38,6 +38,8 @@ pub struct DirectoryRoute { /// Mount prefix with trailing `/` (`"/static/"`, or `"/"` for `"/*"`). url_prefix: Box<[u8]>, stat_cache: Box<[Cell]>, + /// Sum of `StatCacheEntry.path` capacities, for `memory_cost()`. + stat_cache_path_bytes: Cell, } impl DirectoryRoute { @@ -50,6 +52,7 @@ impl DirectoryRoute { size_of::() + self.url_prefix.len() + self.stat_cache.len() * size_of::>() + + self.stat_cache_path_bytes.get() } /// Open `root` and construct the route. `url_prefix` must end in `/`. @@ -89,6 +92,7 @@ impl DirectoryRoute { root_fd: Cell::new(root_fd), url_prefix: url_prefix.to_vec().into_boxed_slice(), stat_cache: stat_cache.into_boxed_slice(), + stat_cache_path_bytes: Cell::new(0), }))) } @@ -305,9 +309,12 @@ impl DirectoryRoute { let slot = &self.stat_cache[(bun_wyhash::hash(rel) as usize) % self.stat_cache.len()]; let mut entry = slot.replace(StatCacheEntry::default()); if entry.path.as_slice() != rel { + let old_cap = entry.path.capacity(); entry.path.clear(); entry.path.extend_from_slice(rel); entry.stat_hash = StatHash::default(); + self.stat_cache_path_bytes + .set(self.stat_cache_path_bytes.get() + entry.path.capacity() - old_cap); } entry.stat_hash.hash(stat, rel); let ms = entry.stat_hash.last_modified_u64; From 302861d8b9856095d199ce9b8d1086b1040f9ad8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:49:35 +0000 Subject: [PATCH 14/29] DirectoryRoute: Windows fixes, provenance fix, docs - open_beneath: drop O::NONBLOCK. On Windows openat() honors it by omitting FILE_SYNCHRONOUS_IO_NONALERT, so the HANDLE is opened for overlapped I/O and synchronous reads from FileResponseStream fail immediately; on POSIX O_NONBLOCK is a no-op for regular files anyway. - on() takes NonNull instead of &self so the pointer stashed in ResponseGuard / FileResponseStream ctx retains raw provenance for the eventual Box::from_raw in deinit (Stacked Borrows; see src/CLAUDE.md Pointer provenance at FFI boundaries). - ENOENT test: use a subdir of a real tempDir so the path is valid on Windows (/nonexistent/... is EINVAL there). - docs/runtime/http/routing.mdx: add a Directory routes section. --- docs/runtime/http/routing.mdx | 24 ++++++++++++++ src/runtime/server/DirectoryRoute.rs | 31 +++++++++++-------- .../bun/http/serve-directory-routes.test.ts | 3 +- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/docs/runtime/http/routing.mdx b/docs/runtime/http/routing.mdx index 9b09b294e52f..8e04cc574914 100644 --- a/docs/runtime/http/routing.mdx +++ b/docs/runtime/http/routing.mdx @@ -194,6 +194,30 @@ Bun.serve({ - **Memory efficient** - Only buffers small chunks during transfer, not entire file - **Best for**: Large files, dynamic content, user uploads, files that change frequently +### Directory routes + +To serve an entire directory tree at a URL prefix, pass `{ dir }` as the route value. The route path must end in `/*`. + +```ts +Bun.serve({ + routes: { + "/static/*": { dir: "./public" }, + }, +}); +``` + +The part of the request URL after the prefix is percent-decoded once, cleaned of `.` and `..` segments, and opened relative to `dir`. On Linux the open uses `openat2(RESOLVE_IN_ROOT)`, so symlinks that would escape `dir` are clamped by the kernel; other platforms rely on the same lexical normalization. + +Directory routes share the response path with file routes: + +- **Content-Type** is set from the file extension. +- **Last-Modified** and a weak `ETag` (`W/"-"`) are sent on every response, and `If-Modified-Since` / `If-None-Match` are honored with `304 Not Modified`. +- **Range requests** are supported with `Accept-Ranges: bytes` and `Content-Range`. +- Requests that resolve to a directory are served `index.html` from that directory. +- Missing files return `404`. + +Pass `statCache: false` to disable the per-path `Last-Modified` cache (saves roughly 80 KB per route). + --- ## Streaming files diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 170b6b56c50d..8944e32793fe 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -104,23 +104,28 @@ impl DirectoryRoute { #[allow(clippy::not_unsafe_ptr_arg_deref)] pub fn on_head_request(this: *mut DirectoryRoute, req: AnyRequest, resp: AnyResponse) { - bun_ptr::BackRef::from(NonNull::new(this).unwrap()).on(req, resp, Method::HEAD); + Self::on(NonNull::new(this).unwrap(), req, resp, Method::HEAD); } #[allow(clippy::not_unsafe_ptr_arg_deref)] pub fn on_request(this: *mut DirectoryRoute, req: AnyRequest, resp: AnyResponse) { let method = Method::find(req.method()).unwrap_or(Method::GET); - bun_ptr::BackRef::from(NonNull::new(this).unwrap()).on(req, resp, method); + Self::on(NonNull::new(this).unwrap(), req, resp, method); } - fn on(&self, mut req: AnyRequest, resp: AnyResponse, method: Method) { - debug_assert!(self.server.get().is_some()); - self.ref_(); + // `this_ptr` (not `&self`) because it is stashed as `FileResponseStream`'s + // ctx userdata; `on_stream_complete` may drop the last ref after a reload, + // and `Box::from_raw` on a `&self`-derived pointer is UB under Stacked + // Borrows. See src/CLAUDE.md §Pointer provenance at FFI boundaries. + fn on(this_ptr: NonNull, mut req: AnyRequest, resp: AnyResponse, method: Method) { + let this = bun_ptr::BackRef::from(this_ptr); + debug_assert!(this.server.get().is_some()); + this.ref_(); let guard = ResponseGuard { - route: NonNull::from(self), + route: this_ptr, resp, }; - if let Some(mut server) = self.server.get() { + if let Some(mut server) = this.server.get() { server.on_pending_request(); resp.timeout(server.config().idle_timeout); } @@ -129,7 +134,7 @@ impl DirectoryRoute { let mut path_buf = bun_paths::path_buffer_pool::get(); let Some(rel_len) = resolve_subpath( req.url(), - &self.url_prefix, + &this.url_prefix, &mut decode_buf.0[..], &mut path_buf.0[..], ) else { @@ -140,7 +145,7 @@ impl DirectoryRoute { drop(decode_buf); let rel: &[u8] = &path_buf.0[..rel_len]; - let Some((file, stat, is_index)) = self.open_subpath(rel) else { + let Some((file, stat, is_index)) = this.open_subpath(rel) else { bun_output::scoped_log!(DirectoryRoute, "miss {}", bstr::BStr::new(rel)); write_miss(&mut req, resp); return; @@ -148,7 +153,7 @@ impl DirectoryRoute { let size: u64 = u64::try_from(stat.st_size.max(0)).expect("int cast"); - let (last_modified_ms, lm_buf, lm_len) = self.stat_cache_lookup(rel, &stat); + let (last_modified_ms, lm_buf, lm_len) = this.stat_cache_lookup(rel, &stat); let last_modified = (lm_len > 0).then(|| &lm_buf[..lm_len]); let mut etag_buf = [0u8; 40]; @@ -186,7 +191,7 @@ impl DirectoryRoute { resp.write_header(b"last-modified", lm); } resp.write_header(b"etag", etag); - if let Some(srv) = self.server.get() { + if let Some(srv) = this.server.get() { if let Some(alt) = srv.h3_alt_svc() { resp.write_header(b"alt-svc", alt); } @@ -233,7 +238,7 @@ impl DirectoryRoute { size ); - let server = self.server.get().unwrap(); + let server = this.server.get().unwrap(); FileResponseStream::start(&FileResponseStreamOptions { fd: file.into_raw(), auto_close: true, @@ -282,7 +287,7 @@ impl DirectoryRoute { fn open_beneath(&self, rel: &[u8]) -> Option { let mut buf = bun_paths::path_buffer_pool::get(); let zrel = resolve_path::z(rel, &mut *buf); - let flags = bun_sys::O::RDONLY | bun_sys::O::CLOEXEC | bun_sys::O::NONBLOCK; + let flags = bun_sys::O::RDONLY | bun_sys::O::CLOEXEC; #[cfg(any(target_os = "linux", target_os = "android"))] let fd = bun_sys::openat2_in_root(self.root_fd.get(), zrel, flags, 0).ok()?; #[cfg(not(any(target_os = "linux", target_os = "android")))] diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index ee65ac2bdf4a..781d734771b7 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -394,10 +394,11 @@ describe("Bun.serve() directory routes", () => { }); it("throws if the directory does not exist", () => { + using dir = tempDir("serve-dir-enoent", {}); expect(() => serve({ port: 0, - routes: { "/static/*": { dir: "/nonexistent/path/that/does/not/exist" } }, + routes: { "/static/*": { dir: join(String(dir), "does-not-exist") } }, }), ).toThrow(expect.objectContaining({ code: "ENOENT" })); }); From 4c7c6338010c5ab99ac38042a51db0ef72f66060 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:51:43 +0000 Subject: [PATCH 15/29] [autofix.ci] apply automated fixes --- src/runtime/server/DirectoryRoute.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 8944e32793fe..4ceb3cdee5a9 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -117,7 +117,12 @@ impl DirectoryRoute { // ctx userdata; `on_stream_complete` may drop the last ref after a reload, // and `Box::from_raw` on a `&self`-derived pointer is UB under Stacked // Borrows. See src/CLAUDE.md §Pointer provenance at FFI boundaries. - fn on(this_ptr: NonNull, mut req: AnyRequest, resp: AnyResponse, method: Method) { + fn on( + this_ptr: NonNull, + mut req: AnyRequest, + resp: AnyResponse, + method: Method, + ) { let this = bun_ptr::BackRef::from(this_ptr); debug_assert!(this.server.get().is_some()); this.ref_(); From e16de34893f953da578aee64ad513b613b369118 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:51:41 +0000 Subject: [PATCH 16/29] DirectoryRoute: 256 cache slots, reject :params, clippy, POSIX-only NONBLOCK - STAT_CACHE_SLOTS 1024 -> 256 (~20 KB per route); docs and the exhaustion test updated to match. - Reject : in the route path with a clear error; :parameters would match in uWS but never pass the literal prefix check in resolve_subpath. - into_ctx uses ManuallyDrop instead of mem::forget (clippy mem_forget on Drop type). - O::NONBLOCK is back on POSIX (so opening a FIFO without a writer cannot block the event loop) but stays off on Windows where openat maps it to omitting FILE_SYNCHRONOUS_IO_NONALERT. --- docs/runtime/http/routing.mdx | 2 +- packages/bun-types/serve.d.ts | 2 +- src/runtime/server/DirectoryRoute.rs | 13 +++++++++---- src/runtime/server/server_body.rs | 5 +++++ test/js/bun/http/serve-directory-routes.test.ts | 12 +++++++++++- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/runtime/http/routing.mdx b/docs/runtime/http/routing.mdx index 8e04cc574914..eeac97498fc2 100644 --- a/docs/runtime/http/routing.mdx +++ b/docs/runtime/http/routing.mdx @@ -216,7 +216,7 @@ Directory routes share the response path with file routes: - Requests that resolve to a directory are served `index.html` from that directory. - Missing files return `404`. -Pass `statCache: false` to disable the per-path `Last-Modified` cache (saves roughly 80 KB per route). +Pass `statCache: false` to disable the per-path `Last-Modified` cache (saves roughly 20 KB per route). --- diff --git a/packages/bun-types/serve.d.ts b/packages/bun-types/serve.d.ts index 159e00ed878d..2a9142f80ec1 100644 --- a/packages/bun-types/serve.d.ts +++ b/packages/bun-types/serve.d.ts @@ -607,7 +607,7 @@ declare module "bun" { dir: string; /** * Cache formatted `Last-Modified` strings per path so repeated requests - * for an unchanged file skip the date formatter. Uses ~60KB per route. + * for an unchanged file skip the date formatter. Uses ~20 KB per route. * @default true */ statCache?: boolean; diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 4ceb3cdee5a9..76dfa2d8cb00 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -21,7 +21,7 @@ use crate::server::{AnyServer, FileResponseStream, HTTPStatusText, RangeRequest} bun_output::declare_scope!(DirectoryRoute, hidden); /// `wyhash(subpath) % N` direct-mapped StatHash cache; collisions overwrite. -const STAT_CACHE_SLOTS: usize = 1024; +const STAT_CACHE_SLOTS: usize = 256; #[derive(Default)] struct StatCacheEntry { @@ -292,6 +292,13 @@ impl DirectoryRoute { fn open_beneath(&self, rel: &[u8]) -> Option { let mut buf = bun_paths::path_buffer_pool::get(); let zrel = resolve_path::z(rel, &mut *buf); + // NONBLOCK so opening a FIFO without a writer cannot block the event + // loop on POSIX. Not on Windows: there `openat` maps it to omitting + // FILE_SYNCHRONOUS_IO_NONALERT, which breaks the synchronous reads + // FileResponseStream issues. + #[cfg(not(windows))] + let flags = bun_sys::O::RDONLY | bun_sys::O::CLOEXEC | bun_sys::O::NONBLOCK; + #[cfg(windows)] let flags = bun_sys::O::RDONLY | bun_sys::O::CLOEXEC; #[cfg(any(target_os = "linux", target_os = "android"))] let fd = bun_sys::openat2_in_root(self.root_fd.get(), zrel, flags, 0).ok()?; @@ -360,9 +367,7 @@ struct ResponseGuard { impl ResponseGuard { fn into_ctx(self) -> *mut c_void { - let ctx = self.route.as_ptr().cast::(); - core::mem::forget(self); - ctx + core::mem::ManuallyDrop::new(self).route.as_ptr().cast() } } diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index dafe138173ad..30fc558189f9 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -780,6 +780,11 @@ impl AnyRoute { let style_js = argument.get(global, b"style")?; if style_js.is_none() { + if strings::index_of_char(path, b':').is_some() { + return Err(global.throw_invalid_arguments(format_args!( + "Directory routes do not support :parameters; use a fixed prefix ending in `/*`" + ))); + } // `{ dir }` without `style` serves the directory tree // verbatim; `{ dir, style }` opts into framework routing. let url_prefix: &[u8] = if path.len() == 2 { diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index 781d734771b7..f57a64bcb4c8 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -393,6 +393,16 @@ describe("Bun.serve() directory routes", () => { ).toThrow(/ends in `\/\*`/); }); + it("rejects :parameters in the route path", () => { + using dir = tempDir("serve-dir-param", { "public/x.txt": "x" }); + expect(() => + serve({ + port: 0, + routes: { "/users/:id/files/*": { dir: join(String(dir), "public") } }, + }), + ).toThrow(/do not support :parameters/); + }); + it("throws if the directory does not exist", () => { using dir = tempDir("serve-dir-enoent", {}); expect(() => @@ -404,7 +414,7 @@ describe("Bun.serve() directory routes", () => { }); it("serves correctly with more unique paths than stat-cache slots", async () => { - const N = 1100; // > STAT_CACHE_SLOTS (1024) + const N = 300; // > STAT_CACHE_SLOTS (256) const files: Record = {}; for (let i = 0; i < N; i++) files[`public/f${i}.txt`] = `v${i}`; using dir = tempDir("serve-dir-exhaust", files); From 920ba66205917acba96fe9d95bc632768e78810b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:14:06 +0000 Subject: [PATCH 17/29] DirectoryRoute: accept absolute-form request-targets uWS routes GET http://host/static/foo via getUrlForRouting() (which strips the scheme+authority) but req.url() is getFullUrl() and returns the raw request-target. Strip the leading scheme+authority in resolve_subpath so the prefix check sees the same path the router did. Fail-closed before (404), now serves per RFC 9112 3.2.2. --- src/runtime/server/DirectoryRoute.rs | 31 ++++++++++++++++++- .../bun/http/serve-directory-routes.test.ts | 5 +++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 76dfa2d8cb00..261086df1b1e 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -400,7 +400,28 @@ fn resolve_subpath( scratch: &mut [u8], out: &mut [u8], ) -> Option { - // `req.url()` is uWS `getFullUrl()`, which includes the query string. + // `req.url()` is uWS `getFullUrl()`: the raw request-target. Strip an + // absolute-form scheme+authority (RFC 9112 §3.2.2) and the query string, + // mirroring what uWS `getUrlForRouting()` did to dispatch to this handler. + let url = if !url.is_empty() && url[0] != b'/' { + let skip = if strings::has_prefix_comptime(url, b"http://") { + 7 + } else if strings::has_prefix_comptime(url, b"https://") { + 8 + } else { + 0 + }; + if skip > 0 { + match strings::index_of_char(&url[skip..], b'/') { + Some(i) => &url[skip + i as usize..], + None => b"/", + } + } else { + url + } + } else { + url + }; let url = match strings::index_of_char(url, b'?') { Some(i) => &url[..i as usize], None => url, @@ -495,6 +516,14 @@ mod tests { resolve(b"/static?x", b"/static/").as_deref(), Some(&b""[..]) ); + assert_eq!( + resolve(b"http://x/static/a.txt", b"/static/").as_deref(), + Some(&b"a.txt"[..]) + ); + assert_eq!( + resolve(b"https://x:8080/static/a.txt?v=1", b"/static/").as_deref(), + Some(&b"a.txt"[..]) + ); } #[test] diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index f57a64bcb4c8..2b7b49b11005 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -553,6 +553,11 @@ describe("Bun.serve() directory routes", () => { expect(slashes.status).toBe(200); expect(slashes.body).toContain("ok"); + // Absolute-form request-target (RFC 9112 §3.2.2). + const abs = await raw("http://x/static/ok.txt"); + expect(abs.status).toBe(200); + expect(abs.body).toContain("ok"); + // Percent-encoded UTF-8 filename. const utf8 = await fetch(`${server.url}static/%C3%A9.txt`); expect(utf8.status).toBe(200); From 1e703e6c1411ea0a547f8581f383ac79b6217417 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:30:18 +0000 Subject: [PATCH 18/29] DirectoryRoute: case-insensitive scheme match for absolute-form uWS getUrlForRouting() uses strncasecmp; match that so HTTP:// and https:// both work. Still fail-closed on any other non-/ prefix. --- src/runtime/server/DirectoryRoute.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 261086df1b1e..a66b431beb61 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -404,9 +404,9 @@ fn resolve_subpath( // absolute-form scheme+authority (RFC 9112 §3.2.2) and the query string, // mirroring what uWS `getUrlForRouting()` did to dispatch to this handler. let url = if !url.is_empty() && url[0] != b'/' { - let skip = if strings::has_prefix_comptime(url, b"http://") { + let skip = if strings::has_prefix_case_insensitive(url, b"http://") { 7 - } else if strings::has_prefix_comptime(url, b"https://") { + } else if strings::has_prefix_case_insensitive(url, b"https://") { 8 } else { 0 @@ -520,6 +520,10 @@ mod tests { resolve(b"http://x/static/a.txt", b"/static/").as_deref(), Some(&b"a.txt"[..]) ); + assert_eq!( + resolve(b"HTTP://x/static/a.txt", b"/static/").as_deref(), + Some(&b"a.txt"[..]) + ); assert_eq!( resolve(b"https://x:8080/static/a.txt?v=1", b"/static/").as_deref(), Some(&b"a.txt"[..]) From 0029592897c67b48d572fb9f5058122cd55d2bc3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:57:33 +0000 Subject: [PATCH 19/29] DirectoryRoute: strip ? before absolute-form authority (route precedence) uWS getUrlForRouting() operates on getUrl() which is already truncated at the first ?, so its authority search never sees a / inside the query. Stripping scheme+authority first meant http://x?q/admin/secret resolved to /admin/secret here while uWS routed it as /, bypassing any more-specific /admin/* handler. Unlike the earlier absolute-form fixes this was fail-open; moving the ? truncation first restores exact parity with the router. --- src/runtime/server/DirectoryRoute.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index a66b431beb61..392eefff5563 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -400,9 +400,15 @@ fn resolve_subpath( scratch: &mut [u8], out: &mut [u8], ) -> Option { - // `req.url()` is uWS `getFullUrl()`: the raw request-target. Strip an - // absolute-form scheme+authority (RFC 9112 §3.2.2) and the query string, - // mirroring what uWS `getUrlForRouting()` did to dispatch to this handler. + // `req.url()` is uWS `getFullUrl()`: the raw request-target. Strip the + // query string first, then an absolute-form scheme+authority (RFC 9112 + // §3.2.2), mirroring uWS `getUrlForRouting()` exactly — it operates on + // `getUrl()` (already truncated at `?`) so the authority search never + // sees a `/` that appears inside the query. + let url = match strings::index_of_char(url, b'?') { + Some(i) => &url[..i as usize], + None => url, + }; let url = if !url.is_empty() && url[0] != b'/' { let skip = if strings::has_prefix_case_insensitive(url, b"http://") { 7 @@ -422,10 +428,6 @@ fn resolve_subpath( } else { url }; - let url = match strings::index_of_char(url, b'?') { - Some(i) => &url[..i as usize], - None => url, - }; let after_prefix = if strings::starts_with(url, url_prefix) { &url[url_prefix.len()..] } else if url.len() + 1 == url_prefix.len() && url == &url_prefix[..url_prefix.len() - 1] { @@ -524,6 +526,8 @@ mod tests { resolve(b"HTTP://x/static/a.txt", b"/static/").as_deref(), Some(&b"a.txt"[..]) ); + assert_eq!(resolve(b"http://x?q/admin/secret", b"/").as_deref(), Some(&b""[..])); + assert_eq!(resolve(b"http://x", b"/").as_deref(), Some(&b""[..])); assert_eq!( resolve(b"https://x:8080/static/a.txt?v=1", b"/static/").as_deref(), Some(&b"a.txt"[..]) From 64f62f79f3e90b938d2e7e1de8a8f99108c76894 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:59:40 +0000 Subject: [PATCH 20/29] [autofix.ci] apply automated fixes --- src/runtime/server/DirectoryRoute.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 392eefff5563..19edb635113c 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -526,7 +526,10 @@ mod tests { resolve(b"HTTP://x/static/a.txt", b"/static/").as_deref(), Some(&b"a.txt"[..]) ); - assert_eq!(resolve(b"http://x?q/admin/secret", b"/").as_deref(), Some(&b""[..])); + assert_eq!( + resolve(b"http://x?q/admin/secret", b"/").as_deref(), + Some(&b""[..]) + ); assert_eq!(resolve(b"http://x", b"/").as_deref(), Some(&b""[..])); assert_eq!( resolve(b"https://x:8080/static/a.txt?v=1", b"/static/").as_deref(), From c1804c66f3cb236a95dffcc7495b48c866701116 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:44:24 +0000 Subject: [PATCH 21/29] DirectoryRoute: require canonical paths (route-precedence parity) uWS routes on raw URL segments with no percent-decoding and no normalization, so any transformation resolve_subpath applied that uWS did not created a served path uWS never matched. With an overlapping auth-gated inner route, GET /static//admin/secret (browser-reachable), /static/./admin/..., /static/x/../admin/..., or /static/admin%2Fsecret all routed to the outer /static/* directory route but reached public/admin/secret, bypassing the inner handler. resolve_subpath now validates that the decoded subpath is already canonical (no empty, `.`, or `..` segments) and rejects if percent-decoding introduced a `/` (encoded %2F). The served path is then byte-identical to what uWS routed on, so no overlapping route can be bypassed. A single trailing `/` is still accepted and stripped for index.html handling; other percent-encoding (spaces, UTF-8) is unchanged. The adversarial test now expects 404 for `//`, `./`, `../` and %2F forms, and a new "cannot bypass a more-specific overlapping route" test covers the four vectors against a 401 inner handler. --- src/runtime/server/DirectoryRoute.rs | 92 ++++++++++++------- .../bun/http/serve-directory-routes.test.ts | 62 ++++++++++--- 2 files changed, 109 insertions(+), 45 deletions(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 19edb635113c..588c6dd5ada1 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -436,40 +436,51 @@ fn resolve_subpath( return None; }; - if after_prefix.len() >= scratch.len() { + // Leave room for the NUL `z()` appends and for `"/index.html"` when the + // resolved path turns out to be a directory. + if after_prefix.len() >= out.len().saturating_sub(b"/index.html\0".len()) { return None; } + let raw_slashes = after_prefix.iter().filter(|&&b| b == b'/').count(); let decoded_len = bun_url::PercentEncoding::decode_into(&mut scratch[..after_prefix.len()], after_prefix) .ok()? as usize; - - let mut start = 0; - while start < decoded_len && scratch[start] == b'/' { - start += 1; + let decoded = &scratch[..decoded_len]; + + // uWS routed on the raw URL split on literal `/` with no decode and no + // normalization. Any transformation we apply that uWS did not creates a + // path uWS never matched, which can bypass a more-specific overlapping + // route. So: reject if percent-decoding introduced a `/` (encoded %2F), + // and require the decoded path to already be in canonical form (no empty, + // `.`, or `..` segments). A single trailing `/` is allowed and stripped. + if decoded.iter().filter(|&&b| b == b'/').count() != raw_slashes { + return None; + } + let mut end = decoded_len; + if end > 0 && decoded[end - 1] == b'/' { + end -= 1; } - let decoded = &scratch[start..decoded_len]; - for &b in decoded { - // NUL truncates C strings; `\` and `:` are Windows separators / drive - // prefixes / ADS markers that `openat` on Windows treats as absolute. - if b == 0 || b == b'\\' || b == b':' { + let mut seg_start = 0; + let mut i = 0; + while i <= end { + if i == end || decoded[i] == b'/' { + let seg = &decoded[seg_start..i]; + if seg.is_empty() || seg == b"." || seg == b".." { + if i == 0 && end == 0 { + return Some(0); + } + return None; + } + seg_start = i + 1; + } else if decoded[i] == 0 || decoded[i] == b'\\' || decoded[i] == b':' { return None; } - } - if decoded.is_empty() { - return Some(0); + i += 1; } - // Leave room for the NUL `z()` appends and for `"/index.html"` when the - // resolved path turns out to be a directory. - let max_norm = out.len().saturating_sub(b"/index.html\0".len()); - let norm = resolve_path::normalize_string_buf::( - decoded, out, - ); - if norm == b".." || strings::starts_with(norm, b"../") || norm.len() > max_norm { - return None; - } - Some(norm.len()) + out[..end].copy_from_slice(&decoded[..end]); + Some(end) } /// `W/"-"` (nginx/send scheme). @@ -537,6 +548,12 @@ mod tests { ); } + #[test] + fn resolve_trailing_slash() { + assert_eq!(resolve(b"/static/a/", b"/static/").as_deref(), Some(&b"a"[..])); + assert_eq!(resolve(b"/static/a/b/", b"/static/").as_deref(), Some(&b"a/b"[..])); + } + #[test] fn resolve_traversal() { assert_eq!(resolve(b"/static/../etc/passwd", b"/static/"), None); @@ -545,15 +562,28 @@ mod tests { assert_eq!(resolve(b"/static/a/../../etc", b"/static/"), None); assert_eq!(resolve(b"/static/c:/windows", b"/static/"), None); assert_eq!(resolve(b"/static/file::$DATA", b"/static/"), None); - assert_eq!( - resolve(b"/static/a/../b.txt", b"/static/").as_deref(), - Some(&b"b.txt"[..]) - ); - assert_eq!( - resolve(b"/static/a//b.txt", b"/static/").as_deref(), - Some(&b"a/b.txt"[..]) - ); assert_eq!(resolve(b"/static/a%00.txt", b"/static/"), None); assert_eq!(resolve(b"/static/a%5Cb.txt", b"/static/"), None); } + + #[test] + fn resolve_route_precedence_parity() { + // These all route to the outer wildcard in uWS (which matches on raw + // segments) but would reach a file under an inner prefix if we + // normalized or decoded `/`. Reject so the served path equals the + // routed path. + assert_eq!(resolve(b"/static/a%2Fb.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/a%2fb.txt", b"/static/"), None); + assert_eq!(resolve(b"/static//a/b.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/a//b.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/./a.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/a/./b.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/a/../b.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/a/..", b"/static/"), None); + // Legitimate percent-encoding (not `/`) still works. + assert_eq!( + resolve(b"/static/hello%20world.txt", b"/static/").as_deref(), + Some(&b"hello world.txt"[..]) + ); + } } diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index 2b7b49b11005..8c241babb256 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -480,6 +480,40 @@ describe("Bun.serve() directory routes", () => { expect(results.every(r => r === "hot")).toBe(true); }); + it("cannot bypass a more-specific overlapping route via path manipulation", async () => { + using dir = tempDir("serve-dir-bypass", { + "public/admin/secret.txt": "SECRET", + "public/ok.txt": "ok", + }); + + server = serve({ + port: 0, + routes: { + "/static/admin/*": () => new Response("auth", { status: 401 }), + "/static/*": { dir: join(String(dir), "public") }, + }, + }); + + // Canonical path hits the inner route. + expect((await fetch(`${server.url}static/admin/secret.txt`)).status).toBe(401); + + // Non-canonical forms that uWS routes to the outer wildcard must not + // reach public/admin/secret.txt via the directory route. + for (const p of [ + "/static//admin/secret.txt", + "/static/./admin/secret.txt", + "/static/x/../admin/secret.txt", + "/static/admin%2Fsecret.txt", + ]) { + const r = await raw(p); + expect(r.body).not.toContain("SECRET"); + expect([401, 404]).toContain(r.status); + } + + // Canonical paths under the outer route still work. + expect(await (await fetch(`${server.url}static/ok.txt`)).text()).toBe("ok"); + }); + it("yields to more-specific overlapping routes", async () => { using dir = tempDir("serve-dir-precedence", { "public/file.txt": "from-dir", @@ -538,20 +572,20 @@ describe("Bun.serve() directory routes", () => { fetch: () => new Response("fallback", { status: 404 }), }); - // Deep `..` chain that cancels out stays inside the root. - const deep = await raw("/static/a/b/c/d/e/f/g/h/../../../../../../../../a/b/c/d/e/f/g/h/target.txt"); - expect(deep.status).toBe(200); - expect(deep.body).toContain("deep"); - - // Deep `..` chain that escapes is rejected. - const escape = await raw("/static/a/b/c/d/e/f/g/h/../../../../../../../../../secret.txt"); - expect(escape.body).not.toContain("SECRET"); - expect(escape.status).toBe(404); - - // Many consecutive slashes collapse. - const slashes = await raw("/static////////ok.txt"); - expect(slashes.status).toBe(200); - expect(slashes.body).toContain("ok"); + // `..`, `.`, empty segments, and encoded `/` are rejected outright so the + // served path matches what uWS routed on (route-precedence parity). + for (const p of [ + "/static/a/b/c/d/e/f/g/h/../../../../../../../../a/b/c/d/e/f/g/h/target.txt", + "/static/a/b/c/d/e/f/g/h/../../../../../../../../../secret.txt", + "/static////////ok.txt", + "/static/./ok.txt", + "/static/a/../ok.txt", + "/static/a%2Fb%2Fok.txt", + ]) { + const r = await raw(p); + expect(r.body).not.toContain("SECRET"); + expect(r.status).toBe(404); + } // Absolute-form request-target (RFC 9112 §3.2.2). const abs = await raw("http://x/static/ok.txt"); From 0941cf5ae934104e2ecc30c009f4996408156bd1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:46:31 +0000 Subject: [PATCH 22/29] [autofix.ci] apply automated fixes --- src/runtime/server/DirectoryRoute.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 588c6dd5ada1..8fe586a25e2f 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -550,8 +550,14 @@ mod tests { #[test] fn resolve_trailing_slash() { - assert_eq!(resolve(b"/static/a/", b"/static/").as_deref(), Some(&b"a"[..])); - assert_eq!(resolve(b"/static/a/b/", b"/static/").as_deref(), Some(&b"a/b"[..])); + assert_eq!( + resolve(b"/static/a/", b"/static/").as_deref(), + Some(&b"a"[..]) + ); + assert_eq!( + resolve(b"/static/a/b/", b"/static/").as_deref(), + Some(&b"a/b"[..]) + ); } #[test] From b01e9669aae012ee44f6e0646a72aa3bf4d68c96 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:47:19 +0000 Subject: [PATCH 23/29] DirectoryRoute: close remaining route-precedence bypasses Three fixes to the canonical-path invariant ("served path equals routed path"): - Reject %XX encoding an RFC 3986 pchar (unreserved / sub-delims / ":" / "@"). uWS matches static children by raw byte-compare, so /static/%61dmin/secret.txt routes to /static/* (not /static/admin/*) but previously decoded to admin/secret.txt and was served. Since route segments can only consist of pchars on the wire, rejecting encoded pchars makes percent-decoding the identity on every byte that can influence routing, while still decoding %20, high-bit UTF-8, etc. - 301-redirect to the trailing-slash URL when a non-root subpath opens as a directory without a trailing slash, instead of serving {rel}/index.html directly. /static/admin (no slash) previously routed to /static/* and served admin/index.html, bypassing /static/admin/*. The redirect re-enters routing so the canonical URL matches the canonical route. Location is built from the validated path portion of the request-target, so it always begins with a single "/" (cannot be a protocol-relative //host/). - MimeType::by_extension now looks up case-insensitively (photo.JPG was served as application/octet-stream). Also: drop the dead "|| rel == b\".\"" disjunct and bring the docs (routing.mdx, serve.d.ts JSDoc, resolve_subpath doc comment) in line with the reject-not-normalize behavior from c1804c66. --- docs/runtime/http/routing.mdx | 4 +- packages/bun-types/serve.d.ts | 12 +- src/http_types/MimeType.rs | 2 +- src/runtime/server/DirectoryRoute.rs | 302 ++++++++++++------ .../bun/http/serve-directory-routes.test.ts | 69 +++- 5 files changed, 282 insertions(+), 107 deletions(-) diff --git a/docs/runtime/http/routing.mdx b/docs/runtime/http/routing.mdx index eeac97498fc2..f72b28583b96 100644 --- a/docs/runtime/http/routing.mdx +++ b/docs/runtime/http/routing.mdx @@ -206,14 +206,14 @@ Bun.serve({ }); ``` -The part of the request URL after the prefix is percent-decoded once, cleaned of `.` and `..` segments, and opened relative to `dir`. On Linux the open uses `openat2(RESOLVE_IN_ROOT)`, so symlinks that would escape `dir` are clamped by the kernel; other platforms rely on the same lexical normalization. +The part of the request URL after the prefix is percent-decoded once and opened relative to `dir`. Non-canonical paths (those containing `.`, `..`, empty segments, `%2F`, or a percent-encoded ASCII character) are rejected with `404`, so the served path is always the path the router matched. On Linux the open uses `openat2(RESOLVE_IN_ROOT)`, so symlinks that would escape `dir` are clamped by the kernel. Directory routes share the response path with file routes: - **Content-Type** is set from the file extension. - **Last-Modified** and a weak `ETag` (`W/"-"`) are sent on every response, and `If-Modified-Since` / `If-None-Match` are honored with `304 Not Modified`. - **Range requests** are supported with `Accept-Ranges: bytes` and `Content-Range`. -- Requests that resolve to a directory are served `index.html` from that directory. +- A request that resolves to a directory without a trailing `/` is answered with a `301` redirect to the trailing-slash URL; with the trailing slash, `index.html` from that directory is served. - Missing files return `404`. Pass `statCache: false` to disable the per-path `Last-Modified` cache (saves roughly 20 KB per route). diff --git a/packages/bun-types/serve.d.ts b/packages/bun-types/serve.d.ts index 2a9142f80ec1..9f9143bb2c60 100644 --- a/packages/bun-types/serve.d.ts +++ b/packages/bun-types/serve.d.ts @@ -583,15 +583,19 @@ declare module "bun" { * Serve a directory tree at a URL prefix. * * The route path **must** end in `/*`. The part of the request URL after - * the prefix is percent-decoded once, cleaned of `.`/`..` segments, and - * opened relative to `dir`. On Linux the open uses + * the prefix is percent-decoded once and opened relative to `dir`. + * Non-canonical paths (containing `.`, `..`, empty segments, `%2F`, or a + * percent-encoded ASCII character) are rejected with `404` so the served + * path is always the path the router matched. On Linux the open uses * `openat2(RESOLVE_IN_ROOT)`, so symlinks that would escape `dir` are * clamped by the kernel. * * Responses carry `Content-Type` (from the file extension), * `Last-Modified`, a weak `ETag`, and support single-range `Range` - * requests. Requests that resolve to a directory are served - * `index.html` from that directory. Missing files return `404`. + * requests. A request that resolves to a directory without a trailing + * `/` is redirected (`301`) to the trailing-slash URL; with the trailing + * slash, `index.html` from that directory is served. Missing files + * return `404`. * * @example * ```ts diff --git a/src/http_types/MimeType.rs b/src/http_types/MimeType.rs index 326065165989..751f8a2ba9da 100644 --- a/src/http_types/MimeType.rs +++ b/src/http_types/MimeType.rs @@ -432,7 +432,7 @@ pub fn by_extension(ext_without_leading_dot: &[u8]) -> MimeType { } pub fn by_extension_no_default(ext_without_leading_dot: &[u8]) -> Option { - if let Some(entry) = EXTENSIONS.get(ext_without_leading_dot) { + if let Some(entry) = EXTENSIONS.get_ascii_case_insensitive(ext_without_leading_dot) { return Some(Compact::from(*entry).to_mime_type()); } None diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 8fe586a25e2f..aaa0033d49d2 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -137,7 +137,7 @@ impl DirectoryRoute { let mut decode_buf = bun_paths::path_buffer_pool::get(); let mut path_buf = bun_paths::path_buffer_pool::get(); - let Some(rel_len) = resolve_subpath( + let Some((rel_len, had_trailing_slash)) = resolve_subpath( req.url(), &this.url_prefix, &mut decode_buf.0[..], @@ -150,10 +150,24 @@ impl DirectoryRoute { drop(decode_buf); let rel: &[u8] = &path_buf.0[..rel_len]; - let Some((file, stat, is_index)) = this.open_subpath(rel) else { - bun_output::scoped_log!(DirectoryRoute, "miss {}", bstr::BStr::new(rel)); - write_miss(&mut req, resp); - return; + let (file, stat, is_index) = match this.open_subpath(rel, had_trailing_slash) { + Some(Subpath::File(f, s, idx)) => (f, s, idx), + Some(Subpath::RedirectSlash) => { + let mut loc = bun_paths::path_buffer_pool::get(); + let n = build_slash_redirect(req.url(), &mut loc.0[..]); + req.set_yield(false); + write_any_status(resp, 301); + resp.write_mark(); + resp.write_header(b"location", &loc.0[..n]); + resp.write_header_int(b"content-length", 0); + resp.end(b"", resp.should_close_connection()); + return; + } + None => { + bun_output::scoped_log!(DirectoryRoute, "miss {}", bstr::BStr::new(rel)); + write_miss(&mut req, resp); + return; + } }; let size: u64 = u64::try_from(stat.st_size.max(0)).expect("int cast"); @@ -261,31 +275,38 @@ impl DirectoryRoute { }); } - /// Returns `(file, stat, served_index_html)` for a regular file; tries - /// `index.html` for directories. - fn open_subpath(&self, rel: &[u8]) -> Option<(File, bun_sys::Stat, bool)> { + /// Open `rel` under the root. For directories: serve `index.html` when the + /// URL had a trailing slash, otherwise ask the caller to 301-redirect to + /// the slash form so the new request re-enters routing (the served + /// resource's canonical URL may be owned by a more-specific route). + fn open_subpath(&self, rel: &[u8], had_trailing_slash: bool) -> Option { let open_and_stat = |p: &[u8]| -> Option<(File, bun_sys::Stat)> { let f = self.open_beneath(p)?; let s = f.stat().ok()?; Some((f, s)) }; - if rel.is_empty() || rel == b"." { + if rel.is_empty() { let (f, s) = open_and_stat(b"index.html")?; - return bun_sys::S::ISREG(s.st_mode as bun_sys::Mode).then_some((f, s, true)); + return bun_sys::S::ISREG(s.st_mode as bun_sys::Mode) + .then_some(Subpath::File(f, s, true)); } let (file, stat) = open_and_stat(rel)?; let mode = stat.st_mode as bun_sys::Mode; if bun_sys::S::ISDIR(mode) { drop(file); + if !had_trailing_slash { + return Some(Subpath::RedirectSlash); + } let mut buf = bun_paths::path_buffer_pool::get(); let joined = resolve_path::join_string_buf::( &mut buf.0[..], &[rel, b"index.html"], ); let (f, s) = open_and_stat(joined)?; - return bun_sys::S::ISREG(s.st_mode as bun_sys::Mode).then_some((f, s, true)); + return bun_sys::S::ISREG(s.st_mode as bun_sys::Mode) + .then_some(Subpath::File(f, s, true)); } - bun_sys::S::ISREG(mode).then_some((file, stat, false)) + bun_sys::S::ISREG(mode).then_some(Subpath::File(file, stat, false)) } /// `openat2(RESOLVE_IN_ROOT|NO_MAGICLINKS)` on Linux, `openat` elsewhere. @@ -385,6 +406,13 @@ fn on_stream_error(ctx: *mut c_void, resp: AnyResponse, _err: bun_sys::Error) { DirectoryRoute::on_response_complete(NonNull::new(ctx.cast()).unwrap(), resp); } +// `Stat` is ~144 bytes; boxing it would add a heap alloc on the hot path. +#[allow(clippy::large_enum_variant)] +enum Subpath { + File(File, bun_sys::Stat, bool), + RedirectSlash, +} + fn write_miss(req: &mut AnyRequest, resp: AnyResponse) { req.set_yield(false); write_any_status(resp, 404); @@ -392,45 +420,98 @@ fn write_miss(req: &mut AnyRequest, resp: AnyResponse) { resp.end(b"", resp.should_close_connection()); } -/// Strip `url_prefix`, percent-decode once, reject NUL/`\`, normalize `.`/`..`; -/// `None` if the result would escape the root. Writes into `out`. -fn resolve_subpath( - url: &[u8], - url_prefix: &[u8], - scratch: &mut [u8], - out: &mut [u8], -) -> Option { - // `req.url()` is uWS `getFullUrl()`: the raw request-target. Strip the - // query string first, then an absolute-form scheme+authority (RFC 9112 - // §3.2.2), mirroring uWS `getUrlForRouting()` exactly — it operates on - // `getUrl()` (already truncated at `?`) so the authority search never - // sees a `/` that appears inside the query. - let url = match strings::index_of_char(url, b'?') { - Some(i) => &url[..i as usize], - None => url, +/// `Location: {path}/{?query}` into `out`. `resolve_subpath` has already +/// validated `path`: it starts with `url_prefix` (which starts with `/`) and +/// its first segment is non-empty, so the result cannot be a `//...` +/// protocol-relative URL (CVE-2024-43799). +fn build_slash_redirect(url: &[u8], out: &mut [u8]) -> usize { + let (path, query) = path_and_query(url); + debug_assert!(path.first() == Some(&b'/') && path.get(1) != Some(&b'/')); + let n = path.len() + 1 + query.len(); + if n > out.len() { + // Oversized query: redirect without it rather than fail the request. + out[..path.len()].copy_from_slice(path); + out[path.len()] = b'/'; + return path.len() + 1; + } + out[..path.len()].copy_from_slice(path); + out[path.len()] = b'/'; + out[path.len() + 1..n].copy_from_slice(query); + n +} + +/// Split a raw request-target (uWS `getFullUrl()`) into `(path, query)`. +/// Strips `?query` first, then any absolute-form scheme+authority (RFC 9112 +/// §3.2.2), mirroring uWS `getUrlForRouting()` exactly. `query` includes the +/// leading `?` when present. +fn path_and_query(url: &[u8]) -> (&[u8], &[u8]) { + let (path, query) = match strings::index_of_char(url, b'?') { + Some(i) => (&url[..i as usize], &url[i as usize..]), + None => (url, &b""[..]), }; - let url = if !url.is_empty() && url[0] != b'/' { - let skip = if strings::has_prefix_case_insensitive(url, b"http://") { + let path = if !path.is_empty() && path[0] != b'/' { + let skip = if strings::has_prefix_case_insensitive(path, b"http://") { 7 - } else if strings::has_prefix_case_insensitive(url, b"https://") { + } else if strings::has_prefix_case_insensitive(path, b"https://") { 8 } else { 0 }; if skip > 0 { - match strings::index_of_char(&url[skip..], b'/') { - Some(i) => &url[skip + i as usize..], + match strings::index_of_char(&path[skip..], b'/') { + Some(i) => &path[skip + i as usize..], None => b"/", } } else { - url + path } } else { - url + path }; - let after_prefix = if strings::starts_with(url, url_prefix) { - &url[url_prefix.len()..] - } else if url.len() + 1 == url_prefix.len() && url == &url_prefix[..url_prefix.len() - 1] { + (path, query) +} + +/// RFC 3986 `pchar` (the bytes that may appear literally in a path segment): +/// unreserved / sub-delims / ":" / "@". `%XX` encoding one of these never +/// changes the URL's meaning, so there is no legitimate reason to send it. +#[inline] +fn is_url_path_literal(b: u8) -> bool { + b.is_ascii_alphanumeric() + || matches!( + b, + b'-' | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + ) +} + +/// Strip `url_prefix`, percent-decode once, and validate the result is a +/// canonical relative path. `None` for any input that would make the served +/// path differ from the routed path (see comment on the segment scan below). +/// Writes into `out`; returns `(len, had_trailing_slash)`. +fn resolve_subpath( + url: &[u8], + url_prefix: &[u8], + scratch: &mut [u8], + out: &mut [u8], +) -> Option<(usize, bool)> { + let (path, _query) = path_and_query(url); + let after_prefix = if strings::starts_with(path, url_prefix) { + &path[url_prefix.len()..] + } else if path.len() + 1 == url_prefix.len() && path == &url_prefix[..url_prefix.len() - 1] { b"" } else { return None; @@ -442,25 +523,48 @@ fn resolve_subpath( return None; } - let raw_slashes = after_prefix.iter().filter(|&&b| b == b'/').count(); + // uWS routed on the raw URL split on literal `/` with no decode and no + // normalization. Any transformation we apply that uWS did not creates a + // path uWS never matched, which can bypass a more-specific overlapping + // route. So reject every such transformation: `%XX` whose decoded byte is + // a `pchar` (would let `%61dmin` reach `admin/`); encoded `%2F`; and any + // non-canonical segment (empty / `.` / `..`). Route segments can only + // consist of `pchar`s on the wire, so rejecting encoded `pchar`s leaves + // percent-decoding as the identity on every byte that could influence + // routing, while still decoding `%20`, high-bit bytes, etc. + let mut raw_slashes = 0usize; + let mut i = 0usize; + while i < after_prefix.len() { + match after_prefix[i] { + b'/' => { + raw_slashes += 1; + i += 1; + } + b'%' if i + 2 < after_prefix.len() + && after_prefix[i + 1].is_ascii_hexdigit() + && after_prefix[i + 2].is_ascii_hexdigit() => + { + let b = (strings::to_ascii_hex_value(after_prefix[i + 1]) << 4) + | strings::to_ascii_hex_value(after_prefix[i + 2]); + if is_url_path_literal(b) { + return None; + } + i += 3; + } + _ => i += 1, + } + } + let decoded_len = bun_url::PercentEncoding::decode_into(&mut scratch[..after_prefix.len()], after_prefix) .ok()? as usize; let decoded = &scratch[..decoded_len]; - // uWS routed on the raw URL split on literal `/` with no decode and no - // normalization. Any transformation we apply that uWS did not creates a - // path uWS never matched, which can bypass a more-specific overlapping - // route. So: reject if percent-decoding introduced a `/` (encoded %2F), - // and require the decoded path to already be in canonical form (no empty, - // `.`, or `..` segments). A single trailing `/` is allowed and stripped. if decoded.iter().filter(|&&b| b == b'/').count() != raw_slashes { return None; } - let mut end = decoded_len; - if end > 0 && decoded[end - 1] == b'/' { - end -= 1; - } + let had_trailing_slash = decoded_len > 0 && decoded[decoded_len - 1] == b'/'; + let end = decoded_len - usize::from(had_trailing_slash); let mut seg_start = 0; let mut i = 0; while i <= end { @@ -468,7 +572,7 @@ fn resolve_subpath( let seg = &decoded[seg_start..i]; if seg.is_empty() || seg == b"." || seg == b".." { if i == 0 && end == 0 { - return Some(0); + return Some((0, had_trailing_slash)); } return None; } @@ -480,7 +584,7 @@ fn resolve_subpath( } out[..end].copy_from_slice(&decoded[..end]); - Some(end) + Some((end, had_trailing_slash)) } /// `W/"-"` (nginx/send scheme). @@ -501,63 +605,52 @@ fn extension_for_mime(path: &[u8]) -> &[u8] { mod tests { use super::*; - fn resolve(url: &[u8], prefix: &[u8]) -> Option> { + fn resolve(url: &[u8], prefix: &[u8]) -> Option<(Vec, bool)> { let mut scratch = [0u8; 4096]; let mut out = [0u8; 4096]; - resolve_subpath(url, prefix, &mut scratch, &mut out).map(|n| out[..n].to_vec()) + resolve_subpath(url, prefix, &mut scratch, &mut out).map(|(n, s)| (out[..n].to_vec(), s)) + } + fn ok(bytes: &[u8], slash: bool) -> Option<(Vec, bool)> { + Some((bytes.to_vec(), slash)) } #[test] fn resolve_basic() { + assert_eq!(resolve(b"/static/a.txt", b"/static/"), ok(b"a.txt", false)); assert_eq!( - resolve(b"/static/a.txt", b"/static/").as_deref(), - Some(&b"a.txt"[..]) - ); - assert_eq!( - resolve(b"/static/a/b.txt", b"/static/").as_deref(), - Some(&b"a/b.txt"[..]) - ); - assert_eq!(resolve(b"/a.txt", b"/").as_deref(), Some(&b"a.txt"[..])); - assert_eq!(resolve(b"/", b"/").as_deref(), Some(&b""[..])); - assert_eq!(resolve(b"/static", b"/static/").as_deref(), Some(&b""[..])); - assert_eq!(resolve(b"/static/", b"/static/").as_deref(), Some(&b""[..])); - assert_eq!( - resolve(b"/static/a.txt?v=1", b"/static/").as_deref(), - Some(&b"a.txt"[..]) + resolve(b"/static/a/b.txt", b"/static/"), + ok(b"a/b.txt", false) ); + assert_eq!(resolve(b"/a.txt", b"/"), ok(b"a.txt", false)); + assert_eq!(resolve(b"/", b"/"), ok(b"", false)); + assert_eq!(resolve(b"/static", b"/static/"), ok(b"", false)); + assert_eq!(resolve(b"/static/", b"/static/"), ok(b"", false)); assert_eq!( - resolve(b"/static?x", b"/static/").as_deref(), - Some(&b""[..]) + resolve(b"/static/a.txt?v=1", b"/static/"), + ok(b"a.txt", false) ); + assert_eq!(resolve(b"/static?x", b"/static/"), ok(b"", false)); assert_eq!( - resolve(b"http://x/static/a.txt", b"/static/").as_deref(), - Some(&b"a.txt"[..]) + resolve(b"http://x/static/a.txt", b"/static/"), + ok(b"a.txt", false) ); assert_eq!( - resolve(b"HTTP://x/static/a.txt", b"/static/").as_deref(), - Some(&b"a.txt"[..]) + resolve(b"HTTP://x/static/a.txt", b"/static/"), + ok(b"a.txt", false) ); + assert_eq!(resolve(b"http://x?q/admin/secret", b"/"), ok(b"", false)); + assert_eq!(resolve(b"http://x", b"/"), ok(b"", false)); assert_eq!( - resolve(b"http://x?q/admin/secret", b"/").as_deref(), - Some(&b""[..]) - ); - assert_eq!(resolve(b"http://x", b"/").as_deref(), Some(&b""[..])); - assert_eq!( - resolve(b"https://x:8080/static/a.txt?v=1", b"/static/").as_deref(), - Some(&b"a.txt"[..]) + resolve(b"https://x:8080/static/a.txt?v=1", b"/static/"), + ok(b"a.txt", false) ); } #[test] fn resolve_trailing_slash() { - assert_eq!( - resolve(b"/static/a/", b"/static/").as_deref(), - Some(&b"a"[..]) - ); - assert_eq!( - resolve(b"/static/a/b/", b"/static/").as_deref(), - Some(&b"a/b"[..]) - ); + assert_eq!(resolve(b"/static/a/", b"/static/"), ok(b"a", true)); + assert_eq!(resolve(b"/static/a/b/", b"/static/"), ok(b"a/b", true)); + assert_eq!(resolve(b"/static/a", b"/static/"), ok(b"a", false)); } #[test] @@ -576,8 +669,8 @@ mod tests { fn resolve_route_precedence_parity() { // These all route to the outer wildcard in uWS (which matches on raw // segments) but would reach a file under an inner prefix if we - // normalized or decoded `/`. Reject so the served path equals the - // routed path. + // normalized, decoded `/`, or decoded a pchar. Reject so the served + // path equals the routed path. assert_eq!(resolve(b"/static/a%2Fb.txt", b"/static/"), None); assert_eq!(resolve(b"/static/a%2fb.txt", b"/static/"), None); assert_eq!(resolve(b"/static//a/b.txt", b"/static/"), None); @@ -586,10 +679,33 @@ mod tests { assert_eq!(resolve(b"/static/a/./b.txt", b"/static/"), None); assert_eq!(resolve(b"/static/a/../b.txt", b"/static/"), None); assert_eq!(resolve(b"/static/a/..", b"/static/"), None); - // Legitimate percent-encoding (not `/`) still works. + // `%XX` encoding a pchar (RFC 3986) is rejected: uWS would not have + // matched the literal segment, so decoding it creates a new path. + assert_eq!(resolve(b"/static/%61dmin/x", b"/static/"), None); + assert_eq!(resolve(b"/static/admi%6E/x", b"/static/"), None); + assert_eq!(resolve(b"/static/ad%4Din/x", b"/static/"), None); + assert_eq!(resolve(b"/static/%40user/x", b"/static/"), None); + assert_eq!(resolve(b"/static/%2Ewell-known/x", b"/static/"), None); + // Legitimate percent-encoding (bytes that cannot appear literally in + // a path segment) still works. + assert_eq!( + resolve(b"/static/hello%20world.txt", b"/static/"), + ok(b"hello world.txt", false) + ); assert_eq!( - resolve(b"/static/hello%20world.txt", b"/static/").as_deref(), - Some(&b"hello world.txt"[..]) + resolve(b"/static/%C3%A9.txt", b"/static/"), + ok(b"\xC3\xA9.txt", false) ); } + + #[test] + fn slash_redirect_location() { + let mut out = [0u8; 256]; + let n = build_slash_redirect(b"/static/sub", &mut out); + assert_eq!(&out[..n], b"/static/sub/"); + let n = build_slash_redirect(b"/static/sub?v=1&x=2", &mut out); + assert_eq!(&out[..n], b"/static/sub/?v=1&x=2"); + let n = build_slash_redirect(b"http://h/static/sub?v=1", &mut out); + assert_eq!(&out[..n], b"/static/sub/?v=1"); + } } diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index 8c241babb256..76bceb8e67da 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -14,7 +14,7 @@ describe("Bun.serve() directory routes", () => { // fetch() normalizes `..` client-side, so the traversal/adversarial tests // send raw request bytes over a socket. - async function raw(path: string): Promise<{ status: number; body: string }> { + async function raw(path: string): Promise<{ status: number; headers: Record; body: string }> { const { promise, resolve } = Promise.withResolvers(); let buf = ""; const sock = await Bun.connect({ @@ -35,8 +35,14 @@ describe("Bun.serve() directory routes", () => { sock.write(`GET ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`); const full = await promise; const status = parseInt(full.slice(9, 12), 10); - const body = full.split("\r\n\r\n").slice(1).join("\r\n\r\n"); - return { status, body }; + const headEnd = full.indexOf("\r\n\r\n"); + const headers: Record = {}; + for (const line of full.slice(full.indexOf("\r\n") + 2, headEnd).split("\r\n")) { + const i = line.indexOf(":"); + if (i > 0) headers[line.slice(0, i).toLowerCase()] = line.slice(i + 1).trim(); + } + const body = full.slice(headEnd + 4); + return { status, headers, body }; } it("serves files from a directory at /*", async () => { @@ -125,9 +131,41 @@ describe("Bun.serve() directory routes", () => { expect(sub.status).toBe(200); expect(await sub.text()).toBe("

sub

"); - const subNoSlash = await fetch(`${server.url}sub`); - expect(subNoSlash.status).toBe(200); - expect(await subNoSlash.text()).toBe("

sub

"); + // Without a trailing slash the server 301-redirects to the slash form so + // the followed request re-enters routing. + const noSlash = await fetch(`${server.url}sub`, { redirect: "manual" }); + expect(noSlash.status).toBe(301); + expect(noSlash.headers.get("location")).toBe("/sub/"); + + const withQuery = await fetch(`${server.url}sub?v=1`, { redirect: "manual" }); + expect(withQuery.status).toBe(301); + expect(withQuery.headers.get("location")).toBe("/sub/?v=1"); + + const followed = await fetch(`${server.url}sub`); + expect(followed.status).toBe(200); + expect(await followed.text()).toBe("

sub

"); + }); + + it("sets Content-Type case-insensitively by extension", async () => { + using dir = tempDir("serve-dir-mime-case", { + "public/photo.JPG": Buffer.alloc(8, 0).toString("binary"), + "public/style.CSS": "body{}", + "public/app.MJS": "export{}", + }); + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const jpg = await fetch(`${server.url}photo.JPG`); + expect(jpg.status).toBe(200); + expect(jpg.headers.get("content-type")).toContain("image/jpeg"); + + const css = await fetch(`${server.url}style.CSS`); + expect(css.headers.get("content-type")).toContain("text/css"); + + const mjs = await fetch(`${server.url}app.MJS`); + expect(mjs.headers.get("content-type")).toContain("javascript"); }); describe.each(["/static/*", "/*"] as const)("mounted at %s", prefix => { @@ -483,6 +521,7 @@ describe("Bun.serve() directory routes", () => { it("cannot bypass a more-specific overlapping route via path manipulation", async () => { using dir = tempDir("serve-dir-bypass", { "public/admin/secret.txt": "SECRET", + "public/admin/index.html": "SECRET-INDEX", "public/ok.txt": "ok", }); @@ -496,20 +535,36 @@ describe("Bun.serve() directory routes", () => { // Canonical path hits the inner route. expect((await fetch(`${server.url}static/admin/secret.txt`)).status).toBe(401); + expect((await fetch(`${server.url}static/admin/`)).status).toBe(401); // Non-canonical forms that uWS routes to the outer wildcard must not - // reach public/admin/secret.txt via the directory route. + // reach public/admin/ via the directory route. for (const p of [ "/static//admin/secret.txt", "/static/./admin/secret.txt", "/static/x/../admin/secret.txt", "/static/admin%2Fsecret.txt", + // `%XX` encoding a character that can appear literally in a path + // segment: uWS sees `%61dmin` != `admin` and routes to `/static/*`. + "/static/%61dmin/secret.txt", + "/static/admi%6E/secret.txt", + "/static/ad%4Din/secret.txt", + "/static/%61dmin/", ]) { const r = await raw(p); expect(r.body).not.toContain("SECRET"); expect([401, 404]).toContain(r.status); } + // A directory hit without a trailing slash 301-redirects to the slash + // form, which re-enters routing and matches `/static/admin/*`. It must + // not serve `admin/index.html` directly. + const noSlash = await raw("/static/admin"); + expect(noSlash.body).not.toContain("SECRET"); + expect(noSlash.status).toBe(301); + expect(noSlash.headers.location).toBe("/static/admin/"); + expect((await fetch(`${server.url}static/admin`)).status).toBe(401); + // Canonical paths under the outer route still work. expect(await (await fetch(`${server.url}static/ok.txt`)).text()).toBe("ok"); }); From ff44afe71244dc6bd940aaee645f7c79e8d64fd9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:38:06 +0000 Subject: [PATCH 24/29] DirectoryRoute: reject trailing slash on regular files A request like /static/page.html/ was stripped of its trailing slash by resolve_subpath() and then served as a regular file, which (1) breaks relative-URL resolution in the served document and (2) bypasses an exact overlapping route: /static/secret.pdf/ routes past an exact "/static/secret.pdf" handler in uWS (the exact node has no child for the trailing empty segment) and reached the outer wildcard. open_subpath() now returns None when a regular file is opened with had_trailing_slash, matching nginx and npm send. Also: - build_slash_redirect: bound path.len() against the output buffer and truncate query instead of panicking when url_prefix + after_prefix exceeds PATH_MAX (resolve_subpath only bounds after_prefix). - FileRoute: inline the one-line write_status_code wrapper left over from the write_any_status extraction. - docs/types: "percent-encoded ASCII character" over-stated the guard (space is ASCII but %20 passes); reword to match is_url_path_literal. --- docs/runtime/http/routing.mdx | 2 +- packages/bun-types/serve.d.ts | 3 +- src/runtime/server/DirectoryRoute.rs | 28 +++++++++++++------ src/runtime/server/FileRoute.rs | 6 +--- .../bun/http/serve-directory-routes.test.ts | 13 +++++++++ 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/docs/runtime/http/routing.mdx b/docs/runtime/http/routing.mdx index f72b28583b96..1001a670070a 100644 --- a/docs/runtime/http/routing.mdx +++ b/docs/runtime/http/routing.mdx @@ -206,7 +206,7 @@ Bun.serve({ }); ``` -The part of the request URL after the prefix is percent-decoded once and opened relative to `dir`. Non-canonical paths (those containing `.`, `..`, empty segments, `%2F`, or a percent-encoded ASCII character) are rejected with `404`, so the served path is always the path the router matched. On Linux the open uses `openat2(RESOLVE_IN_ROOT)`, so symlinks that would escape `dir` are clamped by the kernel. +The part of the request URL after the prefix is percent-decoded once and opened relative to `dir`. Non-canonical paths (those containing `.`, `..`, empty segments, `%2F`, or a `%XX` sequence encoding a character that may appear literally in a path segment) are rejected with `404`, so the served path is always the path the router matched. On Linux the open uses `openat2(RESOLVE_IN_ROOT)`, so symlinks that would escape `dir` are clamped by the kernel. Directory routes share the response path with file routes: diff --git a/packages/bun-types/serve.d.ts b/packages/bun-types/serve.d.ts index 9f9143bb2c60..969f428c4832 100644 --- a/packages/bun-types/serve.d.ts +++ b/packages/bun-types/serve.d.ts @@ -585,7 +585,8 @@ declare module "bun" { * The route path **must** end in `/*`. The part of the request URL after * the prefix is percent-decoded once and opened relative to `dir`. * Non-canonical paths (containing `.`, `..`, empty segments, `%2F`, or a - * percent-encoded ASCII character) are rejected with `404` so the served + * `%XX` sequence encoding a character that may appear literally in a path + * segment) are rejected with `404` so the served * path is always the path the router matched. On Linux the open uses * `openat2(RESOLVE_IN_ROOT)`, so symlinks that would escape `dir` are * clamped by the kernel. diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index aaa0033d49d2..983eb95358e8 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -155,6 +155,10 @@ impl DirectoryRoute { Some(Subpath::RedirectSlash) => { let mut loc = bun_paths::path_buffer_pool::get(); let n = build_slash_redirect(req.url(), &mut loc.0[..]); + if n == 0 { + write_miss(&mut req, resp); + return; + } req.set_yield(false); write_any_status(resp, 301); resp.write_mark(); @@ -306,7 +310,9 @@ impl DirectoryRoute { return bun_sys::S::ISREG(s.st_mode as bun_sys::Mode) .then_some(Subpath::File(f, s, true)); } - bun_sys::S::ISREG(mode).then_some(Subpath::File(file, stat, false)) + // Trailing slash on a regular file is a miss (nginx, npm `send`): + // `/file/` would route past an exact `/file` handler in uWS. + (bun_sys::S::ISREG(mode) && !had_trailing_slash).then_some(Subpath::File(file, stat, false)) } /// `openat2(RESOLVE_IN_ROOT|NO_MAGICLINKS)` on Linux, `openat` elsewhere. @@ -427,17 +433,14 @@ fn write_miss(req: &mut AnyRequest, resp: AnyResponse) { fn build_slash_redirect(url: &[u8], out: &mut [u8]) -> usize { let (path, query) = path_and_query(url); debug_assert!(path.first() == Some(&b'/') && path.get(1) != Some(&b'/')); - let n = path.len() + 1 + query.len(); - if n > out.len() { - // Oversized query: redirect without it rather than fail the request. - out[..path.len()].copy_from_slice(path); - out[path.len()] = b'/'; - return path.len() + 1; + if path.len() >= out.len() { + return 0; } out[..path.len()].copy_from_slice(path); out[path.len()] = b'/'; - out[path.len() + 1..n].copy_from_slice(query); - n + let q = query.len().min(out.len() - path.len() - 1); + out[path.len() + 1..path.len() + 1 + q].copy_from_slice(&query[..q]); + path.len() + 1 + q } /// Split a raw request-target (uWS `getFullUrl()`) into `(path, query)`. @@ -707,5 +710,12 @@ mod tests { assert_eq!(&out[..n], b"/static/sub/?v=1&x=2"); let n = build_slash_redirect(b"http://h/static/sub?v=1", &mut out); assert_eq!(&out[..n], b"/static/sub/?v=1"); + // Path alone does not fit: bail rather than panic. + let mut small = [0u8; 8]; + assert_eq!(build_slash_redirect(b"/static/sub", &mut small), 0); + // Query truncated to fit. + let mut small = [0u8; 14]; + let n = build_slash_redirect(b"/static/sub?verylongquery", &mut small); + assert_eq!(&small[..n], b"/static/sub/?v"); } } diff --git a/src/runtime/server/FileRoute.rs b/src/runtime/server/FileRoute.rs index 4994b66e35e7..f11b96079d8b 100644 --- a/src/runtime/server/FileRoute.rs +++ b/src/runtime/server/FileRoute.rs @@ -273,10 +273,6 @@ impl FileRoute { } } - fn write_status_code(&self, status: u16, resp: AnyResponse) { - write_any_status(resp, status); - } - /// # Safety /// `this` must point to a live heap `FileRoute` (intrusive ref held by the /// route table) for the duration of the call. @@ -451,7 +447,7 @@ impl FileRoute { req.set_yield(false); - this.write_status_code(status_code, resp); + write_any_status(resp, status_code); if this.has_date_header { resp.mark_wrote_date_header(); } diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index 76bceb8e67da..a3c035e6ac67 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -144,6 +144,10 @@ describe("Bun.serve() directory routes", () => { const followed = await fetch(`${server.url}sub`); expect(followed.status).toBe(200); expect(await followed.text()).toBe("

sub

"); + + // A trailing slash on a regular file is a miss (nginx/send behavior). + const fileSlash = await fetch(`${server.url}index.html/`, { redirect: "manual" }); + expect(fileSlash.status).toBe(404); }); it("sets Content-Type case-insensitively by extension", async () => { @@ -522,6 +526,7 @@ describe("Bun.serve() directory routes", () => { using dir = tempDir("serve-dir-bypass", { "public/admin/secret.txt": "SECRET", "public/admin/index.html": "SECRET-INDEX", + "public/secret.pdf": "SECRET-PDF", "public/ok.txt": "ok", }); @@ -529,6 +534,7 @@ describe("Bun.serve() directory routes", () => { port: 0, routes: { "/static/admin/*": () => new Response("auth", { status: 401 }), + "/static/secret.pdf": () => new Response("auth", { status: 401 }), "/static/*": { dir: join(String(dir), "public") }, }, }); @@ -536,6 +542,7 @@ describe("Bun.serve() directory routes", () => { // Canonical path hits the inner route. expect((await fetch(`${server.url}static/admin/secret.txt`)).status).toBe(401); expect((await fetch(`${server.url}static/admin/`)).status).toBe(401); + expect((await fetch(`${server.url}static/secret.pdf`)).status).toBe(401); // Non-canonical forms that uWS routes to the outer wildcard must not // reach public/admin/ via the directory route. @@ -565,6 +572,12 @@ describe("Bun.serve() directory routes", () => { expect(noSlash.headers.location).toBe("/static/admin/"); expect((await fetch(`${server.url}static/admin`)).status).toBe(401); + // A trailing slash on a regular file routes past the exact `/static/secret.pdf` + // handler in uWS; the directory route must not strip the slash and serve it. + const fileSlash = await raw("/static/secret.pdf/"); + expect(fileSlash.body).not.toContain("SECRET"); + expect(fileSlash.status).toBe(404); + // Canonical paths under the outer route still work. expect(await (await fetch(`${server.url}static/ok.txt`)).text()).toBe("ok"); }); From d4e5ad8820c01685a8e2771844145640f14c3919 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:15:51 +0000 Subject: [PATCH 25/29] DirectoryRoute: drop scratch buffer; reject // in route path - resolve_subpath: decode directly into out and drop the separate scratch buffer + trailing copy_from_slice. The two-buffer shape existed for normalize_string_buf (removed in c1804c66); now validation runs in place, so the extra pool round-trip and memcpy per request were dead weight. - Reject empty segments (//) in the directory route path at config time. build_slash_redirect relies on url_prefix having no empty first segment so Location cannot be //host/ (protocol-relative), and the segment-scan guard in resolve_subpath only validates bytes after the prefix. A "//*" route key was accepted and would emit Location: //foo/ on a directory redirect; now it throws at serve(). --- src/runtime/server/DirectoryRoute.rs | 30 ++++++------------- src/runtime/server/server_body.rs | 5 ++++ .../bun/http/serve-directory-routes.test.ts | 12 ++++++++ 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 983eb95358e8..38289aeb8fe3 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -63,6 +63,7 @@ impl DirectoryRoute { enable_stat_cache: bool, ) -> JsResult<*mut DirectoryRoute> { debug_assert!(url_prefix.last() == Some(&b'/')); + debug_assert!(!strings::contains(url_prefix, b"//")); let root_fd = match bun_sys::open_a( root, @@ -135,19 +136,14 @@ impl DirectoryRoute { resp.timeout(server.config().idle_timeout); } - let mut decode_buf = bun_paths::path_buffer_pool::get(); let mut path_buf = bun_paths::path_buffer_pool::get(); - let Some((rel_len, had_trailing_slash)) = resolve_subpath( - req.url(), - &this.url_prefix, - &mut decode_buf.0[..], - &mut path_buf.0[..], - ) else { + let Some((rel_len, had_trailing_slash)) = + resolve_subpath(req.url(), &this.url_prefix, &mut path_buf.0[..]) + else { bun_output::scoped_log!(DirectoryRoute, "reject {}", bstr::BStr::new(req.url())); write_miss(&mut req, resp); return; }; - drop(decode_buf); let rel: &[u8] = &path_buf.0[..rel_len]; let (file, stat, is_index) = match this.open_subpath(rel, had_trailing_slash) { @@ -505,12 +501,7 @@ fn is_url_path_literal(b: u8) -> bool { /// canonical relative path. `None` for any input that would make the served /// path differ from the routed path (see comment on the segment scan below). /// Writes into `out`; returns `(len, had_trailing_slash)`. -fn resolve_subpath( - url: &[u8], - url_prefix: &[u8], - scratch: &mut [u8], - out: &mut [u8], -) -> Option<(usize, bool)> { +fn resolve_subpath(url: &[u8], url_prefix: &[u8], out: &mut [u8]) -> Option<(usize, bool)> { let (path, _query) = path_and_query(url); let after_prefix = if strings::starts_with(path, url_prefix) { &path[url_prefix.len()..] @@ -559,9 +550,9 @@ fn resolve_subpath( } let decoded_len = - bun_url::PercentEncoding::decode_into(&mut scratch[..after_prefix.len()], after_prefix) - .ok()? as usize; - let decoded = &scratch[..decoded_len]; + bun_url::PercentEncoding::decode_into(&mut out[..after_prefix.len()], after_prefix).ok()? + as usize; + let decoded = &out[..decoded_len]; if decoded.iter().filter(|&&b| b == b'/').count() != raw_slashes { return None; @@ -585,8 +576,6 @@ fn resolve_subpath( } i += 1; } - - out[..end].copy_from_slice(&decoded[..end]); Some((end, had_trailing_slash)) } @@ -609,9 +598,8 @@ mod tests { use super::*; fn resolve(url: &[u8], prefix: &[u8]) -> Option<(Vec, bool)> { - let mut scratch = [0u8; 4096]; let mut out = [0u8; 4096]; - resolve_subpath(url, prefix, &mut scratch, &mut out).map(|(n, s)| (out[..n].to_vec(), s)) + resolve_subpath(url, prefix, &mut out).map(|(n, s)| (out[..n].to_vec(), s)) } fn ok(bytes: &[u8], slash: bool) -> Option<(Vec, bool)> { Some((bytes.to_vec(), slash)) diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 30fc558189f9..a05687551be7 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -785,6 +785,11 @@ impl AnyRoute { "Directory routes do not support :parameters; use a fixed prefix ending in `/*`" ))); } + if strings::contains(path, b"//") { + return Err(global.throw_invalid_arguments(format_args!( + "Directory route paths cannot contain empty segments" + ))); + } // `{ dir }` without `style` serves the directory tree // verbatim; `{ dir, style }` opts into framework routing. let url_prefix: &[u8] = if path.len() == 2 { diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index a3c035e6ac67..8a3700ca5c46 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -445,6 +445,18 @@ describe("Bun.serve() directory routes", () => { ).toThrow(/do not support :parameters/); }); + it("rejects empty segments in the route path", () => { + using dir = tempDir("serve-dir-empty-seg", { "public/x.txt": "x" }); + for (const key of ["//*", "//assets/*", "/a//*"]) { + expect(() => + serve({ + port: 0, + routes: { [key]: { dir: join(String(dir), "public") } }, + }), + ).toThrow(/empty segments/); + } + }); + it("throws if the directory does not exist", () => { using dir = tempDir("serve-dir-enoent", {}); expect(() => From 5276989b856c45ecb359ec1f2633c6052627d39b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:56:10 +0000 Subject: [PATCH 26/29] docs: note case-insensitive filesystem caveat for directory routes Routing is case-sensitive but macOS APFS and Windows NTFS are not, so a case-varied URL routes to the directory wildcard and the filesystem case-folds the open. This matches nginx/Caddy/Go/express behavior and resolve_subpath applies no transformation here (the filesystem does), so no code change; but the docs claimed "the served path is always the path the router matched" without qualification, which is misleading. Add a callout to routing.mdx and a sentence to the DirectoryRouteOptions JSDoc advising not to gate content inside dir via overlapping routes. --- docs/runtime/http/routing.mdx | 4 ++++ packages/bun-types/serve.d.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/runtime/http/routing.mdx b/docs/runtime/http/routing.mdx index 1001a670070a..36295e669f1e 100644 --- a/docs/runtime/http/routing.mdx +++ b/docs/runtime/http/routing.mdx @@ -208,6 +208,10 @@ Bun.serve({ The part of the request URL after the prefix is percent-decoded once and opened relative to `dir`. Non-canonical paths (those containing `.`, `..`, empty segments, `%2F`, or a `%XX` sequence encoding a character that may appear literally in a path segment) are rejected with `404`, so the served path is always the path the router matched. On Linux the open uses `openat2(RESOLVE_IN_ROOT)`, so symlinks that would escape `dir` are clamped by the kernel. +{% callout %} +Routing is case-sensitive but filesystems on macOS and Windows are case-insensitive by default, so a case-varied URL (`/static/Admin/secret.txt`) will route to the directory wildcard rather than a sibling `/static/admin/*` handler and still open `admin/secret.txt`. As with nginx, Caddy, and other static file servers, do not place access-controlled content inside `dir` and rely on an overlapping route to gate it. +{% /callout %} + Directory routes share the response path with file routes: - **Content-Type** is set from the file extension. diff --git a/packages/bun-types/serve.d.ts b/packages/bun-types/serve.d.ts index 969f428c4832..fee1ee6e0f2a 100644 --- a/packages/bun-types/serve.d.ts +++ b/packages/bun-types/serve.d.ts @@ -586,10 +586,12 @@ declare module "bun" { * the prefix is percent-decoded once and opened relative to `dir`. * Non-canonical paths (containing `.`, `..`, empty segments, `%2F`, or a * `%XX` sequence encoding a character that may appear literally in a path - * segment) are rejected with `404` so the served - * path is always the path the router matched. On Linux the open uses - * `openat2(RESOLVE_IN_ROOT)`, so symlinks that would escape `dir` are - * clamped by the kernel. + * segment) are rejected with `404` so the served path is always the path + * the router matched. On Linux the open uses `openat2(RESOLVE_IN_ROOT)`, + * so symlinks that would escape `dir` are clamped by the kernel. Routing + * is case-sensitive but filesystems on macOS and Windows are not by + * default: do not place access-controlled content inside `dir` and rely + * on an overlapping route to gate it. * * Responses carry `Content-Type` (from the file extension), * `Last-Modified`, a weak `ETag`, and support single-range `Range` From a28e6c906b7b589cb51ed8f3684a40715bcc17f1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:52:38 +0000 Subject: [PATCH 27/29] DirectoryRoute: reject lone-slash suffix; skip alt-svc on H3 - resolve_subpath: the mount-root special case (i==0 && end==0) also fired for after_prefix == b"/" (decoded_len=1, trailing-slash strip leaves end=0), so /static// served index.html while /static//a was rejected. Hoist the mount-root return to an explicit decoded_len==0 check before the segment scan; the scan then rejects every empty segment unconditionally. - Skip alt-svc on H3 responses to match FileRoute/StaticRoute/ RequestContext (the header advertises H3 to H1/H2 clients; writing it on an H3 response is harmless per RFC 7838 but redundant). --- src/runtime/server/DirectoryRoute.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 38289aeb8fe3..15e07c85aa4d 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -210,9 +210,11 @@ impl DirectoryRoute { resp.write_header(b"last-modified", lm); } resp.write_header(b"etag", etag); - if let Some(srv) = this.server.get() { - if let Some(alt) = srv.h3_alt_svc() { - resp.write_header(b"alt-svc", alt); + if !matches!(resp, AnyResponse::H3(_)) { + if let Some(srv) = this.server.get() { + if let Some(alt) = srv.h3_alt_svc() { + resp.write_header(b"alt-svc", alt); + } } } @@ -557,7 +559,10 @@ fn resolve_subpath(url: &[u8], url_prefix: &[u8], out: &mut [u8]) -> Option<(usi if decoded.iter().filter(|&&b| b == b'/').count() != raw_slashes { return None; } - let had_trailing_slash = decoded_len > 0 && decoded[decoded_len - 1] == b'/'; + if decoded_len == 0 { + return Some((0, false)); + } + let had_trailing_slash = decoded[decoded_len - 1] == b'/'; let end = decoded_len - usize::from(had_trailing_slash); let mut seg_start = 0; let mut i = 0; @@ -565,9 +570,6 @@ fn resolve_subpath(url: &[u8], url_prefix: &[u8], out: &mut [u8]) -> Option<(usi if i == end || decoded[i] == b'/' { let seg = &decoded[seg_start..i]; if seg.is_empty() || seg == b"." || seg == b".." { - if i == 0 && end == 0 { - return Some((0, had_trailing_slash)); - } return None; } seg_start = i + 1; @@ -666,6 +668,8 @@ mod tests { assert_eq!(resolve(b"/static/a%2fb.txt", b"/static/"), None); assert_eq!(resolve(b"/static//a/b.txt", b"/static/"), None); assert_eq!(resolve(b"/static/a//b.txt", b"/static/"), None); + assert_eq!(resolve(b"/static//", b"/static/"), None); + assert_eq!(resolve(b"//", b"/"), None); assert_eq!(resolve(b"/static/./a.txt", b"/static/"), None); assert_eq!(resolve(b"/static/a/./b.txt", b"/static/"), None); assert_eq!(resolve(b"/static/a/../b.txt", b"/static/"), None); From f4bebf548a8964b1b6ac9edfd9dcfbee33ab76a0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:10:32 +0000 Subject: [PATCH 28/29] test: reduce stat-cache exhaustion batch size to 32 128 concurrent connects per batch occasionally hit ConnectionRefused on Windows aarch64 (listen backlog). The test exercises cache eviction across >256 paths, not connection concurrency (the separate "concurrent burst" test covers that), so 32 is sufficient. --- test/js/bun/http/serve-directory-routes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index 8a3700ca5c46..1e82702cb48d 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -481,7 +481,7 @@ describe("Bun.serve() directory routes", () => { // Walk the full set twice so every slot is guaranteed to have been // evicted and reused between the two visits to any given path. for (let pass = 0; pass < 2; pass++) { - const batch = 128; + const batch = 32; for (let base = 0; base < N; base += batch) { const chunk = Array.from({ length: Math.min(batch, N - base) }, (_, j) => base + j); const bodies = await Promise.all(chunk.map(i => fetch(`${server!.url}f${i}.txt`).then(r => r.text()))); From 72cd1b58425f28264ea0be18dc920110ced7385b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:50:58 +0000 Subject: [PATCH 29/29] DirectoryRoute: drop redundant Content-Length on 301 redirect write_header_int(b"content-length", 0) wrote the header but did not set HTTP_WROTE_CONTENT_LENGTH_HEADER, so end(b"") wrote a second one. Let end() emit it once like write_miss and the 412 arm do. The raw() test helper now returns the header block so the redirect test asserts exactly one Content-Length header. --- src/runtime/server/DirectoryRoute.rs | 1 - test/js/bun/http/serve-directory-routes.test.ts | 11 ++++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 15e07c85aa4d..79977e1f11ca 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -159,7 +159,6 @@ impl DirectoryRoute { write_any_status(resp, 301); resp.write_mark(); resp.write_header(b"location", &loc.0[..n]); - resp.write_header_int(b"content-length", 0); resp.end(b"", resp.should_close_connection()); return; } diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index 1e82702cb48d..14d0fd41a18a 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -14,7 +14,9 @@ describe("Bun.serve() directory routes", () => { // fetch() normalizes `..` client-side, so the traversal/adversarial tests // send raw request bytes over a socket. - async function raw(path: string): Promise<{ status: number; headers: Record; body: string }> { + async function raw( + path: string, + ): Promise<{ status: number; headers: Record; head: string; body: string }> { const { promise, resolve } = Promise.withResolvers(); let buf = ""; const sock = await Bun.connect({ @@ -36,13 +38,14 @@ describe("Bun.serve() directory routes", () => { const full = await promise; const status = parseInt(full.slice(9, 12), 10); const headEnd = full.indexOf("\r\n\r\n"); + const head = full.slice(0, headEnd); const headers: Record = {}; - for (const line of full.slice(full.indexOf("\r\n") + 2, headEnd).split("\r\n")) { + for (const line of head.slice(head.indexOf("\r\n") + 2).split("\r\n")) { const i = line.indexOf(":"); if (i > 0) headers[line.slice(0, i).toLowerCase()] = line.slice(i + 1).trim(); } const body = full.slice(headEnd + 4); - return { status, headers, body }; + return { status, headers, head, body }; } it("serves files from a directory at /*", async () => { @@ -582,6 +585,8 @@ describe("Bun.serve() directory routes", () => { expect(noSlash.body).not.toContain("SECRET"); expect(noSlash.status).toBe(301); expect(noSlash.headers.location).toBe("/static/admin/"); + // Exactly one Content-Length header (strict intermediaries reject duplicates). + expect(noSlash.head.toLowerCase().match(/^content-length:/gm)?.length).toBe(1); expect((await fetch(`${server.url}static/admin`)).status).toBe(401); // A trailing slash on a regular file routes past the exact `/static/secret.pdf`