Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 44 additions & 57 deletions src/bun_core/ip_address.rs
Original file line number Diff line number Diff line change
@@ -1,91 +1,78 @@
//! IP-address parsing shared by URL/host handling and the DNS backends.

use core::net::{IpAddr, Ipv4Addr, Ipv6Addr};
#[cfg(not(windows))]
use core::net::Ipv4Addr;
use core::net::{IpAddr, Ipv6Addr};

use sys::{AF_INET, AF_INET6, pton};

/// The one place this module touches C: the vendored c-ares `inet_pton`, which is pure C with no preconditions — unlike ws2_32's, which fails with WSANOTINITIALISED whenever it runs before `WSAStartup()`, as URL/host parsing can.
/// The one place this module touches C.
#[cfg(not(windows))]
mod sys {
use core::ffi::{c_char, c_int, c_void};

// A thin libc/ws2def passthrough: bun_string sits below bun_sys, so `bun_sys::posix::AF` is out of reach.
pub(super) const AF_INET: c_int = 2;
#[cfg(not(windows))]
pub(super) const AF_INET6: c_int = libc::AF_INET6 as c_int;
#[cfg(windows)]
pub(super) const AF_INET6: c_int = 23; // ws2def.h

unsafe extern "C" {
fn ares_inet_pton(af: c_int, src: *const c_char, dst: *mut c_void) -> c_int;
#[cfg(not(windows))]
fn inet_aton(cp: *const c_char, addr: *mut c_void) -> c_int;
}

/// BSD `inet_aton(3)`, which unlike `inet_pton` takes the shorthand forms (`127.1`, `0x7f000001`) that `getaddrinfo` accepts; Windows has no equivalent, so it parses strictly there.
/// BSD `inet_aton(3)`, which unlike `inet_pton` takes the shorthand forms (`127.1`, `0x7f000001`) that `getaddrinfo` accepts.
pub(super) fn aton(src: &[u8], dst: &mut [u8; 4]) -> bool {
#[cfg(windows)]
{
pton(AF_INET, src, dst)
}
#[cfg(not(windows))]
{
debug_assert_eq!(src.last(), Some(&0));
// SAFETY: `src` is NUL-terminated per the assert; `dst` is an `in_addr`.
unsafe { inet_aton(src.as_ptr().cast(), dst.as_mut_ptr().cast()) != 0 }
}
}

/// Safe wrapper: `src` is NUL-terminated by construction and `dst` is sized for the family.
pub(super) fn pton(af: c_int, src: &[u8], dst: &mut [u8]) -> bool {
debug_assert_eq!(src.last(), Some(&0));
debug_assert!(dst.len() >= if af == AF_INET6 { 16 } else { 4 });
// SAFETY: per the asserts above.
unsafe { ares_inet_pton(af, src.as_ptr().cast(), dst.as_mut_ptr().cast()) > 0 }
// SAFETY: `src` is NUL-terminated per the assert; `dst` is an `in_addr`.
unsafe { inet_aton(src.as_ptr().cast(), dst.as_mut_ptr().cast()) != 0 }
}
}

/// Whether `input` is an IP literal: `net.isIP` without a `%zone`.
pub fn is_ip_address(input: &[u8]) -> bool {
let mut buf = [0u8; 512];
if input.len() >= buf.len() {
return false;
}
buf[..input.len()].copy_from_slice(input);
let mut dst = [0u8; 28];
pton(AF_INET, &buf[..=input.len()], &mut dst) || pton(AF_INET6, &buf[..=input.len()], &mut dst)
parse_strict(input).is_some()
Comment thread
robobun marked this conversation as resolved.
Resolved
}

/// A strict parse, never a `contains(':')` heuristic — that mis-bracketed Windows paths like `C:/Windows/Temp/…` as `unix://[C:/…]`.
pub fn is_ipv6_address(input: &[u8]) -> bool {
let mut buf = [0u8; 512];
if input.len() >= buf.len() {
return false;
}
buf[..input.len()].copy_from_slice(input);
let mut dst = [0u8; 28];
pton(AF_INET6, &buf[..=input.len()], &mut dst)
parse_strict_v6(input).is_some()
}

/// A dotted quad or an IPv6 address and nothing else, the same on every platform. This is `core::net`'s parser and not `ares_inet_pton`, which is `inet_net_pton` underneath: it also takes `10` (as 10.0.0.0), `127.1` (as 127.1.0.0), `0x7f000001`, zero-padded octets, a trailing `/bits`, and stops at a NUL. Use this when the parsed value is compared, not just classified.
/// A dotted quad or an IPv6 address and nothing else, the same on every platform. This is `core::net`'s parser and not `ares_inet_pton`, which is `inet_net_pton` underneath: it also takes `10` (as 10.0.0.0), `127.1` (as 127.1.0.0), `0x7f000001`, zero-padded octets, a trailing `/bits`, and stops at a NUL.
#[inline(never)]
pub fn parse_strict(input: &[u8]) -> Option<IpAddr> {
// The longest literal is "ffff:ffff:ffff:ffff:ffff:ffff:255.255.255.255", so a longer host is never scanned.
if input.len() > 45 {
return None;
}
crate::fmt::parse_ascii::<IpAddr>(input)
}

/// Parses what the platform resolver treats as a numeric host: dotted-quad, IPv6 (an optional `%zone` is stripped, not validated), and the `inet_aton` shorthand `getaddrinfo` accepts but `is_ip_address` rejects (`127.1`, `2130706433`, `0x7f000001`, `0177.0.0.1`).
pub fn to_ip_address(input: &[u8]) -> Option<IpAddr> {
let mut buf = [0u8; 512];
if input.is_empty() || input.len() >= buf.len() {
/// The IPv6 half of [`parse_strict`], for a caller that has no use for a dotted quad.
fn parse_strict_v6(input: &[u8]) -> Option<Ipv6Addr> {
if input.len() > 45 {
return None;
}
crate::fmt::parse_ascii::<Ipv6Addr>(input)
}

/// Parses what the platform resolver treats as a numeric host: dotted-quad, IPv6 (an optional `%zone` is stripped, not validated), and the `inet_aton` shorthand `getaddrinfo` accepts but `is_ip_address` rejects (`127.1`, `2130706433`, `0x7f000001`, `0177.0.0.1`). The Windows resolver reads no shorthand.
pub fn to_ip_address(input: &[u8]) -> Option<IpAddr> {
// A `%zone` suffix belongs to a numeric v6 host; resolving the zone is the caller's business.
let head = crate::strings::index_of_char_usize(input, b'%').unwrap_or(input.len());
buf[..head].copy_from_slice(&input[..head]);
let mut v6 = [0u8; 16];
if pton(AF_INET6, &buf[..=head], &mut v6) {
return Some(IpAddr::V6(Ipv6Addr::from(v6)));
if let Some(v6) = parse_strict_v6(&input[..head]) {
return Some(IpAddr::V6(v6));
}
#[cfg(windows)]
{
parse_strict(input)
}
#[cfg(not(windows))]
{
let mut buf = [0u8; 512];
if input.is_empty() || input.len() >= buf.len() {
return None;
}
buf[..input.len()].copy_from_slice(input);
let mut v4 = [0u8; 4];
// `inet_aton` stops at whitespace or a NUL and reads what comes before.
(sys::aton(&buf[..=input.len()], &mut v4)
&& crate::strings::index_of_any(input, b" \t\n\r\x0b\x0c\0").is_none())
.then(|| IpAddr::V4(Ipv4Addr::from(v4)))
}
buf[..input.len()].copy_from_slice(input);
let mut v4 = [0u8; 4];
sys::aton(&buf[..=input.len()], &mut v4).then(|| IpAddr::V4(Ipv4Addr::from(v4)))
}

/// A host as URLs and `host:port` strings write it keeps an IPv6 literal's
Expand Down
7 changes: 6 additions & 1 deletion src/runtime/bake/DevServer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1383,7 +1383,12 @@ pub(crate) fn is_allowed_host_header(
{
return true;
}
if bun_core::ip_address::is_ip_address(bun_core::ip_address::strip_ipv6_brackets(host)) {
// A host that the resolver reads as a number is never looked up, so DNS cannot rebind it.
let numeric = bun_core::ip_address::strip_ipv6_brackets(host);
if bun_core::ip_address::is_ip_address(numeric)
|| (numeric.first().is_some_and(u8::is_ascii_digit)
&& bun_core::ip_address::to_ip_address(numeric).is_some_and(|ip| ip.is_ipv4()))
{
return true;
}
if let Some(crate::server::server_config::Address::Tcp {
Expand Down
53 changes: 53 additions & 0 deletions test/bake/dev/esm.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// ESM tests are about various esm features in development mode.
import { expect } from "bun:test";
import { isWindows } from "harness";
import { devTest, emptyHtmlFile, minimalFramework } from "../bake-harness";

const liveBindingTest = devTest("live bindings with `var`", {
Expand Down Expand Up @@ -565,3 +566,55 @@ devTest("html routes reject requests whose host header does not match the dev se
expect(normal.status).toBe(200);
},
});

// A host that the resolver reads as a number passes the check, because DNS
// cannot rebind it. Outside Windows that covers the IPv4 shorthand, which wget
// and Python send as typed. ares_inet_pton also took an IPv6 text with "/bits"
// or with a shortened IPv4 part, and no resolver reads those.
devTest("html routes accept a numeric host header and reject one that no resolver reads", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
console.log("loaded");
`,
},
async test(dev) {
const statuses = async (hosts: string[]) =>
Object.fromEntries(
await Promise.all(
hosts.map(async host => {
const res = await dev.fetch("/", { headers: { Host: host } });
await res.text();
return [host, res.status];
}),
),
);

const numeric = [
"127.0.0.1",
"127.0.0.1:3000",
"10.0.0.1",
"[::1]",
"[::1]:3000",
"[::ffff:127.0.0.1]",
// The longest text of an address, 45 bytes.
"[ffff:ffff:ffff:ffff:ffff:ffff:255.255.255.255]:3000",
];
expect(await statuses(numeric)).toEqual(Object.fromEntries(numeric.map(host => [host, 200])));

const shorthand = ["127.1", "127.1:3000", "10", "0x7f000001", "127.000.000.001", "1.2.3"];
expect(await statuses(shorthand)).toEqual(Object.fromEntries(shorthand.map(host => [host, isWindows ? 403 : 200])));

// inet_aton stops at whitespace, and the check does not.
const unread = [
"[::1/64]",
"[::1/128]:3000",
"[::ffff:127.1]",
"127.0.0.1 rebound-host.example",
"rebound-host.example",
];
expect(await statuses(unread)).toEqual(Object.fromEntries(unread.map(host => [host, 403])));
},
});
88 changes: 88 additions & 0 deletions test/js/bun/http/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,35 @@ test("HTTPS proxy tunnel keeps a caller-supplied Host header out of SNI and cert
}
});

// As on a direct connection, only an IP address in the strict form of net.isIP
// gets no SNI inside the tunnel. ares_inet_pton also reads "127.1" (as
// 127.1.0.0), "10" and a trailing "/bits" as an address.
test("HTTPS proxy tunnel sends a tls.serverName that is IP shorthand as SNI", async () => {
const seen: (string | null)[] = [];
const target = tls.createServer(tlsCert, socket => {
socket.on("error", () => {});
seen.push(socket.servername || null);
socket.once("data", () => socket.end("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"));
});
target.listen(0);
await once(target, "listening");
try {
const port = (target.address() as net.AddressInfo).port;
const serverNames = ["127.1", "10", "0x7f000001", "1.2.3.4/8", "127.0.0.1", "::1"];
for (const serverName of serverNames) {
const res = await fetch(`https://localhost:${port}/`, {
proxy: httpProxyServer.url,
keepalive: false,
tls: { serverName, rejectUnauthorized: false },
});
expect(`${res.status} ${await res.text()}`).toBe("200 ok");
}
expect(seen).toEqual(["127.1", "10", "0x7f000001", "1.2.3.4/8", null, null]);
} finally {
target.close();
}
});

test("HTTPS proxy tunnel keep-alive does not share tunnel across different credentials", async () => {
using target = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response("ok") });

Expand Down Expand Up @@ -2556,6 +2585,65 @@ describe.concurrent("proxy environment", () => {
return parsed;
}

// The host of an https:// proxy from the environment reaches TLS as typed,
// not through a URL parser. In IP shorthand it is a name: it goes out as
// SNI, and it is not the address in the certificate of the proxy. The
// Windows resolver does not read the shorthand, so a proxy at such a host
// needs a lookup there.
test.each([
["127.0.0.1", "proxy", null],
...(isWindows
? []
: ["0x7f000001", "127.000.000.001"].map(host => [host, "ERR_TLS_CERT_ALTNAME_INVALID", host] as const)),
])("an https:// proxy at %j", async (host, result, sni) => {
let name: string | null = null;
const proxy = tls.createServer(
{
...tlsCert,
SNICallback(servername, cb) {
name = servername;
cb(null, tls.createSecureContext(tlsCert));
},
},
socket => {
socket.on("error", () => {});
socket.once("data", () => socket.end("HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nproxy"));
},
);
proxy.listen(0, "127.0.0.1");
await once(proxy, "listening");
try {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const tls = { ca: process.env.PROXY_CA };
const result = await fetch("http://example.invalid/", { keepalive: false, tls }).then(
r => r.text(),
e => e.code,
);
console.log(JSON.stringify(result));
`,
],
env: {
...bunEnv,
...proxyFreeEnv,
HTTP_PROXY: `https://${host}:${(proxy.address() as net.AddressInfo).port}`,
PROXY_CA: tlsCert.cert,
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect({ result: JSON.parse(stdout), sni: name }).toEqual({ result, sni });
expect(exitCode).toBe(0);
} finally {
proxy.close();
}
});

test("NO_PROXY grammar", async () => {
// The full grammar is in "proxy resolution" above; this is the path from
// the variable to a connection.
Expand Down
Loading
Loading