From 3eb28da5c6c18db2dbbdf39f9060dfbbf0f0b3e5 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:52:57 +0000 Subject: [PATCH] js: serialize FormData bodies to multipart/form-data in fetch fetch() and XHR did String(body), so passing a FormData as the request body sent the literal "[object Object]" and lost the multipart payload along with its boundary. Servers expecting multipart/form-data replied 400 "Invalid boundary for multipart/form-data", which is what blocked booking.com: the AWS WAF challenge POSTs its proof-of-work solution to /mp_verify as FormData, so the verify always failed and no aws-waf-token was issued. Serialize FormData to a multipart/form-data body with a generated boundary and set the matching Content-Type, the way a real browser does; URLSearchParams bodies get application/x-www-form-urlencoded. XHR.send routes through fetch() so it is covered too. With this, obscura --stealth renders the real booking.com landing page the same as headless Chrome. --- crates/obscura-js/js/bootstrap.js | 50 +++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 445e8e0..cced65f 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -2906,6 +2906,51 @@ function _installWasmStreamingFallback() { } _installWasmStreamingFallback(); +// Serialize a FormData into a multipart/form-data body the way a browser does +// when it is passed as fetch()/XHR body. The previous shim did String(body), +// so a FormData became the literal "[object Object]" and the multipart payload +// (with its boundary) was lost; servers replied "Invalid boundary for +// multipart/form-data" (e.g. the AWS WAF challenge /mp_verify POST). +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 entries = fd._d || []; + for (let i = 0; i < entries.length; i++) { + const k = entries[i][0], v = entries[i][1]; + out += '--' + 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'; + } else { + out += 'Content-Disposition: form-data; name="' + k + '"\r\n\r\n' + String(v) + '\r\n'; + } + } + out += '--' + bnd + '--\r\n'; + return { boundary: bnd, body: out }; +} + +// Coerce a fetch()/XHR body into the string 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 ''; + if (initBody instanceof FormData) { + const mp = _formDataToMultipart(initBody); + 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')) { + headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'; + } + return initBody.toString(); + } + return typeof initBody === 'string' ? initBody : String(initBody); +} + globalThis.fetch = async (input, init = {}) => { let url = typeof input === "string" ? input @@ -2919,8 +2964,9 @@ globalThis.fetch = async (input, init = {}) => { } catch(e) { /* keep as-is if URL resolution fails */ } } const method = init.method || (input instanceof Request ? input.method : "GET"); - const hdrs = JSON.stringify(init.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : init.headers || {}); - const body = init.body ? String(init.body) : ""; + let _h = init.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : (init.headers || {}); + const body = _serializeBody(init.body, _h); + const hdrs = JSON.stringify(_h); const fetchMode = init.mode || (input instanceof Request ? input.mode : "cors"); const pageOrigin = (function() { try { const u = new URL(_domParse("document_url") || "about:blank"); return u.origin; } catch(e) { return ""; } })(); const raw = await Deno.core.ops.op_fetch_url(url, method, hdrs, body, pageOrigin, fetchMode);