From e688414d1884d5da6928a12307b054d2240d9e5f Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Fri, 16 Oct 2026 12:54:20 +0300 Subject: [PATCH] fix(client): replace lone UTF-16 surrogates instead of dropping the message Chrome sends binary request bodies, e.g. a Blob upload paused by blacklist=/whitelist=, and broken page text with lone surrogates that the json gem rejects. The fallback unescaped every \uXXXX, which broke messages with escaped control characters, so Fetch.requestPaused was dropped and the request hung. Only lone surrogates are replaced with U+FFFD now. Refs rubycdp/cuprite#308 --- CHANGELOG.md | 4 ++++ lib/ferrum/client/web_socket.rb | 11 +++++------ spec/network_spec.rb | 12 ++++++++++++ spec/node_spec.rb | 2 +- spec/support/application.rb | 4 ++++ 5 files changed, 26 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6f90e8..fd2beb1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Changed ### Fixed +- A paused request with a binary body, e.g. a `Blob` upload with `blacklist=`/`whitelist=` set, hung forever: Chrome + sends the body with lone UTF-16 surrogates that the `json` gem rejects, and the fallback that unescaped them broke + the message further, so `Fetch.requestPaused` was dropped and the request was never continued. Lone surrogates are + now replaced with U+FFFD, so broken text from the page reads as `�` instead of `???` [rubycdp/cuprite#308] - Passing a node resolved on the previous page to `evaluate`, e.g. when the page navigates while Cuprite searches within an element, raised `Ferrum::BrowserError: Argument should belong to the same JavaScript world as target object`. It's `Ferrum::NodeNotFoundError` now, so callers can treat the node as stale and find it again diff --git a/lib/ferrum/client/web_socket.rb b/lib/ferrum/client/web_socket.rb index 1069e22f..0ed40b13 100644 --- a/lib/ferrum/client/web_socket.rb +++ b/lib/ferrum/client/web_socket.rb @@ -16,6 +16,7 @@ class WebSocket WEBSOCKET_BUG_SLEEP = 0.05 DEFAULT_PORTS = { "ws" => 80, "wss" => 443 }.freeze SKIP_LOGGING_SCREENSHOTS = !ENV["FERRUM_LOGGING_SCREENSHOTS"] + ESCAPE_OR_LONE_SURROGATE = /\\u[dD][89abAB]\h{2}\\u[dD][c-fC-F]\h{2}|\\u([dD][89a-fA-F]\h{2})|\\./ attr_reader :url, :messages @@ -223,14 +224,12 @@ def safely_parse_json(data) rescue JSON::NestingError # nop rescue JSON::ParserError - safely_parse_escaped_json(data) + safely_parse_lone_surrogates(data) end - def safely_parse_escaped_json(data) - unescaped_unicode = - data.gsub(/\\u([\da-fA-F]{4})/) { |_| [::Regexp.last_match(1)].pack("H*").unpack("n*").pack("U*") } - escaped_data = unescaped_unicode.encode("UTF-8", "UTF-8", undef: :replace, invalid: :replace, replace: "?") - JSON.parse(escaped_data, max_nesting: false) + def safely_parse_lone_surrogates(data) + replaced = data.gsub(ESCAPE_OR_LONE_SURROGATE) { |escape| ::Regexp.last_match(1) ? "\\ufffd" : escape } + JSON.parse(replaced, max_nesting: false) rescue JSON::ParserError # nop end diff --git a/spec/network_spec.rb b/spec/network_spec.rb index d46773c7..8a415120 100644 --- a/spec/network_spec.rb +++ b/spec/network_spec.rb @@ -227,6 +227,18 @@ expect(page.body).not_to include("Disappearing header") end + it "continues requests with a binary body" do + network.blacklist = /jquery/ + page.go_to("/") + + size = page.evaluate_async(<<~JS, page.timeout) + const bytes = new Uint8Array([0x22, 0x5c, 0x00, 0x0a, 0xed, 0xa0, 0x80, 0x41]); + fetch("/upload", { method: "PUT", body: new Blob([bytes]) }).then((r) => r.text()).then(arguments[0]); + JS + + expect(size).to eq("8") + end + it "blocks with single pattern" do network.blacklist = /jquery/ diff --git a/spec/node_spec.rb b/spec/node_spec.rb index 8ef826be..3c86263d 100644 --- a/spec/node_spec.rb +++ b/spec/node_spec.rb @@ -482,7 +482,7 @@ browser.go_to("/unicode") node = browser.at_css("#invalid") - expect(node.text).to eq("Havregrynskake med marengs og nøtter ???") + expect(node.text).to eq("Havregrynskake med marengs og nøtter \uFFFD") end it "works with curly utf8" do diff --git a/spec/support/application.rb b/spec/support/application.rb index 773d8221..0a154fb6 100644 --- a/spec/support/application.rb +++ b/spec/support/application.rb @@ -88,6 +88,10 @@ def authorized?(login, password) "Current host is #{request.scheme}://#{request.host}:#{request.port}" end + put "/upload" do + request.body.read.bytesize.to_s + end + get "/unicode" do File.read("#{FERRUM_VIEWS}/unicode.html") end