diff --git a/README.md b/README.md index 9660f86..f152eb6 100644 --- a/README.md +++ b/README.md @@ -848,6 +848,34 @@ await fetch('https://mtls.example.com', { }); ``` +For TLS diagnostics, you can ask for peer certificate metadata on the response or write TLS session keys to a file for tools like Wireshark: + +```ts +const response = await fetch('https://mtls.example.com', { + tlsDebug: { + peerCertificates: true, + keylog: { + path: '/tmp/node-wreq.keys', + }, + }, +}); + +console.log(response.wreq.tls?.peerCertificate); +console.log(response.wreq.tls?.peerCertificateChain); +``` + +Unsafe TLS overrides are separate and explicit: + +```ts +await fetch('https://staging.internal.example', { + tlsDanger: { + certVerification: false, + verifyHostname: false, + sni: false, + }, +}); +``` + ### compression Compression is enabled by default. diff --git a/rust/src/napi/convert.rs b/rust/src/napi/convert.rs index 05f4d2a..7c345f5 100644 --- a/rust/src/napi/convert.rs +++ b/rust/src/napi/convert.rs @@ -1,12 +1,14 @@ use crate::emulation::resolve_emulation; use crate::napi::profiles::parse_browser_emulation; use crate::transport::types::{ - CertificateAuthorityOptions, DnsOptions, RequestOptions, Response, TlsIdentityOptions, + CertificateAuthorityOptions, DnsOptions, LocalBindOptions, RequestOptions, Response, + TlsDangerOptions, TlsDebugOptions, TlsIdentityOptions, TlsKeylogOptions, WebSocketConnectOptions, WebSocketConnection, }; use neon::prelude::*; use neon::types::buffer::TypedArray; use neon::types::JsBuffer; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; fn js_value_to_timeout_ms(cx: &mut FunctionContext, value: Handle) -> NeonResult { let value = value.downcast::(cx).or_throw(cx)?.value(cx); @@ -18,6 +20,231 @@ fn js_value_to_timeout_ms(cx: &mut FunctionContext, value: Handle) -> N Ok(if value == 0.0 { 0 } else { value.ceil() as u64 }) } +fn js_value_to_positive_usize( + cx: &mut FunctionContext, + value: Handle, + name: &str, +) -> NeonResult { + let value = value.downcast::(cx).or_throw(cx)?.value(cx); + + if !value.is_finite() || value <= 0.0 { + return cx.throw_type_error(format!("{name} must be a finite positive number")); + } + + if value > usize::MAX as f64 { + return cx.throw_type_error(format!("{name} exceeds the supported range")); + } + + Ok(value.ceil() as usize) +} + +fn js_value_to_non_negative_timeout_ms( + cx: &mut FunctionContext, + obj: Handle, + name: &str, +) -> NeonResult> { + obj.get_opt(cx, name)? + .map(|v| js_value_to_timeout_ms(cx, v)) + .transpose() +} + +fn js_object_to_local_bind_options( + cx: &mut FunctionContext, + obj: Handle, +) -> NeonResult> { + let address = obj + .get_opt(cx, "localAddress")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)) + .map(|value| { + value.parse::().or_else(|_| { + cx.throw_type_error(format!("localAddress must be a valid IP address: {value}")) + }) + }) + .transpose()?; + + let (ipv4, ipv6) = if let Some(local_addresses) = obj + .get_opt(cx, "localAddresses")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + { + let ipv4 = local_addresses + .get_opt(cx, "ipv4")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)) + .map(|value| { + value.parse::().or_else(|_| { + cx.throw_type_error(format!( + "localAddresses.ipv4 must be a valid IPv4 address: {value}" + )) + }) + }) + .transpose()?; + let ipv6 = local_addresses + .get_opt(cx, "ipv6")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)) + .map(|value| { + value.parse::().or_else(|_| { + cx.throw_type_error(format!( + "localAddresses.ipv6 must be a valid IPv6 address: {value}" + )) + }) + }) + .transpose()?; + + (ipv4, ipv6) + } else { + (None, None) + }; + + let interface = obj + .get_opt(cx, "interface")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)) + .map(|value| { + let trimmed = value.trim().to_string(); + + if trimmed.is_empty() { + return cx.throw_type_error("interface must be a non-empty string"); + } + + Ok(trimmed) + }) + .transpose()?; + + if address.is_none() && ipv4.is_none() && ipv6.is_none() && interface.is_none() { + return Ok(None); + } + + Ok(Some(LocalBindOptions { + address, + ipv4, + ipv6, + interface, + })) +} + +fn js_object_to_tls_debug_options( + cx: &mut FunctionContext, + obj: Handle, +) -> NeonResult> { + let Some(debug_obj) = obj + .get_opt(cx, "tlsDebug")? + .map(|value: Handle| value.downcast::(cx).or_throw(cx)) + .transpose()? + else { + return Ok(None); + }; + + let peer_certificates = debug_obj + .get_opt(cx, "peerCertificates")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)) + .unwrap_or(false); + + let keylog_from_env = debug_obj + .get_opt(cx, "keylogFromEnv")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)) + .unwrap_or(false); + let keylog_path = debug_obj + .get_opt(cx, "keylogPath")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)); + + let keylog = if let Some(path) = keylog_path { + let path = path.trim().to_string(); + + if path.is_empty() { + return cx.throw_type_error("tlsDebug.keylog.path must be a non-empty string"); + } + + Some(TlsKeylogOptions::File { path }) + } else if keylog_from_env { + Some(TlsKeylogOptions::FromEnv) + } else { + match debug_obj.get_opt::(cx, "keylog")? { + Some(value) if value.is_a::(cx) => { + let enabled = value.downcast::(cx).or_throw(cx)?.value(cx); + + if enabled { + Some(TlsKeylogOptions::FromEnv) + } else { + None + } + } + Some(value) if value.is_a::(cx) => { + let value = value.downcast::(cx).or_throw(cx)?; + let Some(path) = value + .get_opt(cx, "path")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)) + else { + return cx.throw_type_error("tlsDebug.keylog.path must be a non-empty string"); + }; + + let path = path.trim().to_string(); + + if path.is_empty() { + return cx.throw_type_error("tlsDebug.keylog.path must be a non-empty string"); + } + + Some(TlsKeylogOptions::File { path }) + } + Some(_) => { + return cx + .throw_type_error("tlsDebug.keylog must be true or an object with a path"); + } + None => None, + } + }; + + if !peer_certificates && keylog.is_none() { + return Ok(None); + } + + Ok(Some(TlsDebugOptions { + peer_certificates, + keylog, + })) +} + +fn js_object_to_tls_danger_options( + cx: &mut FunctionContext, + obj: Handle, +) -> NeonResult> { + let Some(danger_obj) = obj + .get_opt(cx, "tlsDanger")? + .map(|value: Handle| value.downcast::(cx).or_throw(cx)) + .transpose()? + else { + return Ok(None); + }; + + let cert_verification = danger_obj + .get_opt(cx, "certVerification")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)); + let verify_hostname = danger_obj + .get_opt(cx, "verifyHostname")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)); + let sni = danger_obj + .get_opt(cx, "sni")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)); + + if cert_verification.is_none() && verify_hostname.is_none() && sni.is_none() { + return Ok(None); + } + + Ok(Some(TlsDangerOptions { + cert_verification, + verify_hostname, + sni, + })) +} + pub(crate) fn js_value_to_string_array( cx: &mut FunctionContext, value: Handle, @@ -111,16 +338,25 @@ pub(crate) fn js_object_to_request_options( .map(|v| v.value(cx)) .unwrap_or(false); let dns = js_object_to_dns_options(cx, obj)?; - let timeout = obj - .get_opt(cx, "timeout")? - .map(|v| js_value_to_timeout_ms(cx, v)) - .transpose()?; + let timeout = js_value_to_non_negative_timeout_ms(cx, obj, "timeout")?; + let read_timeout = js_value_to_non_negative_timeout_ms(cx, obj, "readTimeout")?; + let connect_timeout = js_value_to_non_negative_timeout_ms(cx, obj, "connectTimeout")?; let timeout = match timeout { Some(0) => None, Some(timeout) => Some(timeout), None => Some(30000), }; + let read_timeout = match read_timeout { + Some(0) => None, + Some(timeout) => Some(timeout), + None => None, + }; + let connect_timeout = match connect_timeout { + Some(0) => None, + Some(timeout) => Some(timeout), + None => None, + }; let disable_default_headers = obj .get_opt(cx, "disableDefaultHeaders")? @@ -133,8 +369,21 @@ pub(crate) fn js_object_to_request_options( .and_then(|v: Handle| v.downcast::(cx).ok()) .map(|v| v.value(cx)) .unwrap_or(true); + let http1_only = obj + .get_opt(cx, "http1Only")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)) + .unwrap_or(false); + let http2_only = obj + .get_opt(cx, "http2Only")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)) + .unwrap_or(false); + let local_bind = js_object_to_local_bind_options(cx, obj)?; let tls_identity = js_object_to_tls_identity_options(cx, obj)?; let certificate_authority = js_object_to_certificate_authority_options(cx, obj)?; + let tls_debug = js_object_to_tls_debug_options(cx, obj)?; + let tls_danger = js_object_to_tls_danger_options(cx, obj)?; Ok(RequestOptions { url, @@ -147,10 +396,17 @@ pub(crate) fn js_object_to_request_options( disable_system_proxy, dns, timeout, + read_timeout, + connect_timeout, disable_default_headers, compress, + http1_only, + http2_only, + local_bind, tls_identity, certificate_authority, + tls_debug, + tls_danger, }) } @@ -225,8 +481,40 @@ pub(crate) fn js_object_to_websocket_options( } } } + let force_http2 = obj + .get_opt(cx, "forceHttp2")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)) + .unwrap_or(false); + let read_buffer_size = obj + .get_opt(cx, "readBufferSize")? + .map(|v| js_value_to_positive_usize(cx, v, "readBufferSize")) + .transpose()?; + let write_buffer_size = obj + .get_opt(cx, "writeBufferSize")? + .map(|v| js_value_to_positive_usize(cx, v, "writeBufferSize")) + .transpose()?; + let max_write_buffer_size = obj + .get_opt(cx, "maxWriteBufferSize")? + .map(|v| js_value_to_positive_usize(cx, v, "maxWriteBufferSize")) + .transpose()?; + let accept_unmasked_frames = obj + .get_opt(cx, "acceptUnmaskedFrames")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)); + let max_frame_size = obj + .get_opt(cx, "maxFrameSize")? + .map(|v| js_value_to_positive_usize(cx, v, "maxFrameSize")) + .transpose()?; + let max_message_size = obj + .get_opt(cx, "maxMessageSize")? + .map(|v| js_value_to_positive_usize(cx, v, "maxMessageSize")) + .transpose()?; + let local_bind = js_object_to_local_bind_options(cx, obj)?; let tls_identity = js_object_to_tls_identity_options(cx, obj)?; let certificate_authority = js_object_to_certificate_authority_options(cx, obj)?; + let tls_debug = js_object_to_tls_debug_options(cx, obj)?; + let tls_danger = js_object_to_tls_danger_options(cx, obj)?; Ok(WebSocketConnectOptions { url, @@ -239,8 +527,18 @@ pub(crate) fn js_object_to_websocket_options( timeout, disable_default_headers, protocols, + force_http2, + read_buffer_size, + write_buffer_size, + max_write_buffer_size, + accept_unmasked_frames, + max_frame_size, + max_message_size, + local_bind, tls_identity, certificate_authority, + tls_debug, + tls_danger, }) } @@ -413,6 +711,40 @@ pub(crate) fn response_to_js_object<'a, C: Context<'a>>( } obj.set(cx, "setCookies", set_cookies)?; + if let Some(tls_info) = response.tls_info { + let tls_obj = cx.empty_object(); + + match tls_info.peer_certificate { + Some(peer_certificate) => { + let value = JsBuffer::from_slice(cx, &peer_certificate)?; + tls_obj.set(cx, "peerCertificate", value)?; + } + None => { + let value = cx.undefined(); + tls_obj.set(cx, "peerCertificate", value)?; + } + } + + match tls_info.peer_certificate_chain { + Some(peer_certificate_chain) => { + let value = JsArray::new(cx, peer_certificate_chain.len()); + + for (index, cert) in peer_certificate_chain.into_iter().enumerate() { + let cert = JsBuffer::from_slice(cx, &cert)?; + value.set(cx, index as u32, cert)?; + } + + tls_obj.set(cx, "peerCertificateChain", value)?; + } + None => { + let value = cx.undefined(); + tls_obj.set(cx, "peerCertificateChain", value)?; + } + } + + obj.set(cx, "tls", tls_obj)?; + } + let body_handle = cx.number(response.body_handle as f64); obj.set(cx, "bodyHandle", body_handle)?; diff --git a/rust/src/transport/request.rs b/rust/src/transport/request.rs index 7f06c48..955f6fd 100644 --- a/rust/src/transport/request.rs +++ b/rust/src/transport/request.rs @@ -3,11 +3,11 @@ use crate::transport::cookies::parse_cookie_pair; use crate::transport::dns::configure_client_builder as configure_dns; use crate::transport::headers::build_orig_header_map; use crate::transport::tls::configure_client_builder; -use crate::transport::types::{RequestOptions, Response}; +use crate::transport::types::{RequestOptions, Response, ResponseTlsInfo}; use anyhow::{Context, Result}; use std::collections::HashMap; use std::time::Duration; -use wreq::redirect; +use wreq::{redirect, tls::TlsInfo, Method}; pub async fn make_request(options: RequestOptions) -> Result { let RequestOptions { @@ -21,10 +21,17 @@ pub async fn make_request(options: RequestOptions) -> Result { disable_system_proxy, dns, timeout, + read_timeout, + connect_timeout, disable_default_headers, compress, + http1_only, + http2_only, + local_bind, tls_identity, certificate_authority, + tls_debug, + tls_danger, } = options; let mut client_builder = wreq::Client::builder() @@ -39,7 +46,69 @@ pub async fn make_request(options: RequestOptions) -> Result { } client_builder = configure_dns(client_builder, dns)?; - client_builder = configure_client_builder(client_builder, tls_identity, certificate_authority)?; + client_builder = configure_client_builder( + client_builder, + tls_identity, + certificate_authority, + tls_debug, + tls_danger, + )?; + + if let Some(connect_timeout) = connect_timeout { + client_builder = client_builder.connect_timeout(Duration::from_millis(connect_timeout)); + } + + if http1_only { + client_builder = client_builder.http1_only(); + } + + if http2_only { + client_builder = client_builder.http2_only(); + } + + if let Some(local_bind) = local_bind { + if let Some(address) = local_bind.address { + client_builder = client_builder.local_address(address); + } + + if local_bind.ipv4.is_some() || local_bind.ipv6.is_some() { + client_builder = client_builder.local_addresses(local_bind.ipv4, local_bind.ipv6); + } + + if let Some(interface) = local_bind.interface { + #[cfg(any( + target_os = "android", + target_os = "fuchsia", + target_os = "illumos", + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "solaris", + target_os = "tvos", + target_os = "visionos", + target_os = "watchos", + ))] + { + client_builder = client_builder.interface(interface); + } + + #[cfg(not(any( + target_os = "android", + target_os = "fuchsia", + target_os = "illumos", + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "solaris", + target_os = "tvos", + target_os = "visionos", + target_os = "watchos", + )))] + { + let _ = interface; + } + } + } let orig_headers = build_orig_header_map(&orig_headers); let client = client_builder @@ -47,16 +116,10 @@ pub async fn make_request(options: RequestOptions) -> Result { .context("Failed to build HTTP client")?; let method = if method.is_empty() { "GET" } else { &method }; + let parsed_method = Method::from_bytes(method.as_bytes()) + .with_context(|| format!("Unsupported HTTP method: {}", method))?; - let mut request = match method.to_uppercase().as_str() { - "GET" => client.get(&url), - "POST" => client.post(&url), - "PUT" => client.put(&url), - "DELETE" => client.delete(&url), - "PATCH" => client.patch(&url), - "HEAD" => client.head(&url), - _ => return Err(anyhow::anyhow!("Unsupported HTTP method: {}", method)), - }; + let mut request = client.request(parsed_method, &url); for (key, value) in &headers { request = request.header(key, value); @@ -73,6 +136,9 @@ pub async fn make_request(options: RequestOptions) -> Result { if let Some(timeout) = timeout { request = request.timeout(Duration::from_millis(timeout)); } + if let Some(read_timeout) = read_timeout { + request = request.read_timeout(Duration::from_millis(read_timeout)); + } request = request.redirect(redirect::Policy::none()); request = request.default_headers(!disable_default_headers); request = request.gzip(compress); @@ -85,6 +151,17 @@ pub async fn make_request(options: RequestOptions) -> Result { .await .with_context(|| format!("{} {}", method, url))?; + let tls_info = response + .extensions() + .get::() + .cloned() + .map(|tls_info| ResponseTlsInfo { + peer_certificate: tls_info.peer_certificate().map(|cert| cert.to_vec()), + peer_certificate_chain: tls_info + .peer_certificate_chain() + .map(|chain| chain.map(|cert| cert.to_vec()).collect()), + }); + let status = response.status().as_u16(); let final_url = response.uri().to_string(); @@ -115,6 +192,7 @@ pub async fn make_request(options: RequestOptions) -> Result { body_handle, cookies, set_cookies, + tls_info, url: final_url, }) } diff --git a/rust/src/transport/tls.rs b/rust/src/transport/tls.rs index 288a2b5..b97327b 100644 --- a/rust/src/transport/tls.rs +++ b/rust/src/transport/tls.rs @@ -1,7 +1,10 @@ -use crate::transport::types::{CertificateAuthorityOptions, TlsIdentityOptions}; +use crate::transport::types::{ + CertificateAuthorityOptions, TlsDangerOptions, TlsDebugOptions, TlsIdentityOptions, + TlsKeylogOptions, +}; use anyhow::{Context, Result}; use wreq::{ - tls::{CertStore, Identity}, + tls::{CertStore, Identity, KeyLog}, ClientBuilder, }; @@ -9,6 +12,8 @@ pub fn configure_client_builder( mut builder: ClientBuilder, tls_identity: Option, certificate_authority: Option, + tls_debug: Option, + tls_danger: Option, ) -> Result { if let Some(tls_identity) = tls_identity { builder = builder.identity(build_identity(tls_identity)?); @@ -18,6 +23,33 @@ pub fn configure_client_builder( builder = builder.cert_store(build_cert_store(certificate_authority)?); } + if let Some(tls_debug) = tls_debug { + if tls_debug.peer_certificates { + builder = builder.tls_info(true); + } + + if let Some(keylog) = tls_debug.keylog { + builder = builder.keylog(match keylog { + TlsKeylogOptions::FromEnv => KeyLog::from_env(), + TlsKeylogOptions::File { path } => KeyLog::from_file(path), + }); + } + } + + if let Some(tls_danger) = tls_danger { + if let Some(cert_verification) = tls_danger.cert_verification { + builder = builder.cert_verification(cert_verification); + } + + if let Some(verify_hostname) = tls_danger.verify_hostname { + builder = builder.verify_hostname(verify_hostname); + } + + if let Some(sni) = tls_danger.sni { + builder = builder.tls_sni(sni); + } + } + Ok(builder) } diff --git a/rust/src/transport/types.rs b/rust/src/transport/types.rs index 4914b40..15766d7 100644 --- a/rust/src/transport/types.rs +++ b/rust/src/transport/types.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use wreq::Emulation; #[derive(Debug, Clone)] @@ -19,12 +20,45 @@ pub struct CertificateAuthorityOptions { pub include_default_roots: bool, } +#[derive(Debug, Clone)] +pub enum TlsKeylogOptions { + FromEnv, + File { path: String }, +} + +#[derive(Debug, Clone)] +pub struct TlsDebugOptions { + pub peer_certificates: bool, + pub keylog: Option, +} + +#[derive(Debug, Clone)] +pub struct TlsDangerOptions { + pub cert_verification: Option, + pub verify_hostname: Option, + pub sni: Option, +} + +#[derive(Debug, Clone)] +pub struct ResponseTlsInfo { + pub peer_certificate: Option>, + pub peer_certificate_chain: Option>>, +} + #[derive(Debug, Clone)] pub struct DnsOptions { pub servers: Vec, pub hosts: Vec<(String, Vec)>, } +#[derive(Debug, Clone)] +pub struct LocalBindOptions { + pub address: Option, + pub ipv4: Option, + pub ipv6: Option, + pub interface: Option, +} + #[derive(Debug, Clone)] pub struct RequestOptions { pub url: String, @@ -37,10 +71,17 @@ pub struct RequestOptions { pub disable_system_proxy: bool, pub dns: Option, pub timeout: Option, + pub read_timeout: Option, + pub connect_timeout: Option, pub disable_default_headers: bool, pub compress: bool, + pub http1_only: bool, + pub http2_only: bool, + pub local_bind: Option, pub tls_identity: Option, pub certificate_authority: Option, + pub tls_debug: Option, + pub tls_danger: Option, } #[derive(Debug, Clone)] @@ -50,6 +91,7 @@ pub struct Response { pub body_handle: u64, pub cookies: HashMap, pub set_cookies: Vec, + pub tls_info: Option, pub url: String, } @@ -65,8 +107,18 @@ pub struct WebSocketConnectOptions { pub timeout: Option, pub disable_default_headers: bool, pub protocols: Vec, + pub force_http2: bool, + pub read_buffer_size: Option, + pub write_buffer_size: Option, + pub max_write_buffer_size: Option, + pub accept_unmasked_frames: Option, + pub max_frame_size: Option, + pub max_message_size: Option, + pub local_bind: Option, pub tls_identity: Option, pub certificate_authority: Option, + pub tls_debug: Option, + pub tls_danger: Option, } #[derive(Debug, Clone)] diff --git a/rust/src/transport/websocket.rs b/rust/src/transport/websocket.rs index 52443c3..c4a36eb 100644 --- a/rust/src/transport/websocket.rs +++ b/rust/src/transport/websocket.rs @@ -144,8 +144,18 @@ async fn make_websocket(options: WebSocketConnectOptions) -> Result Result Result + ) { + return this.fetch(input, { ...init, method: 'PUT', body }); + } + + async patch( + input: RequestInput, + body?: WreqInit['body'], + init?: Omit + ) { + return this.fetch(input, { ...init, method: 'PATCH', body }); + } + + async delete(input: RequestInput, init?: Omit) { + return this.fetch(input, { ...init, method: 'DELETE' }); + } + + async head(input: RequestInput, init?: Omit) { + return this.fetch(input, { ...init, method: 'HEAD' }); + } + + async options(input: RequestInput, init?: Omit) { + return this.fetch(input, { ...init, method: 'OPTIONS' }); + } + extend(defaults: ClientDefaults): Client { return new WreqClient(mergeDefaults(this.defaults, defaults)); } diff --git a/src/config/network.ts b/src/config/network.ts index be575ac..72015fd 100644 --- a/src/config/network.ts +++ b/src/config/network.ts @@ -1,4 +1,11 @@ -import type { DnsOptions, NativeDnsOptions, WreqInit } from '../types'; +import { isIP } from 'node:net'; +import type { + DnsOptions, + NativeDnsOptions, + NativeLocalAddresses, + WebSocketInit, + WreqInit, +} from '../types'; export function normalizeProxyOptions(proxy: WreqInit['proxy']): { proxy?: string; @@ -46,3 +53,58 @@ export function normalizeDnsOptions(dns?: DnsOptions): NativeDnsOptions | undefi hosts, }; } + +type BindInput = Pick & + Pick; + +type NormalizedLocalBind = { + localAddress?: string; + localAddresses?: NativeLocalAddresses; + interface?: string; +}; + +export function normalizeLocalBindOptions(input: BindInput): NormalizedLocalBind { + const localAddress = input.localAddress?.trim(); + const interfaceName = input.interface?.trim(); + const ipv4 = input.localAddresses?.ipv4?.trim(); + const ipv6 = input.localAddresses?.ipv6?.trim(); + + if (localAddress && isIP(localAddress) === 0) { + throw new TypeError(`localAddress must be a valid IPv4 or IPv6 address: ${input.localAddress}`); + } + + if (ipv4 && isIP(ipv4) !== 4) { + throw new TypeError( + `localAddresses.ipv4 must be a valid IPv4 address: ${input.localAddresses?.ipv4}` + ); + } + + if (ipv6 && isIP(ipv6) !== 6) { + throw new TypeError( + `localAddresses.ipv6 must be a valid IPv6 address: ${input.localAddresses?.ipv6}` + ); + } + + if (input.interface !== undefined && !interfaceName) { + throw new TypeError('interface must be a non-empty string'); + } + + const normalized: NormalizedLocalBind = {}; + + if (localAddress) { + normalized.localAddress = localAddress; + } + + if (ipv4 || ipv6) { + normalized.localAddresses = { + ...(ipv4 ? { ipv4 } : {}), + ...(ipv6 ? { ipv6 } : {}), + }; + } + + if (interfaceName) { + normalized.interface = interfaceName; + } + + return normalized; +} diff --git a/src/config/tls.ts b/src/config/tls.ts index 2a8e4e0..60a2238 100644 --- a/src/config/tls.ts +++ b/src/config/tls.ts @@ -2,9 +2,13 @@ import { Buffer } from 'node:buffer'; import type { CertificateAuthority, NativeCertificateAuthority, + NativeTlsDanger, + NativeTlsDebug, NativeTlsIdentity, + TlsDangerOptions, TlsBinaryInput, TlsDataInput, + TlsDebugOptions, TlsIdentity, } from '../types'; @@ -60,3 +64,70 @@ export function normalizeCertificateAuthority( includeDefaultRoots: authority.includeDefaultRoots ?? false, }; } + +export function normalizeTlsDebug(debug?: TlsDebugOptions): NativeTlsDebug | undefined { + if (!debug) { + return undefined; + } + + let keylogFromEnv: boolean | undefined; + let keylogPath: string | undefined; + + if (debug.keylog !== undefined) { + if (debug.keylog === true) { + keylogFromEnv = true; + } else if (typeof debug.keylog === 'object' && debug.keylog !== null) { + if (typeof debug.keylog.path !== 'string') { + throw new TypeError('tlsDebug.keylog.path must be a non-empty string'); + } + + const path = debug.keylog.path.trim(); + + if (!path) { + throw new TypeError('tlsDebug.keylog.path must be a non-empty string'); + } + + keylogPath = path; + } else { + throw new TypeError('tlsDebug.keylog must be true or an object with a path'); + } + } + + const normalized: NativeTlsDebug = {}; + + if (debug.peerCertificates !== undefined) { + normalized.peerCertificates = debug.peerCertificates; + } + + if (keylogFromEnv) { + normalized.keylogFromEnv = true; + } + + if (keylogPath !== undefined) { + normalized.keylogPath = keylogPath; + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function normalizeTlsDanger(danger?: TlsDangerOptions): NativeTlsDanger | undefined { + if (!danger) { + return undefined; + } + + const normalized: NativeTlsDanger = {}; + + if (danger.certVerification !== undefined) { + normalized.certVerification = danger.certVerification; + } + + if (danger.verifyHostname !== undefined) { + normalized.verifyHostname = danger.verifyHostname; + } + + if (danger.sni !== undefined) { + normalized.sni = danger.sni; + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} diff --git a/src/http/pipeline/dispatch.ts b/src/http/pipeline/dispatch.ts index 7cc3e28..187f57b 100644 --- a/src/http/pipeline/dispatch.ts +++ b/src/http/pipeline/dispatch.ts @@ -44,6 +44,7 @@ export async function dispatchNativeRequest( bodyHandle: nativeResponse.bodyHandle, cookies: nativeResponse.cookies, setCookies: nativeResponse.setCookies, + tls: nativeResponse.tls, url: nativeResponse.url, timings: { startTime, diff --git a/src/http/pipeline/options.ts b/src/http/pipeline/options.ts index b26a0ca..39595d4 100644 --- a/src/http/pipeline/options.ts +++ b/src/http/pipeline/options.ts @@ -1,7 +1,16 @@ import { Buffer } from 'node:buffer'; import { serializeEmulationOptions } from '../../config/emulation'; -import { normalizeDnsOptions, normalizeProxyOptions } from '../../config/network'; -import { normalizeCertificateAuthority, normalizeTlsIdentity } from '../../config/tls'; +import { + normalizeDnsOptions, + normalizeLocalBindOptions, + normalizeProxyOptions, +} from '../../config/network'; +import { + normalizeCertificateAuthority, + normalizeTlsDanger, + normalizeTlsDebug, + normalizeTlsIdentity, +} from '../../config/tls'; import { Headers } from '../../headers'; import { normalizeMethod, validateBrowserProfile } from '../../native/index'; import type { @@ -71,6 +80,24 @@ function resolveNativeTimeout(timeout: number | undefined): Pick( + name: TName, + value: number | undefined +): Pick | {} { + if (value === undefined) { + return {}; + } + + if (!Number.isFinite(value) || value < 0) { + throw new TypeError(`${name} must be a finite non-negative number`); + } + + return { [name]: value === 0 ? 0 : Math.max(1, Math.ceil(value)) } as Pick< + NativeRequestOptions, + TName + >; +} + export function resolveOptions(init: WreqInit): ResolvedOptions { return { ...init, @@ -102,6 +129,13 @@ export async function buildNativeRequest( const { proxy, disableSystemProxy } = normalizeProxyOptions(options.proxy); const body = await request._getBodyBytesForDispatch(); const timeout = resolveNativeTimeout(options.timeout); + const readTimeout = resolveNativeDuration('readTimeout', options.readTimeout); + const connectTimeout = resolveNativeDuration('connectTimeout', options.connectTimeout); + const localBind = normalizeLocalBindOptions(options); + + if (options.http1Only && options.http2Only) { + throw new TypeError('http1Only and http2Only cannot both be true'); + } return { url: request.url, @@ -115,9 +149,16 @@ export async function buildNativeRequest( disableSystemProxy, dns: normalizeDnsOptions(options.dns), ...timeout, + ...readTimeout, + ...connectTimeout, disableDefaultHeaders: options.disableDefaultHeaders, compress: options.compress, + http1Only: options.http1Only, + http2Only: options.http2Only, + ...localBind, tlsIdentity: normalizeTlsIdentity(options.tlsIdentity), ca: normalizeCertificateAuthority(options.ca), + tlsDebug: normalizeTlsDebug(options.tlsDebug), + tlsDanger: normalizeTlsDanger(options.tlsDanger), }; } diff --git a/src/http/response-meta.ts b/src/http/response-meta.ts index 468a9d9..5c165a9 100644 --- a/src/http/response-meta.ts +++ b/src/http/response-meta.ts @@ -1,5 +1,5 @@ import { Readable } from 'node:stream'; -import type { RequestTimings, RedirectEntry, WreqResponseMeta } from '../types'; +import type { RequestTimings, RedirectEntry, TlsPeerInfo, WreqResponseMeta } from '../types'; import type { Response } from './response'; export class ResponseMeta implements WreqResponseMeta { @@ -33,6 +33,19 @@ export class ResponseMeta implements WreqResponseMeta { return Number.isFinite(parsed) ? parsed : undefined; } + get tls(): TlsPeerInfo | undefined { + return this.response._tls + ? { + peerCertificate: this.response._tls.peerCertificate + ? Buffer.from(this.response._tls.peerCertificate) + : undefined, + peerCertificateChain: this.response._tls.peerCertificateChain?.map((cert) => + Buffer.from(cert) + ), + } + : undefined; + } + readable(): Readable { const body = this.response.clone().body; diff --git a/src/http/response.ts b/src/http/response.ts index fc6a60b..2f58a2e 100644 --- a/src/http/response.ts +++ b/src/http/response.ts @@ -2,6 +2,7 @@ import { Blob, Buffer } from 'node:buffer'; import { STATUS_CODES } from 'node:http'; import { ReadableStream } from 'node:stream/web'; import { TextDecoder } from 'node:util'; +import { RequestError, TimeoutError } from '../errors'; import { Headers } from '../headers'; import { nativeCancelBody, nativeReadBodyChunk } from '../native/index'; import type { @@ -10,6 +11,7 @@ import type { NativeResponse, RedirectEntry, RequestTimings, + TlsPeerInfo, WreqResponseMeta, } from '../types'; import { cloneBytes, toBodyBytes } from './body/bytes'; @@ -67,6 +69,32 @@ function isNativeResponse(value: unknown): value is NativeResponse { ); } +function toBodyReadError(error: unknown): RequestError { + if (error instanceof TimeoutError || error instanceof RequestError) { + return error; + } + + const message = error instanceof Error ? error.message : String(error); + const lowered = message.toLowerCase(); + + if (lowered.includes('timed out') || lowered.includes('timeout')) { + return new TimeoutError(message, { cause: error }); + } + + return new RequestError(message, { cause: error }); +} + +function cloneTlsInfo(value: TlsPeerInfo | undefined): TlsPeerInfo | undefined { + if (!value) { + return undefined; + } + + return { + peerCertificate: value.peerCertificate ? Buffer.from(value.peerCertificate) : undefined, + peerCertificateChain: value.peerCertificateChain?.map((cert) => Buffer.from(cert)), + }; +} + export class Response { readonly status: number; readonly statusText: string; @@ -79,6 +107,7 @@ export class Response { _setCookies: string[]; _timings?: RequestTimings; _redirectChain: RedirectEntry[]; + _tls?: TlsPeerInfo; redirected: boolean; #payloadBytes: Uint8Array | null; #bodyHandle: number | null; @@ -97,6 +126,7 @@ export class Response { this._setCookies = [...(body.setCookies ?? [])]; this._timings = body.timings ? { ...body.timings } : undefined; this._redirectChain = []; + this._tls = cloneTlsInfo(body.tls); this.redirected = false; this.#payloadBytes = body.body !== undefined ? Buffer.from(body.body, 'utf8') : null; this.#bodyHandle = body.bodyHandle ?? null; @@ -110,6 +140,7 @@ export class Response { this._setCookies = []; this._timings = undefined; this._redirectChain = []; + this._tls = undefined; this.redirected = false; this.#payloadBytes = toBodyBytes(body ?? null, 'Unsupported response body type'); this.#bodyHandle = null; @@ -182,6 +213,7 @@ export class Response { cloned._setCookies = [...this._setCookies]; cloned._timings = this._timings ? { ...this._timings } : undefined; cloned._redirectChain = [...this._redirectChain]; + cloned._tls = cloneTlsInfo(this._tls); cloned.redirected = this.redirected; const source = this.#ensureStreamSource(); @@ -245,7 +277,14 @@ export class Response { this.#bodyHandle = null; this.#streamSource = new ReadableStream({ pull: async (controller) => { - const result = await nativeReadBodyChunk(handle); + let result; + + try { + result = await nativeReadBodyChunk(handle); + } catch (error) { + this.#markBodyComplete(); + throw toBodyReadError(error); + } if (result.chunk.length > 0) { controller.enqueue(new Uint8Array(result.chunk)); diff --git a/src/native/methods.ts b/src/native/methods.ts index 5da6bb1..f2c5457 100644 --- a/src/native/methods.ts +++ b/src/native/methods.ts @@ -3,15 +3,9 @@ import type { HttpMethod } from '../types'; export function normalizeMethod(method?: string): HttpMethod { const normalized = (method ?? 'GET').toUpperCase(); - switch (normalized) { - case 'GET': - case 'POST': - case 'PUT': - case 'DELETE': - case 'PATCH': - case 'HEAD': - return normalized; - default: - throw new Error(`Unsupported HTTP method: ${method}`); + if (normalized.length === 0) { + throw new Error(`Unsupported HTTP method: ${method}`); } + + return normalized; } diff --git a/src/test/helpers/local-server.ts b/src/test/helpers/local-server.ts index 4957ce1..238e2c8 100644 --- a/src/test/helpers/local-server.ts +++ b/src/test/helpers/local-server.ts @@ -147,6 +147,47 @@ export function setupLocalTestServer() { return; } + if (url.pathname === '/connection/delay') { + const delayMs = Number(url.searchParams.get('ms') ?? '50'); + + setTimeout(() => { + response.writeHead(200, { + 'content-type': 'application/json', + }); + response.end(JSON.stringify({ delayedConnection: true })); + }, delayMs); + + return; + } + + if (url.pathname === '/stream/slow') { + const chunks = Math.max(1, Number(url.searchParams.get('chunks') ?? '3')); + const chunkBytes = Math.max(1, Number(url.searchParams.get('chunkBytes') ?? '1024')); + const delayMs = Math.max(0, Number(url.searchParams.get('delayMs') ?? '25')); + let sent = 0; + + response.writeHead(200, { + 'content-type': 'application/octet-stream', + }); + + const sendChunk = () => { + response.write(Buffer.alloc(chunkBytes, sent % 251)); + sent += 1; + + if (sent >= chunks) { + response.end(); + + return; + } + + setTimeout(sendChunk, delayMs); + }; + + sendChunk(); + + return; + } + if (url.pathname === '/cookies/set') { sendJson( response, diff --git a/src/test/http-client.spec.ts b/src/test/http-client.spec.ts index 5f0b3a7..096efd2 100644 --- a/src/test/http-client.spec.ts +++ b/src/test/http-client.spec.ts @@ -133,6 +133,92 @@ describe('http client', () => { return true; } ); + + await assert.rejects( + async () => { + await fetch(`${getBaseUrl()}/headers/raw`, { + connectTimeout: Number.NaN, + }); + }, + (error: unknown) => error instanceof Error && error.name === 'RequestError' + ); + }); + + test('should support arbitrary HTTP methods', async () => { + const customResponse = await fetch(`${getBaseUrl()}/body/echo`, { + method: 'PROPFIND', + }); + const customBody = await customResponse.json<{ method: string }>(); + + assert.strictEqual(customBody.method, 'PROPFIND'); + }); + + test('should support client options/put/patch/delete/head helpers', async () => { + const client = createClient({ + baseURL: getBaseUrl(), + }); + + const optionsResponse = await client.options('/body/echo'); + const putResponse = await client.put('/body/echo', 'put-body'); + const patchResponse = await client.patch('/body/echo', 'patch-body'); + const deleteResponse = await client.delete('/body/echo'); + const headResponse = await client.head('/headers/raw'); + + assert.strictEqual((await optionsResponse.json<{ method: string }>()).method, 'OPTIONS'); + assert.strictEqual((await putResponse.json<{ method: string; body: string }>()).method, 'PUT'); + assert.strictEqual( + (await patchResponse.json<{ method: string; body: string }>()).body, + 'patch-body' + ); + assert.strictEqual((await deleteResponse.json<{ method: string }>()).method, 'DELETE'); + assert.strictEqual(headResponse.status, 200); + }); + + test('should support http1Only and reject conflicting protocol forcing', async () => { + const response = await fetch(`${getBaseUrl()}/headers/raw`, { + http1Only: true, + }); + + assert.strictEqual(response.status, 200); + + await assert.rejects( + async () => { + await fetch(`${getBaseUrl()}/headers/raw`, { + http1Only: true, + http2Only: true, + }); + }, + (error: unknown) => + error instanceof Error && + error.name === 'RequestError' && + (error as Error).message.includes('http1Only and http2Only cannot both be true') + ); + }); + + test('should reject invalid local bind options', async () => { + await assert.rejects( + async () => { + await fetch(`${getBaseUrl()}/headers/raw`, { + localAddress: 'not-an-ip', + }); + }, + (error: unknown) => + error instanceof Error && + error.name === 'RequestError' && + error.message.includes('localAddress must be a valid IPv4 or IPv6 address') + ); + + await assert.rejects( + async () => { + await fetch(`${getBaseUrl()}/headers/raw`, { + interface: ' ', + }); + }, + (error: unknown) => + error instanceof Error && + error.name === 'RequestError' && + error.message.includes('interface must be a non-empty string') + ); }); test('should support fetch-style requests', async () => { diff --git a/src/test/mtls.spec.ts b/src/test/mtls.spec.ts index 24d1e32..0bfdde9 100644 --- a/src/test/mtls.spec.ts +++ b/src/test/mtls.spec.ts @@ -1,5 +1,8 @@ import assert from 'node:assert'; import { Buffer } from 'node:buffer'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { test } from 'node:test'; import { fetch } from '../node-wreq'; import { @@ -61,3 +64,133 @@ test('should reject requests to an mTLS endpoint without a client certificate', }); }); }); + +test('should expose peer certificates in response metadata when requested', async () => { + const response = await fetch(`${getBaseUrl()}/protected`, { + browser: 'chrome_137', + tlsIdentity: { + cert: testClientCertPem, + key: testClientKeyPem, + }, + ca: { + cert: testCaPem, + includeDefaultRoots: false, + }, + tlsDebug: { + peerCertificates: true, + }, + }); + + assert.strictEqual(response.status, 200); + assert.ok(response.wreq.tls, 'TLS metadata should be exposed when peerCertificates is enabled'); + assert.ok( + Buffer.isBuffer(response.wreq.tls?.peerCertificate), + 'leaf certificate should be returned as a Buffer' + ); + assert.ok( + (response.wreq.tls?.peerCertificateChain?.length ?? 0) >= 1, + 'certificate chain should include at least the leaf certificate' + ); +}); + +test('should write TLS key log lines to the configured file', async () => { + const directory = mkdtempSync(join(tmpdir(), 'node-wreq-keylog-')); + const keylogPath = join(directory, 'tls.keys'); + + try { + const response = await fetch(`${getBaseUrl()}/protected`, { + browser: 'chrome_137', + tlsIdentity: { + cert: testClientCertPem, + key: testClientKeyPem, + }, + ca: { + cert: testCaPem, + includeDefaultRoots: false, + }, + tlsDebug: { + keylog: { + path: keylogPath, + }, + }, + }); + + assert.strictEqual(response.status, 200); + + const keylog = readFileSync(keylogPath, 'utf8'); + + assert.match(keylog, /^(CLIENT|SERVER)_[A-Z0-9_]+ /m); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test('should allow hostname verification to be disabled explicitly', async () => { + const mismatchedHostUrl = `${getBaseUrl().replace('https://localhost', 'https://mismatch.local')}/protected`; + + await assert.rejects(async () => { + await fetch(mismatchedHostUrl, { + browser: 'chrome_137', + dns: { + hosts: { + 'mismatch.local': ['127.0.0.1'], + }, + }, + tlsIdentity: { + cert: testClientCertPem, + key: testClientKeyPem, + }, + ca: { + cert: testCaPem, + includeDefaultRoots: false, + }, + }); + }); + + const response = await fetch(mismatchedHostUrl, { + browser: 'chrome_137', + dns: { + hosts: { + 'mismatch.local': ['127.0.0.1'], + }, + }, + tlsIdentity: { + cert: testClientCertPem, + key: testClientKeyPem, + }, + ca: { + cert: testCaPem, + includeDefaultRoots: false, + }, + tlsDanger: { + verifyHostname: false, + }, + }); + + assert.strictEqual(response.status, 200); +}); + +test('should allow certificate verification to be disabled explicitly', async () => { + await assert.rejects(async () => { + await fetch(`${getBaseUrl()}/protected`, { + browser: 'chrome_137', + tlsIdentity: { + cert: testClientCertPem, + key: testClientKeyPem, + }, + }); + }); + + const response = await fetch(`${getBaseUrl()}/protected`, { + browser: 'chrome_137', + tlsIdentity: { + cert: testClientCertPem, + key: testClientKeyPem, + }, + tlsDanger: { + certVerification: false, + }, + }); + + assert.strictEqual(response.status, 200); +}); diff --git a/src/test/transport-features.spec.ts b/src/test/transport-features.spec.ts index 6a587ee..808f4d8 100644 --- a/src/test/transport-features.spec.ts +++ b/src/test/transport-features.spec.ts @@ -106,6 +106,23 @@ describe('transport features', () => { ); }); + test('should apply readTimeout independently from total timeout', async () => { + const response = await fetch( + `${getBaseUrl()}/stream/slow?chunks=3&chunkBytes=1024&delayMs=40`, + { + timeout: 0, + readTimeout: 15, + } + ); + + await assert.rejects( + async () => { + await response.arrayBuffer(); + }, + (error: unknown) => error instanceof Error && error.name === 'TimeoutError' + ); + }); + test('should support per-request DNS host overrides', async () => { const target = new URL(`${getBaseUrl()}/headers/raw`); diff --git a/src/test/websocket.spec.ts b/src/test/websocket.spec.ts index d3e7c8a..747ab32 100644 --- a/src/test/websocket.spec.ts +++ b/src/test/websocket.spec.ts @@ -129,6 +129,32 @@ describe('websocket', () => { ); }); + test('should reject invalid websocket size limits', async () => { + const socket = new WreqWebSocket(getBaseUrl().replace('http://', 'ws://') + '/ws', { + maxFrameSize: 0, + }); + + await assert.rejects( + socket.opened, + (error: unknown) => + error instanceof TypeError && + error.message.includes('maxFrameSize must be a finite positive number') + ); + }); + + test('should reject invalid websocket bind options', async () => { + const socket = new WreqWebSocket(getBaseUrl().replace('http://', 'ws://') + '/ws', { + localAddress: 'bad-ip', + }); + + await assert.rejects( + socket.opened, + (error: unknown) => + error instanceof TypeError && + error.message.includes('localAddress must be a valid IPv4 or IPv6 address') + ); + }); + test('should expose negotiated websocket extensions as a string', async () => { const socket = await websocket(getBaseUrl().replace('http://', 'ws://') + '/ws'); diff --git a/src/types/client.ts b/src/types/client.ts index 389b016..a895da6 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -23,5 +23,27 @@ export interface Client { body?: BodyInit | null, init?: Omit ): Promise; + put( + input: RequestInput, + body?: BodyInit | null, + init?: Omit + ): Promise; + patch( + input: RequestInput, + body?: BodyInit | null, + init?: Omit + ): Promise; + delete( + input: RequestInput, + init?: Omit + ): Promise; + head( + input: RequestInput, + init?: Omit + ): Promise; + options( + input: RequestInput, + init?: Omit + ): Promise; extend(defaults: ClientDefaults): Client; } diff --git a/src/types/http.ts b/src/types/http.ts index 6bae17f..a219e46 100644 --- a/src/types/http.ts +++ b/src/types/http.ts @@ -14,7 +14,10 @@ import type { TlsIdentity, HttpMethod, RequestTimings, + TlsDangerOptions, + TlsDebugOptions, TlsOptions, + TlsPeerInfo, } from './shared'; export type RequestInput = string | URL | WreqRequest | globalThis.Request; @@ -66,6 +69,8 @@ export interface WreqInit { proxy?: string | false; dns?: DnsOptions; timeout?: number; + readTimeout?: number; + connectTimeout?: number; retry?: number | RetryOptions; redirect?: RedirectMode; maxRedirects?: number; @@ -77,6 +82,13 @@ export interface WreqInit { tlsOptions?: TlsOptions; tlsIdentity?: TlsIdentity; ca?: CertificateAuthority; + tlsDebug?: TlsDebugOptions; + tlsDanger?: TlsDangerOptions; + http1Only?: boolean; + http2Only?: boolean; + localAddress?: string; + localAddresses?: import('./shared').LocalAddresses; + interface?: string; http1Options?: Http1Options; http2Options?: Http2Options; onStats?: (stats: RequestStats) => void | Promise; @@ -118,5 +130,6 @@ export interface WreqResponseMeta { readonly timings?: RequestTimings; readonly redirectChain: RedirectEntry[]; readonly contentLength?: number; + readonly tls?: TlsPeerInfo; readable(): import('node:stream').Readable; } diff --git a/src/types/native.ts b/src/types/native.ts index d604cf1..98fbe30 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -5,6 +5,11 @@ export interface NativeDnsOptions { hosts?: Record; } +export interface NativeLocalAddresses { + ipv4?: string; + ipv6?: string; +} + export interface NativeTlsIdentityPem { cert: Buffer; key: Buffer; @@ -22,6 +27,23 @@ export interface NativeCertificateAuthority { includeDefaultRoots: boolean; } +export interface NativeTlsDebug { + peerCertificates?: boolean; + keylogFromEnv?: boolean; + keylogPath?: string; +} + +export interface NativeTlsDanger { + certVerification?: boolean; + verifyHostname?: boolean; + sni?: boolean; +} + +export interface NativeTlsPeerInfo { + peerCertificate?: Buffer; + peerCertificateChain?: Buffer[]; +} + export interface NativeRequestOptions { url: string; method: HttpMethod; @@ -34,10 +56,19 @@ export interface NativeRequestOptions { disableSystemProxy?: boolean; dns?: NativeDnsOptions; timeout?: number; + readTimeout?: number; + connectTimeout?: number; disableDefaultHeaders?: boolean; compress?: boolean; + http1Only?: boolean; + http2Only?: boolean; + localAddress?: string; + localAddresses?: NativeLocalAddresses; + interface?: string; tlsIdentity?: NativeTlsIdentity; ca?: NativeCertificateAuthority; + tlsDebug?: NativeTlsDebug; + tlsDanger?: NativeTlsDanger; } export interface NativeResponse { @@ -49,5 +80,6 @@ export interface NativeResponse { cookies: Record; setCookies?: string[]; timings?: RequestTimings; + tls?: NativeTlsPeerInfo; url: string; } diff --git a/src/types/shared.ts b/src/types/shared.ts index d4d8982..085bdbe 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -1,6 +1,16 @@ +import type { Buffer } from 'node:buffer'; + export type { BrowserProfile } from '../config/generated/browser-profiles'; -export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'; +export type HttpMethod = + | 'GET' + | 'POST' + | 'PUT' + | 'DELETE' + | 'PATCH' + | 'HEAD' + | 'OPTIONS' + | (string & {}); export type HeaderTuple = [string, string]; export type TlsBinaryInput = Buffer | ArrayBuffer | ArrayBufferView; @@ -18,6 +28,11 @@ export interface DnsOptions { hosts?: Record; } +export interface LocalAddresses { + ipv4?: string; + ipv6?: string; +} + export type AlpnProtocol = 'HTTP1' | 'HTTP2' | 'HTTP3'; export type AlpsProtocol = 'HTTP1' | 'HTTP2' | 'HTTP3'; export type TlsVersion = '1.0' | '1.1' | '1.2' | '1.3' | 'TLS1.0' | 'TLS1.1' | 'TLS1.2' | 'TLS1.3'; @@ -89,6 +104,22 @@ export interface TlsIdentityPfx { export type TlsIdentity = TlsIdentityPem | TlsIdentityPfx; +export interface TlsDebugOptions { + peerCertificates?: boolean; + keylog?: true | { path: string }; +} + +export interface TlsDangerOptions { + certVerification?: boolean; + verifyHostname?: boolean; + sni?: boolean; +} + +export interface TlsPeerInfo { + peerCertificate?: Buffer; + peerCertificateChain?: Buffer[]; +} + export interface CertificateAuthority { cert: TlsDataInput | TlsDataInput[]; includeDefaultRoots?: boolean; diff --git a/src/types/websocket.ts b/src/types/websocket.ts index 9360111..c21edc8 100644 --- a/src/types/websocket.ts +++ b/src/types/websocket.ts @@ -6,6 +6,8 @@ import type { HeadersInit, Http1Options, Http2Options, + TlsDangerOptions, + TlsDebugOptions, TlsIdentity, TlsOptions, } from './shared'; @@ -26,10 +28,22 @@ export interface WebSocketInit { tlsOptions?: TlsOptions; tlsIdentity?: TlsIdentity; ca?: CertificateAuthority; + tlsDebug?: TlsDebugOptions; + tlsDanger?: TlsDangerOptions; http1Options?: Http1Options; http2Options?: Http2Options; protocols?: string | string[]; binaryType?: WebSocketBinaryType; + forceHttp2?: boolean; + readBufferSize?: number; + writeBufferSize?: number; + maxWriteBufferSize?: number; + acceptUnmaskedFrames?: boolean; + maxFrameSize?: number; + maxMessageSize?: number; + localAddress?: string; + localAddresses?: import('./shared').LocalAddresses; + interface?: string; } export interface NativeWebSocketConnectOptions { @@ -45,7 +59,19 @@ export interface NativeWebSocketConnectOptions { disableDefaultHeaders?: boolean; tlsIdentity?: import('./native').NativeTlsIdentity; ca?: import('./native').NativeCertificateAuthority; + tlsDebug?: import('./native').NativeTlsDebug; + tlsDanger?: import('./native').NativeTlsDanger; protocols: string[]; + forceHttp2?: boolean; + readBufferSize?: number; + writeBufferSize?: number; + maxWriteBufferSize?: number; + acceptUnmaskedFrames?: boolean; + maxFrameSize?: number; + maxMessageSize?: number; + localAddress?: string; + localAddresses?: import('./native').NativeLocalAddresses; + interface?: string; } export interface NativeWebSocketConnection { diff --git a/src/websocket/index.ts b/src/websocket/index.ts index 6d10cff..67d001d 100644 --- a/src/websocket/index.ts +++ b/src/websocket/index.ts @@ -1,6 +1,15 @@ import { serializeEmulationOptions } from '../config/emulation'; -import { normalizeDnsOptions, normalizeProxyOptions } from '../config/network'; -import { normalizeCertificateAuthority, normalizeTlsIdentity } from '../config/tls'; +import { + normalizeDnsOptions, + normalizeLocalBindOptions, + normalizeProxyOptions, +} from '../config/network'; +import { + normalizeCertificateAuthority, + normalizeTlsDanger, + normalizeTlsDebug, + normalizeTlsIdentity, +} from '../config/tls'; import { WebSocketError } from '../errors'; import { loadCookiesIntoHeaders } from '../http/pipeline/cookies'; import { @@ -38,6 +47,43 @@ function resolveNativeTimeout( return { timeout: timeout === 0 ? 0 : Math.max(1, Math.ceil(timeout)) }; } +function resolveNativeWebSocketSize( + value: number | undefined, + name: 'maxFrameSize' | 'maxMessageSize' +): Partial< + Pick +> { + if (value === undefined) { + return {}; + } + + if (!Number.isFinite(value) || value <= 0) { + throw new TypeError(`${name} must be a finite positive number`); + } + + return { [name]: Math.max(1, Math.ceil(value)) }; +} + +function resolveNativeWebSocketBufferSize( + value: number | undefined, + name: 'readBufferSize' | 'writeBufferSize' | 'maxWriteBufferSize' +): Partial< + Pick< + import('../types').NativeWebSocketConnectOptions, + 'readBufferSize' | 'writeBufferSize' | 'maxWriteBufferSize' + > +> { + if (value === undefined) { + return {}; + } + + if (!Number.isFinite(value) || value <= 0) { + throw new TypeError(`${name} must be a finite positive number`); + } + + return { [name]: Math.max(1, Math.ceil(value)) }; +} + type OpenHandler = ((event: Event) => void) | null; type MessageHandler = ((event: MessageEvent) => void) | null; type CloseHandler = ((event: CloseEvent) => void) | null; @@ -236,6 +282,7 @@ export class WebSocket extends EventTarget { await loadCookiesIntoHeaders(init.cookieJar, this.url, headers); try { + const localBind = normalizeLocalBindOptions(init); const { proxy, disableSystemProxy } = normalizeProxyOptions(init.proxy); const connection = await nativeWebSocketConnect({ url: this.url, @@ -250,7 +297,17 @@ export class WebSocket extends EventTarget { disableDefaultHeaders: init.disableDefaultHeaders ?? false, tlsIdentity: normalizeTlsIdentity(init.tlsIdentity), ca: normalizeCertificateAuthority(init.ca), + tlsDebug: normalizeTlsDebug(init.tlsDebug), + tlsDanger: normalizeTlsDanger(init.tlsDanger), protocols, + forceHttp2: init.forceHttp2, + acceptUnmaskedFrames: init.acceptUnmaskedFrames, + ...resolveNativeWebSocketBufferSize(init.readBufferSize, 'readBufferSize'), + ...resolveNativeWebSocketBufferSize(init.writeBufferSize, 'writeBufferSize'), + ...resolveNativeWebSocketBufferSize(init.maxWriteBufferSize, 'maxWriteBufferSize'), + ...resolveNativeWebSocketSize(init.maxFrameSize, 'maxFrameSize'), + ...resolveNativeWebSocketSize(init.maxMessageSize, 'maxMessageSize'), + ...localBind, }); this.#handle = connection.handle;