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
135 changes: 132 additions & 3 deletions crates/perry-ext-http-server/src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,60 @@ use crate::types::{

pub type ResponseBody = BoxBody<Bytes, Infallible>;

// ------------------------------------------------------------------
// #4907 — Node-compatible header / argument validation.
//
// `res.setHeader` / `res.removeHeader` throw after headers are sent, and
// `setHeader` rejects non-token field names. `res.writeEarlyHints` validates
// its `hints` argument. Each throws an `ERR_*`-coded error that unwinds back
// to the JS handler frame.
// ------------------------------------------------------------------

/// Node HTTP token bytes (`tchar`, mirrored from `lib/_http_common.js`).
fn http_is_token_byte(b: u8) -> bool {
matches!(b,
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9'
| b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*'
| b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~')
}

fn http_is_valid_token(s: &str) -> bool {
!s.is_empty() && s.bytes().all(http_is_token_byte)
}

/// Returns whether the response's headers have already been flushed. A throw
/// must fire even when the handle has gone away, so callers check this before
/// touching state.
fn response_headers_sent(handle: i64) -> bool {
get_handle::<ServerResponse>(handle)
.map(|sr| sr.headers_sent)
.unwrap_or(false)
}

/// `lib/internal/http.js` link-header format: a `<uri>` followed by at least
/// one `;`-separated parameter. Faithful enough for the invalid cases the
/// corpus exercises (`'</>; '`, `'rel=preload; </scripts.js>'`,
/// `'invalid string'`).
fn is_valid_link_header(value: &str) -> bool {
let s = value.trim();
if !s.starts_with('<') {
return false;
}
let close = match s.find('>') {
Some(i) => i,
None => return false,
};
let rest = s[close + 1..].trim_start();
if !rest.starts_with(';') {
return false;
}
let params = rest[1..].trim();
if params.is_empty() {
return false;
}
params.split(';').all(|p| !p.trim().is_empty())
}

struct TrailerBody {
body: Option<Bytes>,
trailers: Option<HeaderMap>,
Expand Down Expand Up @@ -356,9 +410,25 @@ pub unsafe extern "C" fn js_node_http_res_set_header(
value: f64,
) {
let name = read_string_header(name_ptr as *mut _).unwrap_or_default();
// #4907 — Node's `OutgoingMessage.setHeader` throws if headers are already
// sent, then validates the field name, before touching any state.
if response_headers_sent(handle) {
perry_ffi::throw_with_code(
"Cannot set headers after they are sent to the client",
"ERR_HTTP_HEADERS_SENT",
perry_ffi::ErrorKind::Error,
);
}
if name.is_empty() {
return;
}
if !http_is_valid_token(&name) {
perry_ffi::throw_with_code(
&format!("Header name must be a valid HTTP token [\"{name}\"]"),
"ERR_INVALID_HTTP_TOKEN",
perry_ffi::ErrorKind::TypeError,
);
}
let lower = name.to_lowercase();

// Detect an array value via JSON: an array serializes to `[ … ]` and
Expand Down Expand Up @@ -443,6 +513,15 @@ pub unsafe extern "C" fn js_node_http_res_remove_header(
handle: i64,
name_ptr: *const StringHeader,
) {
// #4907 — Node's `OutgoingMessage.removeHeader` throws once headers are
// sent (distinct "remove" wording from `setHeader`).
if response_headers_sent(handle) {
perry_ffi::throw_with_code(
"Cannot remove headers after they are sent to the client",
"ERR_HTTP_HEADERS_SENT",
perry_ffi::ErrorKind::Error,
);
}
let name = match read_string_header(name_ptr as *mut _) {
Some(s) => s.to_lowercase(),
None => return,
Expand Down Expand Up @@ -879,10 +958,60 @@ pub extern "C" fn js_node_http_res_set_timeout(handle: i64, _msecs: f64, _callba
handle
}

/// `res.writeEarlyHints(headers[, cb])` — accepted no-op until interim
/// response streaming is implemented.
/// `res.writeEarlyHints(hints[, cb])` — interim 103 response is not yet sent
/// (accepted no-op), but the `hints` argument is validated to match Node
/// (#4907): a non-object throws `ERR_INVALID_ARG_TYPE`, and a malformed
/// `link` value throws `ERR_INVALID_ARG_VALUE`.
#[no_mangle]
pub extern "C" fn js_node_http_res_write_early_hints(_handle: i64, _headers: f64, _callback: i64) {}
pub unsafe extern "C" fn js_node_http_res_write_early_hints(
_handle: i64,
hints: f64,
_callback: i64,
) {
// Serialize the hints object and inspect it. Node requires `typeof hints
// === 'object'` (and non-null); a string / number / null throws
// `ERR_INVALID_ARG_TYPE`.
let json = {
let ptr = js_json_stringify(hints, 0);
if ptr.is_null() {
None
} else {
read_string_header(ptr)
}
};
let value: Option<serde_json::Value> = json.and_then(|j| serde_json::from_str(&j).ok());
match value {
// Arrays are objects in JS — `hints.link` is simply undefined, so no
// validation fires.
Some(serde_json::Value::Object(map)) => {
if let Some(link) = map.get("link") {
let invalid = match link {
serde_json::Value::Null => false,
serde_json::Value::String(s) => !is_valid_link_header(s),
serde_json::Value::Array(items) => items
.iter()
.any(|i| i.as_str().map(|s| !is_valid_link_header(s)).unwrap_or(true)),
_ => true,
};
if invalid {
perry_ffi::throw_with_code(
"The property 'hints.link' must be an array or string of format \"</styles.css>; rel=preload; as=style\".",
"ERR_INVALID_ARG_VALUE",
perry_ffi::ErrorKind::TypeError,
);
}
}
}
Some(serde_json::Value::Array(_)) => {}
_ => {
perry_ffi::throw_with_code(
"The \"hints\" argument must be of type object.",
"ERR_INVALID_ARG_TYPE",
perry_ffi::ErrorKind::TypeError,
);
}
}
}

/// `res.writeContinue()` — emits an HTTP/1.1 100-continue. Phase 1
/// stores the intent only; the actual 100-continue sequence requires
Expand Down
19 changes: 19 additions & 0 deletions crates/perry-ext-http/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ use client_dispatch::dispatch_request;
mod client_events;
use client_events::{error_event_arg, fire_request_error_listeners, fire_request_event_listeners};

// Node-compatible argument/header/URL validation for the client factories
// (#4907) — throws `ERR_*`-coded errors on bad input.
mod validation;
use validation::{validate_client_options, validate_client_url_string};

use lazy_static::lazy_static;
use perry_ffi::{
alloc_string, gc_register_mutable_root_scanner_named, get_handle_mut, iter_handles_of_mut,
Expand Down Expand Up @@ -914,6 +919,7 @@ unsafe fn request_common(arg_f64: f64, callback: i64, default_protocol: &str) ->
// the same as `http.request({ host, port, path }, cb)`.
let (method, url, headers, timeout, agent_handle) = if is_string_value(arg_f64) {
let raw = extract_string_value(arg_f64).unwrap_or_default();
validate_client_url_string(&raw); // #4907
let url = if raw.starts_with("http://") || raw.starts_with("https://") {
raw
} else if !raw.is_empty() {
Expand All @@ -924,6 +930,7 @@ unsafe fn request_common(arg_f64: f64, callback: i64, default_protocol: &str) ->
("GET".to_string(), url, HashMap::new(), None, 0)
} else {
let opts = parse_options_object(arg_f64).unwrap_or(serde_json::Value::Null);
validate_client_options(&opts, default_protocol); // #4907
let method = method_from_options(&opts);
let url = url_from_options(&opts, default_protocol);
let headers = headers_from_options(&opts);
Expand Down Expand Up @@ -969,6 +976,7 @@ unsafe fn get_common(arg_f64: f64, callback: i64, default_protocol: &str) -> Han
ensure_gc_scanner_registered();
let (url, headers, timeout, agent_handle) = if is_string_value(arg_f64) {
let raw = extract_string_value(arg_f64).unwrap_or_default();
validate_client_url_string(&raw); // #4907
let url = if raw.starts_with("http://") || raw.starts_with("https://") {
raw
} else if !raw.is_empty() {
Expand All @@ -979,6 +987,7 @@ unsafe fn get_common(arg_f64: f64, callback: i64, default_protocol: &str) -> Han
(url, HashMap::new(), None, 0)
} else {
let opts = parse_options_object(arg_f64).unwrap_or(serde_json::Value::Null);
validate_client_options(&opts, default_protocol); // #4907
let url = url_from_options(&opts, default_protocol);
let headers = headers_from_options(&opts);
let timeout = timeout_from_options(&opts);
Expand Down Expand Up @@ -1026,6 +1035,16 @@ pub unsafe extern "C" fn js_https_get(arg_f64: f64, callback_i64: i64) -> Handle
unsafe fn request_overload(args_array: i64, default_protocol: &str, force_get: bool) -> Handle {
ensure_gc_scanner_registered();
let parsed = parse_client_args(args_array);
// #4907 — validate before building the request handle. A string URL
// argument is validated as a WHATWG URL; the options bag is validated for
// method / path / headers / protocol / option types.
if is_string_value(parsed.url) {
let raw = extract_string_value(parsed.url).unwrap_or_default();
validate_client_url_string(&raw);
}
if let Some(opts) = parse_options_object(parsed.opts) {
validate_client_options(&opts, default_protocol);
}
let method = method_for_overload(parsed.opts);
let (url, headers, timeout, agent_handle) =
merge_url_and_options(parsed.url, parsed.opts, default_protocol);
Expand Down
135 changes: 135 additions & 0 deletions crates/perry-ext-http/src/validation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//! Node-compatible argument validation for `http.request` / `http.get`
//! (and the `https` twins). Node throws on bad method / path / headers /
//! protocol / URL / option types *before* a connection is attempted; Perry
//! previously accepted the bad input silently, so the corpus'
//! `assert.throws(...)` cases failed with "Missing expected exception"
//! (#4907).
//!
//! Each helper either returns normally or `js_throw`s a `TypeError` carrying
//! the matching `ERR_*` code (the same mechanism `agent.rs` uses for
//! `ERR_OUT_OF_RANGE`). Throwing unwinds through the codegen call site back
//! to the JS `try` / `assert.throws` frame.

use perry_runtime::fs::validate::throw_type_error_with_code;

/// Node HTTP token bytes (RFC 7230 `tchar`, mirrored from
/// `lib/_http_common.js` `tokenRegExp`). Used for both method names and
/// header field names.
fn is_token_byte(b: u8) -> bool {
matches!(b,
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9'
| b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*'
| b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~')
}

fn is_valid_token(s: &str) -> bool {
!s.is_empty() && s.bytes().all(is_token_byte)
}

/// `http.request` / `get` with a string argument treats it as a WHATWG URL:
/// `url = urlToHttpOptions(new URL(input))`. A bare hostname
/// (`'www.nodejs.org'`), a scheme-less address (`'127.0.0.1'`), or a
/// host-less URL (`'http://:80/'`) all make `new URL(...)` throw
/// `TypeError [ERR_INVALID_URL]`. Mirror that here, before Perry's lenient
/// `"{proto}://{raw}"` prepend (#769) would otherwise paper over it.
pub(crate) fn validate_client_url_string(raw: &str) {
let invalid = match reqwest::Url::parse(raw) {
Ok(u) => u.host_str().map(|h| h.is_empty()).unwrap_or(true),
Err(_) => true,
};
if invalid {
throw_type_error_with_code("Invalid URL", "ERR_INVALID_URL");
}
}

/// Validate the options bag shared by `http.request` / `http.get` /
/// `new ClientRequest(...)`. `default_protocol` is `"http"` or `"https"`,
/// identifying which factory was called (the protocol must match it).
pub(crate) fn validate_client_options(opts: &serde_json::Value, default_protocol: &str) {
let obj = match opts.as_object() {
Some(o) => o,
None => return,
};

// `insecureHTTPParser` must be a boolean when present (Node:
// `validateBoolean(insecureHTTPParser, 'options.insecureHTTPParser')`).
if let Some(v) = obj.get("insecureHTTPParser") {
if !v.is_boolean() && !v.is_null() {
throw_type_error_with_code(
"The \"options.insecureHTTPParser\" property must be of type boolean.",
"ERR_INVALID_ARG_TYPE",
);
}
}

// `timeout`, when present, must be a number (Node:
// `validateNumber(timeout, 'timeout')`). `timeout: null` throws.
if let Some(v) = obj.get("timeout") {
if !v.is_number() {
throw_type_error_with_code(
"The \"timeout\" argument must be of type number.",
"ERR_INVALID_ARG_TYPE",
);
}
}

// `protocol`, when present, must match the factory's protocol. Node's
// `http.request` agent only speaks `http:`, `https.request` only
// `https:`; anything else (incl. `mailto:` / `ftp:` from `url.parse`)
// throws `ERR_INVALID_PROTOCOL`.
if let Some(proto) = obj.get("protocol").and_then(|v| v.as_str()) {
let normalized = format!("{}:", proto.trim_end_matches(':'));
let expected = format!("{default_protocol}:");
if normalized != expected {
throw_type_error_with_code(
&format!("Protocol \"{normalized}\" not supported. Expected \"{expected}\""),
"ERR_INVALID_PROTOCOL",
);
}
}

// `method`, when a string, must be a valid HTTP token. The error message
// quotes the *raw* (non-upper-cased) method, matching
// `validateHttpToken(method, 'Method')`.
if let Some(method) = obj.get("method").and_then(|v| v.as_str()) {
if !is_valid_token(method) {
throw_type_error_with_code(
&format!("Method must be a valid HTTP token [\"{method}\"]"),
"ERR_INVALID_HTTP_TOKEN",
);
}
}

// `path` must not contain unescaped characters: Node rejects anything
// outside `!-ÿ` (`INVALID_PATH_REGEX`).
if let Some(path) = obj.get("path").and_then(|v| v.as_str()) {
if path.chars().any(|c| {
let cp = c as u32;
cp < 0x21 || cp > 0xff
}) {
throw_type_error_with_code(
"Request path contains unescaped characters",
"ERR_UNESCAPED_CHARACTERS",
);
}
}

// Header field names must be valid HTTP tokens, and the `Host` header may
// not be an array (Node computes a single Host line from it).
if let Some(headers) = obj.get("headers").and_then(|v| v.as_object()) {
for (name, value) in headers {
if name.eq_ignore_ascii_case("host") && value.is_array() {
throw_type_error_with_code(
"The \"options.headers.host\" property must be of type string.",
"ERR_INVALID_ARG_TYPE",
);
}
if !is_valid_token(name) {
throw_type_error_with_code(
&format!("Header name must be a valid HTTP token [\"{name}\"]"),
"ERR_INVALID_HTTP_TOKEN",
);
}
}
}
}
Loading
Loading