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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions crates/obscura-browser/tests/binary_fetch_body.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
use std::io::{Read, Write};
use std::sync::{mpsc, Arc};
use std::time::Duration;

use obscura_browser::{BrowserContext, Page};

const BINARY_BODY: [u8; 4] = [0, 128, 255, 16];

fn spawn_echo_server() -> (String, mpsc::Receiver<Vec<u8>>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let (body_tx, body_rx) = mpsc::channel();

std::thread::spawn(move || {
for incoming in listener.incoming() {
let Ok(mut stream) = incoming else {
continue;
};
let body_tx = body_tx.clone();
std::thread::spawn(move || {
let mut request = Vec::new();
let mut chunk = [0u8; 2048];
let header_end = loop {
let read = stream.read(&mut chunk).unwrap();
if read == 0 {
return;
}
request.extend_from_slice(&chunk[..read]);
if let Some(end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") {
break end + 4;
}
};

let (path, content_length) = {
let headers = std::str::from_utf8(&request[..header_end]).unwrap();
let path = headers.split_whitespace().nth(1).unwrap_or("/").to_string();
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().unwrap())
})
.unwrap_or(0);
(path, content_length)
};
while request.len() < header_end + content_length {
let read = stream.read(&mut chunk).unwrap();
if read == 0 {
break;
}
request.extend_from_slice(&chunk[..read]);
}

if path == "/binary" {
body_tx
.send(request[header_end..header_end + content_length].to_vec())
.unwrap();
}

let (content_type, body) = if path == "/binary" {
("text/plain", "ok")
} else {
(
"text/html",
"<!doctype html><html><body>binary fetch fixture</body></html>",
)
};
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.unwrap();
});
}
});

(format!("http://{address}"), body_rx)
}

async fn assert_binary_fetch_body(stealth: bool) {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let (base_url, body_rx) = spawn_echo_server();
let context = Arc::new(BrowserContext::with_storage_and_network(
"binary-fetch".to_string(),
None,
stealth,
None,
None,
true,
));
let mut page = Page::new("binary-fetch-page".to_string(), context);
page.navigate(&base_url).await.unwrap();

page.evaluate(
r#"
(function() {
document.body.setAttribute('data-binary-fetch', 'pending');
fetch('/binary', {
method: 'POST',
headers: { 'content-type': 'application/octet-stream' },
body: new Uint8Array([0, 128, 255, 16]),
})
.then(function(response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
document.body.setAttribute('data-binary-fetch', 'done');
})
.catch(function(error) {
document.body.setAttribute('data-binary-fetch', 'error: ' + String(error));
});
})()
"#,
);

for _ in 0..20 {
page.settle(100).await;
let status = page.evaluate("document.body.getAttribute('data-binary-fetch')");
if status != serde_json::json!("pending") {
break;
}
}

assert_eq!(
page.evaluate("document.body.getAttribute('data-binary-fetch')"),
serde_json::json!("done")
);
assert_eq!(
body_rx.recv_timeout(Duration::from_secs(2)).unwrap(),
BINARY_BODY
);
}

#[tokio::test(flavor = "current_thread")]
async fn fetch_preserves_binary_request_body() {
assert_binary_fetch_body(false).await;
}

#[cfg(feature = "stealth")]
#[tokio::test(flavor = "current_thread")]
async fn stealth_fetch_preserves_binary_request_body() {
assert_binary_fetch_body(true).await;
}
79 changes: 47 additions & 32 deletions crates/obscura-js/js/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ async function __fetchDynClassicScript(task) {
body = _decodeDataScriptUrl(task.url);
} else {
const raw = await Deno.core.ops.op_fetch_url(
task.url, "GET", "{}", "", task.pageOrigin, "no-cors", "same-origin"
task.url, "GET", "{}", new Uint8Array(0), task.pageOrigin, "no-cors", "same-origin"
);
const parsed = JSON.parse(raw);
// The HTML script-fetch algorithm treats an unsuccessful HTTP response
Expand Down Expand Up @@ -552,7 +552,7 @@ async function _fetchLinkedCss(url, pageOrigin, depth = 0, seen = new Set()) {
if (depth > 4 || seen.has(url)) return "";
seen.add(url);
const raw = await Deno.core.ops.op_fetch_url(
url, "GET", "{}", "", pageOrigin, "no-cors", "same-origin"
url, "GET", "{}", new Uint8Array(0), pageOrigin, "no-cors", "same-origin"
);
const parsed = JSON.parse(raw);
if (parsed.blocked || parsed.status >= 400 || parsed.status === 0) {
Expand Down Expand Up @@ -6851,77 +6851,92 @@ function _formDataToMultipart(fd) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let bnd = '----WebKitFormBoundary';
for (let i = 0; i < 16; i++) bnd += chars[Math.floor(Math.random() * chars.length)];
let out = '';
const encoder = new TextEncoder();
const chunks = [];
let length = 0;
const append = (chunk) => {
const bytes = typeof chunk === 'string' ? encoder.encode(chunk) : _bodyToUint8Array(chunk);
chunks.push(bytes);
length += bytes.byteLength;
};
const entries = fd._d || [];
for (let i = 0; i < entries.length; i++) {
const k = entries[i][0], v = entries[i][1];
out += '--' + bnd + '\r\n';
append('--' + bnd + '\r\n');
if (v != null && typeof v === 'object' && v._bytes != null) {
out += 'Content-Disposition: form-data; name="' + k + '"; filename="' + (v.name || 'blob') + '"\r\n';
out += 'Content-Type: ' + (v.type || 'application/octet-stream') + '\r\n\r\n';
try { out += new TextDecoder().decode(v._bytes); } catch (e) {}
out += '\r\n';
append('Content-Disposition: form-data; name="' + k + '"; filename="' + (v.name || 'blob') + '"\r\n');
append('Content-Type: ' + (v.type || 'application/octet-stream') + '\r\n\r\n');
append(v._bytes);
append('\r\n');
} else {
out += 'Content-Disposition: form-data; name="' + k + '"\r\n\r\n' + String(v) + '\r\n';
append('Content-Disposition: form-data; name="' + k + '"\r\n\r\n' + String(v) + '\r\n');
}
}
out += '--' + bnd + '--\r\n';
append('--' + bnd + '--\r\n');
const out = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.byteLength;
}
return { boundary: bnd, body: out };
}

// Coerce a fetch()/XHR body into the string op_fetch_url expects, attaching a
// Coerce a fetch()/XHR body into the bytes op_fetch_url expects, attaching a
// Content-Type header for body types that need one (FormData, URLSearchParams).
function _serializeBody(initBody, headers) {
if (initBody == null || initBody === '') return '';
function _serializeBody(initBody, headers, synthesizeContentType = true) {
if (initBody == null || initBody === '') return new Uint8Array(0);
if (initBody instanceof FormData) {
const mp = _formDataToMultipart(initBody);
headers['Content-Type'] = 'multipart/form-data; boundary=' + mp.boundary;
if (synthesizeContentType) headers['Content-Type'] = 'multipart/form-data; boundary=' + mp.boundary;
return mp.body;
}
if (initBody instanceof URLSearchParams) {
if (!Object.keys(headers).some(k => k.toLowerCase() === 'content-type')) {
if (synthesizeContentType && !Object.keys(headers).some(k => k.toLowerCase() === 'content-type')) {
headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
}
return initBody.toString();
return new TextEncoder().encode(initBody.toString());
}
if (typeof Blob !== 'undefined' && initBody instanceof Blob) {
if (initBody.type && !Object.keys(headers).some(k => k.toLowerCase() === 'content-type')) {
if (synthesizeContentType && initBody.type && !Object.keys(headers).some(k => k.toLowerCase() === 'content-type')) {
headers['Content-Type'] = initBody.type;
}
return _bytesToBinaryString(_bodyToUint8Array(initBody));
return _bodyToUint8Array(initBody);
}
if (typeof ArrayBuffer !== 'undefined' && initBody instanceof ArrayBuffer) {
const bytes = new Uint8Array(initBody);
let s = ''; for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return s;
return new Uint8Array(initBody);
}
if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(initBody) && initBody.buffer instanceof ArrayBuffer) {
const bytes = new Uint8Array(initBody.buffer, initBody.byteOffset, initBody.byteLength);
let s = ''; for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return s;
return new Uint8Array(initBody.buffer, initBody.byteOffset, initBody.byteLength);
}
return typeof initBody === 'string' ? initBody : String(initBody);
return new TextEncoder().encode(typeof initBody === 'string' ? initBody : String(initBody));
}

globalThis.fetch = async (input, init = {}) => {
init = init || {};
const request = input instanceof Request ? input : null;
let url = typeof input === "string"
? input
: (input instanceof Request
? input.url
: (request
? request.url
: ((typeof URL === 'function' && input instanceof URL) ? input.href : (input?.url || input?.href || String(input || ""))));
// Always resolve: the URL parser, not a "://" substring search, decides
// whether the input is absolute. _resolveUrl leaves absolute URLs
// unchanged and keeps unparseable input as-is.
url = _resolveUrl(url);
const method = init.method || (input instanceof Request ? input.method : "GET");
let _h = init.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : (init.headers || {});
const body = _serializeBody(init.body, _h);
const method = init.method || (request ? request.method : "GET");
const headers = init.headers !== undefined ? init.headers : (request ? request.headers : undefined);
let _h = headers instanceof Headers ? Object.fromEntries(headers.entries()) : (headers || {});
const inheritsRequestBody = init.body === undefined && request !== null;
const initBody = init.body !== undefined
? init.body
: (request ? request.body : undefined);
const body = _serializeBody(initBody, _h, !(inheritsRequestBody && init.headers !== undefined));
const hdrs = JSON.stringify(_h);
const fetchMode = init.mode || (input instanceof Request ? input.mode : "cors");
const fetchMode = init.mode || (request ? request.mode : "cors");
const fetchCredentials = init.credentials !== undefined
? String(init.credentials)
: (input instanceof Request ? input.credentials : "same-origin");
: (request ? request.credentials : "same-origin");
if (fetchCredentials !== "omit" && fetchCredentials !== "same-origin" && fetchCredentials !== "include") {
throw new TypeError("Failed to execute 'fetch': '" + fetchCredentials + "' is not a valid RequestCredentials value");
}
Expand Down
10 changes: 5 additions & 5 deletions crates/obscura-js/src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ use std::sync::Arc;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use deno_core::op2;
use deno_core::Extension;
#[cfg(feature = "render")]
use deno_core::JsBuffer;
use deno_core::v8;
use deno_core::OpState;
Expand Down Expand Up @@ -2160,11 +2159,12 @@ async fn op_fetch_url(
#[string] url: String,
#[string] method: String,
#[string] headers_json: String,
#[string] body: String,
#[buffer] body: JsBuffer,
#[string] origin: String,
#[string] mode: String,
#[string] credentials: String,
) -> Result<String, deno_error::JsErrorBox> {
let body = body.to_vec();
tracing::debug!(
"op_fetch_url called: {} {} (intercept check pending)",
method,
Expand Down Expand Up @@ -2257,7 +2257,7 @@ async fn op_fetch_url(
let mut override_url: Option<String> = None;
let mut override_method: Option<String> = None;
let mut override_headers: Option<HashMap<String, String>> = None;
let mut override_body: Option<String> = None;
let mut override_body: Option<Vec<u8>> = None;

if let Some((tx, request_id)) = intercept_tx {
let custom_headers: HashMap<String, String> =
Expand Down Expand Up @@ -2307,7 +2307,7 @@ async fn op_fetch_url(
override_url = url;
override_method = method;
override_headers = headers;
override_body = body;
override_body = body.map(String::into_bytes);
tracing::debug!(
"Interception: continue (overrides url={} method={} headers={} body={})",
override_url.is_some(),
Expand Down Expand Up @@ -2760,7 +2760,7 @@ async fn stealth_fetch_all(
url: String,
method: String,
custom_headers: HashMap<String, String>,
body: String,
body: Vec<u8>,
page_origin: String,
mode: String,
credentials: FetchCredentials,
Expand Down
Loading