From 65f1c483c8f44afcaca3360b752865c99ca00f8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 14 Apr 2026 14:34:48 +0200 Subject: [PATCH 1/9] fix: use browser-matching multipart boundary format Move multipart boundary generation from JS to Rust so it can use the configured browser fingerprint. Chrome profiles now produce `----WebKitFormBoundary*` and Firefox produces `---------------------------*` matching real browser behavior. Closes #432 --- Cargo.lock | 1 + impit-node/index.d.ts | 1 + impit-node/index.wrapper.js | 6 +++--- impit-node/request.js | 7 +++---- impit-node/src/lib.rs | 5 +++++ impit/Cargo.toml | 1 + impit/src/impit.rs | 36 ++++++++++++++++++++++++++++++++++++ 7 files changed, 50 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bb74d30e..4af61d5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1245,6 +1245,7 @@ dependencies = [ "log", "lol_html", "mime", + "rand", "reqwest", "rustls", "rustls-platform-verifier", diff --git a/impit-node/index.d.ts b/impit-node/index.d.ts index 531d6d42..d7cbbe2e 100644 --- a/impit-node/index.d.ts +++ b/impit-node/index.d.ts @@ -77,6 +77,7 @@ export declare class Impit { * ``` */ constructor(options?: ImpitOptions | undefined | null) + getMultipartBoundary(): string /** * Fetch a URL with the given options. * diff --git a/impit-node/index.wrapper.js b/impit-node/index.wrapper.js index cf8e70bb..7abcc21f 100644 --- a/impit-node/index.wrapper.js +++ b/impit-node/index.wrapper.js @@ -40,7 +40,7 @@ function canonicalizeHeaders(headers) { return []; } -async function parseFetchOptions(resource, init) { +async function parseFetchOptions(resource, init, boundary) { let url; let options = { ...init }; @@ -69,7 +69,7 @@ async function parseFetchOptions(resource, init) { options.headers = canonicalizeHeaders(options?.headers); if (options?.body) { - const { body: requestBody, type } = await castToTypedArray(options.body); + const { body: requestBody, type } = await castToTypedArray(options.body, boundary); options.body = requestBody; if (type && !options.headers.some(([key]) => key.toLowerCase() === 'content-type')) { options.headers.push(['Content-Type', type]); @@ -155,7 +155,7 @@ class Impit extends native.Impit { } async fetch(resource, init) { - const { url: initialUrl, signal, redirect, ...options } = await parseFetchOptions(resource, init); + const { url: initialUrl, signal, redirect, ...options } = await parseFetchOptions(resource, init, super.getMultipartBoundary()); // Check immediately if already aborted (before creating any promises) signal?.throwIfAborted(); diff --git a/impit-node/request.js b/impit-node/request.js index def45b91..2cbf113f 100644 --- a/impit-node/request.js +++ b/impit-node/request.js @@ -1,7 +1,6 @@ // Taken from https://github.com/nodejs/undici/blob/14e62db0d0cff4bea27357aa5bd14881459b27c7/lib/web/fetch/body.js#L120 // patched for use with Impit -async function generateMultipartFormData(formData) { - const boundary = `----formdata-impit-0${`${Math.random().toString().slice(0, 5)}`.padStart(11, '0')}`; +async function generateMultipartFormData(formData, boundary) { const prefix = `--${boundary}\r\nContent-Disposition: form-data`; /*! formdata-polyfill. MIT License. Jimmy Wärting */ @@ -76,7 +75,7 @@ async function generateMultipartFormData(formData) { // logic from https://github.com/nodejs/undici/blob/14e62db0d0cff4bea27357aa5bd14881459b27c7/lib/web/fetch/body.js#L90 -async function castToTypedArray(body) { +async function castToTypedArray(body, boundary) { let typedArray = body; let type = ""; @@ -95,7 +94,7 @@ async function castToTypedArray(body) { typedArray = new Uint8Array(await body.arrayBuffer()); type = body.type; } else if (body instanceof FormData) { - return await generateMultipartFormData(body); + return await generateMultipartFormData(body, boundary); } else if (body instanceof ReadableStream) { const reader = body.getReader(); const chunks = []; diff --git a/impit-node/src/lib.rs b/impit-node/src/lib.rs index 50ccfec1..153cc736 100644 --- a/impit-node/src/lib.rs +++ b/impit-node/src/lib.rs @@ -85,6 +85,11 @@ impl ImpitWrapper { }) } + #[napi(js_name = "getMultipartBoundary")] + pub fn get_multipart_boundary(&self) -> String { + self.inner.generate_multipart_boundary() + } + #[napi(ts_args_type = "resource: string | URL | Request, init?: RequestInit")] /// Fetch a URL with the given options. /// diff --git a/impit/Cargo.toml b/impit/Cargo.toml index e5cb51fc..f52d445b 100644 --- a/impit/Cargo.toml +++ b/impit/Cargo.toml @@ -21,3 +21,4 @@ rustls-platform-verifier = "0.6" webpki-root-certs = "1.0.5" hyper-util = "0.1.18" hyper = "1.7.0" +rand = "0.9" diff --git a/impit/src/impit.rs b/impit/src/impit.rs index 6e6cb9f8..85859da4 100644 --- a/impit/src/impit.rs +++ b/impit/src/impit.rs @@ -637,4 +637,40 @@ impl Impit { ) -> Result { self.make_request(Method::PATCH, url, body, options).await } + + pub fn generate_multipart_boundary(&self) -> String { + use rand::Rng; + + match &self.config.fingerprint { + Some(fp) => match fp.name.as_str() { + "Chrome" => { + const CHARS: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::rng(); + let suffix: String = (0..16) + .map(|_| CHARS[rng.random_range(0..CHARS.len())] as char) + .collect(); + format!("----WebKitFormBoundary{suffix}") + } + "Firefox" => { + let mut rng = rand::rng(); + let suffix: String = (0..20) + .map(|_| rng.random_range(0u8..10).to_string()) + .collect(); + format!("---------------------------{suffix}") + } + _ => Self::default_multipart_boundary(), + }, + None => Self::default_multipart_boundary(), + } + } + + fn default_multipart_boundary() -> String { + use rand::Rng; + let mut rng = rand::rng(); + let suffix: String = (0..16) + .map(|_| rng.random_range(0u8..10).to_string()) + .collect(); + format!("----formdata-impit-{suffix}") + } } From d18f5a3c56ac477bf45ee2e122d35af8f204e110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 14 Apr 2026 14:43:14 +0200 Subject: [PATCH 2/9] fix: match Firefox geckoformboundary format Firefox uses `----geckoformboundary` + two random uint64 in hex, not `---------------------------` + digits. See mozilla-firefox/firefox@main/dom/html/HTMLFormSubmission.cpp#L355 --- impit/src/impit.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/impit/src/impit.rs b/impit/src/impit.rs index 85859da4..c54950a8 100644 --- a/impit/src/impit.rs +++ b/impit/src/impit.rs @@ -654,10 +654,9 @@ impl Impit { } "Firefox" => { let mut rng = rand::rng(); - let suffix: String = (0..20) - .map(|_| rng.random_range(0u8..10).to_string()) - .collect(); - format!("---------------------------{suffix}") + let a: u64 = rng.random(); + let b: u64 = rng.random(); + format!("----geckoformboundary{a:x}{b:x}") } _ => Self::default_multipart_boundary(), }, From 304e1d9a1016cc803a4ed9a6951ec7ba70688a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 14 Apr 2026 14:54:05 +0200 Subject: [PATCH 3/9] refactor: lazily generate multipart boundary only for FormData bodies --- impit-node/index.wrapper.js | 6 +++--- impit-node/request.js | 4 ++-- impit/src/impit.rs | 4 +--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/impit-node/index.wrapper.js b/impit-node/index.wrapper.js index 7abcc21f..d04e54d3 100644 --- a/impit-node/index.wrapper.js +++ b/impit-node/index.wrapper.js @@ -40,7 +40,7 @@ function canonicalizeHeaders(headers) { return []; } -async function parseFetchOptions(resource, init, boundary) { +async function parseFetchOptions(resource, init, getBoundary) { let url; let options = { ...init }; @@ -69,7 +69,7 @@ async function parseFetchOptions(resource, init, boundary) { options.headers = canonicalizeHeaders(options?.headers); if (options?.body) { - const { body: requestBody, type } = await castToTypedArray(options.body, boundary); + const { body: requestBody, type } = await castToTypedArray(options.body, getBoundary); options.body = requestBody; if (type && !options.headers.some(([key]) => key.toLowerCase() === 'content-type')) { options.headers.push(['Content-Type', type]); @@ -155,7 +155,7 @@ class Impit extends native.Impit { } async fetch(resource, init) { - const { url: initialUrl, signal, redirect, ...options } = await parseFetchOptions(resource, init, super.getMultipartBoundary()); + const { url: initialUrl, signal, redirect, ...options } = await parseFetchOptions(resource, init, () => super.getMultipartBoundary()); // Check immediately if already aborted (before creating any promises) signal?.throwIfAborted(); diff --git a/impit-node/request.js b/impit-node/request.js index 2cbf113f..71b934e3 100644 --- a/impit-node/request.js +++ b/impit-node/request.js @@ -75,7 +75,7 @@ async function generateMultipartFormData(formData, boundary) { // logic from https://github.com/nodejs/undici/blob/14e62db0d0cff4bea27357aa5bd14881459b27c7/lib/web/fetch/body.js#L90 -async function castToTypedArray(body, boundary) { +async function castToTypedArray(body, getBoundary) { let typedArray = body; let type = ""; @@ -94,7 +94,7 @@ async function castToTypedArray(body, boundary) { typedArray = new Uint8Array(await body.arrayBuffer()); type = body.type; } else if (body instanceof FormData) { - return await generateMultipartFormData(body, boundary); + return await generateMultipartFormData(body, getBoundary()); } else if (body instanceof ReadableStream) { const reader = body.getReader(); const chunks = []; diff --git a/impit/src/impit.rs b/impit/src/impit.rs index c54950a8..9de81e69 100644 --- a/impit/src/impit.rs +++ b/impit/src/impit.rs @@ -1,6 +1,7 @@ use tokio::sync::RwLock; use log::debug; +use rand::Rng; use reqwest::{cookie::CookieStore, header::HeaderMap, Method, Response, Version}; use std::{fmt::Debug, net::IpAddr, str::FromStr, sync::Arc, time::Duration}; use url::Url; @@ -639,8 +640,6 @@ impl Impit { } pub fn generate_multipart_boundary(&self) -> String { - use rand::Rng; - match &self.config.fingerprint { Some(fp) => match fp.name.as_str() { "Chrome" => { @@ -665,7 +664,6 @@ impl Impit { } fn default_multipart_boundary() -> String { - use rand::Rng; let mut rng = rand::rng(); let suffix: String = (0..16) .map(|_| rng.random_range(0u8..10).to_string()) From 9dff6b564e54de4dfe4f2fcc300c145de4a8de24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 14 Apr 2026 15:32:53 +0200 Subject: [PATCH 4/9] fix: hide getMultipartBoundary from public types --- impit-node/index.d.ts | 1 - impit-node/src/lib.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/impit-node/index.d.ts b/impit-node/index.d.ts index d7cbbe2e..531d6d42 100644 --- a/impit-node/index.d.ts +++ b/impit-node/index.d.ts @@ -77,7 +77,6 @@ export declare class Impit { * ``` */ constructor(options?: ImpitOptions | undefined | null) - getMultipartBoundary(): string /** * Fetch a URL with the given options. * diff --git a/impit-node/src/lib.rs b/impit-node/src/lib.rs index 153cc736..333c444a 100644 --- a/impit-node/src/lib.rs +++ b/impit-node/src/lib.rs @@ -85,7 +85,7 @@ impl ImpitWrapper { }) } - #[napi(js_name = "getMultipartBoundary")] + #[napi(js_name = "getMultipartBoundary", skip_typescript)] pub fn get_multipart_boundary(&self) -> String { self.inner.generate_multipart_boundary() } From cd3783f4102f5a801838dd6809a3e7d5776c4acb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 14 Apr 2026 15:48:14 +0200 Subject: [PATCH 5/9] fix: use UUID boundary format for OkHttp profiles --- impit/src/impit.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/impit/src/impit.rs b/impit/src/impit.rs index 9de81e69..e3046cf3 100644 --- a/impit/src/impit.rs +++ b/impit/src/impit.rs @@ -657,12 +657,28 @@ impl Impit { let b: u64 = rng.random(); format!("----geckoformboundary{a:x}{b:x}") } + "OkHttp" => Self::uuid_boundary(), _ => Self::default_multipart_boundary(), }, None => Self::default_multipart_boundary(), } } + fn uuid_boundary() -> String { + let mut bytes = [0u8; 16]; + rand::rng().fill(&mut bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + format!( + "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", + u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), + u16::from_be_bytes([bytes[4], bytes[5]]), + u16::from_be_bytes([bytes[6], bytes[7]]), + u16::from_be_bytes([bytes[8], bytes[9]]), + u64::from_be_bytes([0, 0, bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]]), + ) + } + fn default_multipart_boundary() -> String { let mut rng = rand::rng(); let suffix: String = (0..16) From 76990c53e6d83a8ff1bb55f50730a06093eefd63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 14 Apr 2026 15:50:33 +0200 Subject: [PATCH 6/9] refactor: use uuid crate for OkHttp boundary generation --- Cargo.lock | 217 +++++++++++++++++++++++++++++++++++++++++++-- impit/Cargo.toml | 1 + impit/src/impit.rs | 13 +-- 3 files changed, 214 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4af61d5e..38576c4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "async-channel" version = "1.9.0" @@ -756,6 +762,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -900,11 +912,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -963,6 +988,15 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -971,7 +1005,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1212,6 +1246,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -1252,6 +1292,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "url", + "uuid", "webpki-root-certs", ] @@ -1298,7 +1339,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -1386,6 +1429,12 @@ dependencies = [ "log", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" @@ -1448,8 +1497,8 @@ dependencies = [ "cfg-if", "cssparser", "encoding_rs", - "foldhash", - "hashbrown", + "foldhash 0.2.0", + "hashbrown 0.16.1", "memchr", "mime", "precomputed-hash", @@ -1766,6 +1815,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1949,6 +2008,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radix_trie" version = "0.2.1" @@ -2657,6 +2722,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -2687,6 +2758,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "value-bag" version = "1.12.0" @@ -2733,6 +2815,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -2792,6 +2883,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -2805,6 +2918,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.91" @@ -3105,6 +3230,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" diff --git a/impit/Cargo.toml b/impit/Cargo.toml index f52d445b..d40371ba 100644 --- a/impit/Cargo.toml +++ b/impit/Cargo.toml @@ -22,3 +22,4 @@ webpki-root-certs = "1.0.5" hyper-util = "0.1.18" hyper = "1.7.0" rand = "0.9" +uuid = { version = "1", features = ["v4"] } diff --git a/impit/src/impit.rs b/impit/src/impit.rs index e3046cf3..23206948 100644 --- a/impit/src/impit.rs +++ b/impit/src/impit.rs @@ -665,18 +665,7 @@ impl Impit { } fn uuid_boundary() -> String { - let mut bytes = [0u8; 16]; - rand::rng().fill(&mut bytes); - bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 - format!( - "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", - u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), - u16::from_be_bytes([bytes[4], bytes[5]]), - u16::from_be_bytes([bytes[6], bytes[7]]), - u16::from_be_bytes([bytes[8], bytes[9]]), - u64::from_be_bytes([0, 0, bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]]), - ) + uuid::Uuid::new_v4().to_string() } fn default_multipart_boundary() -> String { From 12f70af834464d93e39ae69a6ac44223f9b1f477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Wed, 15 Apr 2026 14:30:12 +0200 Subject: [PATCH 7/9] refactor: move multipart boundary generation to BrowserFingerprint --- impit/src/fingerprint/mod.rs | 32 ++++++++++++++++++++++++++++++++ impit/src/impit.rs | 35 ++--------------------------------- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/impit/src/fingerprint/mod.rs b/impit/src/fingerprint/mod.rs index f77648ee..1d6bae8e 100644 --- a/impit/src/fingerprint/mod.rs +++ b/impit/src/fingerprint/mod.rs @@ -6,6 +6,8 @@ pub mod database; mod types; +use rand::Rng; + pub use types::*; /// A complete browser fingerprint containing TLS, HTTP/2, and HTTP header configurations. @@ -34,6 +36,36 @@ impl BrowserFingerprint { headers, } } + + pub fn generate_multipart_boundary(&self) -> String { + match self.name.as_str() { + "Chrome" => { + const CHARS: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::rng(); + let suffix: String = (0..16) + .map(|_| CHARS[rng.random_range(0..CHARS.len())] as char) + .collect(); + format!("----WebKitFormBoundary{suffix}") + } + "Firefox" => { + let mut rng = rand::rng(); + let a: u64 = rng.random(); + let b: u64 = rng.random(); + format!("----geckoformboundary{a:x}{b:x}") + } + "OkHttp" => uuid::Uuid::new_v4().to_string(), + _ => default_multipart_boundary(), + } + } +} + +pub fn default_multipart_boundary() -> String { + let mut rng = rand::rng(); + let suffix: String = (0..16) + .map(|_| rng.random_range(0u8..10).to_string()) + .collect(); + format!("----formdata-impit-{suffix}") } #[derive(Clone, Debug, PartialEq, Eq, Hash)] diff --git a/impit/src/impit.rs b/impit/src/impit.rs index 23206948..1bd7a75c 100644 --- a/impit/src/impit.rs +++ b/impit/src/impit.rs @@ -1,7 +1,6 @@ use tokio::sync::RwLock; use log::debug; -use rand::Rng; use reqwest::{cookie::CookieStore, header::HeaderMap, Method, Response, Version}; use std::{fmt::Debug, net::IpAddr, str::FromStr, sync::Arc, time::Duration}; use url::Url; @@ -641,38 +640,8 @@ impl Impit { pub fn generate_multipart_boundary(&self) -> String { match &self.config.fingerprint { - Some(fp) => match fp.name.as_str() { - "Chrome" => { - const CHARS: &[u8] = - b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let mut rng = rand::rng(); - let suffix: String = (0..16) - .map(|_| CHARS[rng.random_range(0..CHARS.len())] as char) - .collect(); - format!("----WebKitFormBoundary{suffix}") - } - "Firefox" => { - let mut rng = rand::rng(); - let a: u64 = rng.random(); - let b: u64 = rng.random(); - format!("----geckoformboundary{a:x}{b:x}") - } - "OkHttp" => Self::uuid_boundary(), - _ => Self::default_multipart_boundary(), - }, - None => Self::default_multipart_boundary(), + Some(fp) => fp.generate_multipart_boundary(), + None => crate::fingerprint::default_multipart_boundary(), } } - - fn uuid_boundary() -> String { - uuid::Uuid::new_v4().to_string() - } - - fn default_multipart_boundary() -> String { - let mut rng = rand::rng(); - let suffix: String = (0..16) - .map(|_| rng.random_range(0u8..10).to_string()) - .collect(); - format!("----formdata-impit-{suffix}") - } } From d476665c428401f69904881734d02054fd0683ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Wed, 15 Apr 2026 14:40:00 +0200 Subject: [PATCH 8/9] refactor: make parseFetchOptions a private method of Impit --- impit-node/index.wrapper.js | 98 ++++++++++++++++++------------------- impit-node/request.js | 4 +- 2 files changed, 49 insertions(+), 53 deletions(-) diff --git a/impit-node/index.wrapper.js b/impit-node/index.wrapper.js index d04e54d3..6f3d5886 100644 --- a/impit-node/index.wrapper.js +++ b/impit-node/index.wrapper.js @@ -40,56 +40,6 @@ function canonicalizeHeaders(headers) { return []; } -async function parseFetchOptions(resource, init, getBoundary) { - let url; - let options = { ...init }; - - // Handle Request instance - if (resource instanceof Request) { - url = resource.url; - options = { - method: resource.method, - headers: resource.headers, - body: resource.body, - ...init, // init overrides Request fields - }; - // Extract redirect from Request only if not already set by init. - // Request.redirect defaults to 'follow', which is indistinguishable - // from an explicit 'follow', so we only use it when non-default to - // avoid silently overriding instance-level followRedirects. - if (!('redirect' in options) && resource.redirect !== 'follow') { - options.redirect = resource.redirect; - } - } else if (resource.toString) { - url = resource.toString(); - } else { - url = resource; - } - - options.headers = canonicalizeHeaders(options?.headers); - - if (options?.body) { - const { body: requestBody, type } = await castToTypedArray(options.body, getBoundary); - options.body = requestBody; - if (type && !options.headers.some(([key]) => key.toLowerCase() === 'content-type')) { - options.headers.push(['Content-Type', type]); - } - } else { - delete options.body; - } - - return { - url: url, - method: options.method, - headers: options.headers, - body: options.body, - timeout: options.timeout, - forceHttp3: options.forceHttp3, - signal: options.signal, - redirect: options.redirect, - }; -} - function isRedirectStatus(status) { return [301, 302, 303, 307, 308].includes(status); } @@ -154,8 +104,54 @@ class Impit extends native.Impit { } } + async #parseFetchOptions(resource, init) { + let url; + let options = { ...init }; + + if (resource instanceof Request) { + url = resource.url; + options = { + method: resource.method, + headers: resource.headers, + body: resource.body, + ...init, + }; + if (!('redirect' in options) && resource.redirect !== 'follow') { + options.redirect = resource.redirect; + } + } else if (resource.toString) { + url = resource.toString(); + } else { + url = resource; + } + + options.headers = canonicalizeHeaders(options?.headers); + + if (options?.body) { + const boundary = options.body instanceof FormData ? super.getMultipartBoundary() : undefined; + const { body: requestBody, type } = await castToTypedArray(options.body, boundary); + options.body = requestBody; + if (type && !options.headers.some(([key]) => key.toLowerCase() === 'content-type')) { + options.headers.push(['Content-Type', type]); + } + } else { + delete options.body; + } + + return { + url: url, + method: options.method, + headers: options.headers, + body: options.body, + timeout: options.timeout, + forceHttp3: options.forceHttp3, + signal: options.signal, + redirect: options.redirect, + }; + } + async fetch(resource, init) { - const { url: initialUrl, signal, redirect, ...options } = await parseFetchOptions(resource, init, () => super.getMultipartBoundary()); + const { url: initialUrl, signal, redirect, ...options } = await this.#parseFetchOptions(resource, init); // Check immediately if already aborted (before creating any promises) signal?.throwIfAborted(); diff --git a/impit-node/request.js b/impit-node/request.js index 71b934e3..2cbf113f 100644 --- a/impit-node/request.js +++ b/impit-node/request.js @@ -75,7 +75,7 @@ async function generateMultipartFormData(formData, boundary) { // logic from https://github.com/nodejs/undici/blob/14e62db0d0cff4bea27357aa5bd14881459b27c7/lib/web/fetch/body.js#L90 -async function castToTypedArray(body, getBoundary) { +async function castToTypedArray(body, boundary) { let typedArray = body; let type = ""; @@ -94,7 +94,7 @@ async function castToTypedArray(body, getBoundary) { typedArray = new Uint8Array(await body.arrayBuffer()); type = body.type; } else if (body instanceof FormData) { - return await generateMultipartFormData(body, getBoundary()); + return await generateMultipartFormData(body, boundary); } else if (body instanceof ReadableStream) { const reader = body.getReader(); const chunks = []; From 51ec617dbb45daa1a47fb41dd314d22fb03417cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Wed, 15 Apr 2026 14:49:30 +0200 Subject: [PATCH 9/9] refactor: inline body serialization as private methods on Impit --- impit-node/index.wrapper.js | 98 +++++++++++++++++++++++++++- impit-node/request.js | 125 ------------------------------------ 2 files changed, 95 insertions(+), 128 deletions(-) delete mode 100644 impit-node/request.js diff --git a/impit-node/index.wrapper.js b/impit-node/index.wrapper.js index 6f3d5886..d91db141 100644 --- a/impit-node/index.wrapper.js +++ b/impit-node/index.wrapper.js @@ -1,4 +1,3 @@ -const { castToTypedArray } = require('./request.js'); const errors = require('./errors.js'); const { rethrowNativeError } = errors; let native = null; @@ -104,6 +103,100 @@ class Impit extends native.Impit { } } + // Taken from https://github.com/nodejs/undici/blob/14e62db0d0cff4bea27357aa5bd14881459b27c7/lib/web/fetch/body.js#L120 + async #generateMultipartFormData(formData) { + const boundary = super.getMultipartBoundary(); + const prefix = `--${boundary}\r\nContent-Disposition: form-data`; + + /*! formdata-polyfill. MIT License. Jimmy Wärting */ + const escape = (str) => str.replace(/\n/g, '%0A').replace(/\r/g, '%0D').replace(/"/g, '%22'); + const normalizeLinefeeds = (value) => value.replace(/\r?\n|\r/g, '\r\n'); + + const blobParts = []; + const rn = new Uint8Array([13, 10]); + const textEncoder = new TextEncoder(); + + for (const [name, value] of formData) { + if (typeof value === 'string') { + const chunk = textEncoder.encode(prefix + + `; name="${escape(normalizeLinefeeds(name))}"` + + `\r\n\r\n${normalizeLinefeeds(value)}\r\n`); + blobParts.push(chunk); + } else { + const chunk = textEncoder.encode(`${prefix}; name="${escape(normalizeLinefeeds(name))}"` + + (value.name ? `; filename="${escape(value.name)}"` : '') + '\r\n' + + `Content-Type: ${value.type || 'application/octet-stream'}\r\n\r\n`); + blobParts.push(chunk, value, rn); + } + } + + blobParts.push(textEncoder.encode(`--${boundary}--\r\n`)); + + const action = async function* () { + for (const part of blobParts) { + if (part.stream) { + yield* part.stream(); + } else { + yield part; + } + } + }; + + const parts = []; + for await (const part of action()) { + if (part instanceof Uint8Array) { + parts.push(part); + } else if (part instanceof Blob) { + parts.push(new Uint8Array(await part.arrayBuffer())); + } else { + throw new TypeError('Unsupported part type'); + } + } + const body = new Uint8Array(parts.reduce((acc, part) => acc + part.length, 0)); + let offset = 0; + for (const part of parts) { + body.set(part, offset); + offset += part.length; + } + + return { body, type: `multipart/form-data; boundary=${boundary}` }; + } + + // Based on https://github.com/nodejs/undici/blob/14e62db0d0cff4bea27357aa5bd14881459b27c7/lib/web/fetch/body.js#L90 + async #serializeBody(body) { + if (typeof body === 'string') { + return { body: new TextEncoder().encode(body), type: 'text/plain;charset=UTF-8' }; + } else if (body instanceof URLSearchParams) { + return { body: new TextEncoder().encode(body.toString()), type: 'application/x-www-form-urlencoded;charset=UTF-8' }; + } else if (body instanceof ArrayBuffer) { + return { body: new Uint8Array(body.slice()), type: '' }; + } else if (ArrayBuffer.isView(body)) { + return { body: new Uint8Array(body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)), type: '' }; + } else if (body instanceof Blob) { + return { body: new Uint8Array(await body.arrayBuffer()), type: body.type }; + } else if (body instanceof FormData) { + return await this.#generateMultipartFormData(body); + } else if (body instanceof ReadableStream) { + const reader = body.getReader(); + const chunks = []; + let done = false; + while (!done) { + const { done: streamDone, value } = await reader.read(); + done = streamDone; + if (value) chunks.push(value); + } + const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); + const typedArray = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + typedArray.set(chunk, offset); + offset += chunk.length; + } + return { body: typedArray, type: '' }; + } + return { body, type: '' }; + } + async #parseFetchOptions(resource, init) { let url; let options = { ...init }; @@ -128,8 +221,7 @@ class Impit extends native.Impit { options.headers = canonicalizeHeaders(options?.headers); if (options?.body) { - const boundary = options.body instanceof FormData ? super.getMultipartBoundary() : undefined; - const { body: requestBody, type } = await castToTypedArray(options.body, boundary); + const { body: requestBody, type } = await this.#serializeBody(options.body); options.body = requestBody; if (type && !options.headers.some(([key]) => key.toLowerCase() === 'content-type')) { options.headers.push(['Content-Type', type]); diff --git a/impit-node/request.js b/impit-node/request.js deleted file mode 100644 index 2cbf113f..00000000 --- a/impit-node/request.js +++ /dev/null @@ -1,125 +0,0 @@ -// Taken from https://github.com/nodejs/undici/blob/14e62db0d0cff4bea27357aa5bd14881459b27c7/lib/web/fetch/body.js#L120 -// patched for use with Impit -async function generateMultipartFormData(formData, boundary) { - const prefix = `--${boundary}\r\nContent-Disposition: form-data`; - - /*! formdata-polyfill. MIT License. Jimmy Wärting */ - const escape = (str) => str.replace(/\n/g, '%0A').replace(/\r/g, '%0D').replace(/"/g, '%22'); - const normalizeLinefeeds = (value) => value.replace(/\r?\n|\r/g, '\r\n'); - - // Set action to this step: run the multipart/form-data - // encoding algorithm, with object’s entry list and UTF-8. - // - This ensures that the body is immutable and can't be changed afterwords - // - That the content-length is calculated in advance. - // - And that all parts are pre-encoded and ready to be sent. - const blobParts = []; - const rn = new Uint8Array([13, 10]); // '\r\n' - const textEncoder = new TextEncoder(); - - for (const [name, value] of formData) { - if (typeof value === 'string') { - const chunk = textEncoder.encode(prefix + - `; name="${escape(normalizeLinefeeds(name))}"` + - `\r\n\r\n${normalizeLinefeeds(value)}\r\n`); - blobParts.push(chunk); - } else { - const chunk = textEncoder.encode(`${prefix}; name="${escape(normalizeLinefeeds(name))}"` + - (value.name ? `; filename="${escape(value.name)}"` : '') + '\r\n' + - `Content-Type: ${value.type || 'application/octet-stream'}\r\n\r\n`); - blobParts.push(chunk, value, rn); - } - } - - // CRLF is appended to the body to function with legacy servers and match other implementations. - // https://github.com/curl/curl/blob/3434c6b46e682452973972e8313613dfa58cd690/lib/mime.c#L1029-L1030 - // https://github.com/form-data/form-data/issues/63 - const chunk = textEncoder.encode(`--${boundary}--\r\n`); - blobParts.push(chunk); - - const action = async function* () { - for (const part of blobParts) { - if (part.stream) { - yield* part.stream(); - } else { - yield part; - } - } - }; - - const parts = []; - for await (const part of action()) { - if (part instanceof Uint8Array) { - parts.push(part); - } else if (part instanceof Blob) { - const arrayBuffer = await part.arrayBuffer(); - parts.push(new Uint8Array(arrayBuffer)); - } else { - throw new TypeError('Unsupported part type'); - } - } - const body = new Uint8Array(parts.reduce((acc, part) => acc + part.length, 0)); - let offset = 0; - for (const part of parts) { - body.set(part, offset); - offset += part.length; - } - - // Set type to `multipart/form-data; boundary=`, - // followed by the multipart/form-data boundary string generated - // by the multipart/form-data encoding algorithm. - return { - body, - type: `multipart/form-data; boundary=${boundary}`, - }; -} - - -// logic from https://github.com/nodejs/undici/blob/14e62db0d0cff4bea27357aa5bd14881459b27c7/lib/web/fetch/body.js#L90 -async function castToTypedArray(body, boundary) { - let typedArray = body; - let type = ""; - - if (typeof body === 'string') { - typedArray = new TextEncoder().encode(body); - type = 'text/plain;charset=UTF-8'; - } - else if (typedArray instanceof URLSearchParams) { - typedArray = new TextEncoder().encode(body.toString()); - type = 'application/x-www-form-urlencoded;charset=UTF-8'; - } else if (body instanceof ArrayBuffer) { - typedArray = new Uint8Array(body.slice()); - } else if (ArrayBuffer.isView(body)) { - typedArray = new Uint8Array(body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)); - } else if (body instanceof Blob) { - typedArray = new Uint8Array(await body.arrayBuffer()); - type = body.type; - } else if (body instanceof FormData) { - return await generateMultipartFormData(body, boundary); - } else if (body instanceof ReadableStream) { - const reader = body.getReader(); - const chunks = []; - - let done = false; - while (!done) { - const { done: streamDone, value } = await reader.read(); - done = streamDone; - if (value) { - chunks.push(value); - } - } - - const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); - typedArray = new Uint8Array(totalLength); - let offset = 0; - - for (const chunk of chunks) { - typedArray.set(chunk, offset); - offset += chunk.length; - } - } - - - return { body: typedArray, type }; -} - -exports.castToTypedArray = castToTypedArray;