fix(cdp): expose main-resource response body and real mime on navigation

Navigating straight to an image, PDF, or other resource could not read the
main response body over CDP, and Page.frameNavigated always reported
text/html.

Two problems: the main resource's body was stored under its internal
request id, but the client sees it as the navigation loaderId (Chrome's
requestId === loaderId convention), so Network.getResponseBody(loaderId)
missed. And binary bodies were stored as UTF-8-lossy text, corrupting the
bytes. Alias the stored body to the loaderId, store non-text-like content
types (images, PDF, octet-stream) base64, and report the response's real
Content-Type in frameNavigated. HTML pages are unchanged.

Fixes #340.
This commit is contained in:
SGavrl committed 2026-07-01 06:55:37 +00:00
1 parent d5d9b7788f
commit 630f9894f6
2 files changed
+69 -2

No files matched your search

+45 -1
View File
@@ -984,6 +984,10 @@ impl Page {
PageError::NetworkError(e.to_string()) PageError::NetworkError(e.to_string())
})?; })?;
// Store binary main resources (images, PDFs, octet-stream) base64 so
// Network.getResponseBody returns intact bytes. A UTF-8-lossy text store
// corrupts them (issue #340). Text-like types stay as text.
let main_is_binary = !is_text_like_content_type(response.content_type());
self.record_network_event_with_body( self.record_network_event_with_body(
url.as_str(), url.as_str(),
"GET", "GET",
@@ -991,7 +995,7 @@ impl Page {
response.status, response.status,
&response.headers, &response.headers,
&response.body, &response.body,
false, main_is_binary,
); );
if !response.redirected_from.is_empty() { if !response.redirected_from.is_empty() {
@@ -1469,6 +1473,23 @@ impl Page {
}) })
} }
/// Make the body stored under `from_id` also retrievable under `to_id`.
/// The main navigation resource is stored under its internal request id, but
/// the CDP layer reports it to clients with the navigation's loaderId as the
/// requestId (Chrome's `requestId === loaderId` convention). Without this
/// alias, `Network.getResponseBody(loaderId)` misses and a client navigating
/// straight to an image or other resource cannot read the main-response body
/// (issue #340).
pub fn alias_response_body(&mut self, from_id: &str, to_id: &str) {
if from_id == to_id || self.response_bodies.contains_key(to_id) {
return;
}
if let Some(body) = self.response_bodies.get(from_id).cloned() {
self.response_bodies.insert(to_id.to_string(), body);
self.response_body_order.push_back(to_id.to_string());
}
}
pub fn clear_response_bodies(&mut self) { pub fn clear_response_bodies(&mut self) {
self.response_bodies.clear(); self.response_bodies.clear();
self.response_body_order.clear(); self.response_body_order.clear();
@@ -1678,6 +1699,29 @@ impl From<ObscuraNetError> for PageError {
} }
} }
/// Whether a Content-Type is text-like and can be stored/returned as a UTF-8
/// string. Everything else (images, PDF, fonts, octet-stream) is binary and must
/// be base64-encoded so Network.getResponseBody returns intact bytes.
fn is_text_like_content_type(content_type: Option<&str>) -> bool {
let ct = match content_type {
Some(c) => c.split(';').next().unwrap_or(c).trim().to_ascii_lowercase(),
// No Content-Type: assume text (matches the HTML-parse default).
None => return true,
};
if ct.is_empty() {
return true;
}
ct.starts_with("text/")
|| ct == "application/json"
|| ct == "application/xml"
|| ct == "application/xhtml+xml"
|| ct == "application/javascript"
|| ct == "application/ecmascript"
|| ct == "image/svg+xml"
|| ct.ends_with("+json")
|| ct.ends_with("+xml")
}
fn response_body_entry_limit() -> usize { fn response_body_entry_limit() -> usize {
std::env::var("OBSCURA_NETWORK_BODY_BUFFER_ENTRIES") std::env::var("OBSCURA_NETWORK_BODY_BUFFER_ENTRIES")
.ok() .ok()
+24 -1
View File
@@ -41,6 +41,26 @@ pub fn emit_navigation_events(
.iter() .iter()
.position(|ev| ev.resource_type == "Document" && ev.url == page_url); .position(|ev| ev.resource_type == "Document" && ev.url == page_url);
// The main resource's body is stored under its internal request id, but the
// client sees it as `loader_id` (the requestId we report above). Alias it so
// Network.getResponseBody(loaderId) resolves, which is the only way a client
// navigating straight to an image/PDF/other resource can read the main body
// (issue #340). Also read the real Content-Type so frameNavigated reports the
// actual mime instead of a hardcoded text/html.
let mut nav_mime = "text/html".to_string();
if let Some(idx) = nav_idx {
let internal_id = &network_events[idx].request_id;
if let Some(ct) = network_events[idx].response_headers.get("content-type") {
// Strip any `; charset=...` parameter; frameNavigated wants the essence.
nav_mime = ct.split(';').next().unwrap_or(ct).trim().to_string();
}
if internal_id != loader_id {
if let Some(page) = ctx.get_page_mut(page_id) {
page.alias_response_body(internal_id, loader_id);
}
}
}
// Playwright needs `Network.requestWillBeSent` for the main document to // Playwright needs `Network.requestWillBeSent` for the main document to
// arrive BEFORE `Page.frameNavigated` (issue #190). // arrive BEFORE `Page.frameNavigated` (issue #190).
if let Some(idx) = nav_idx { if let Some(idx) = nav_idx {
@@ -56,7 +76,7 @@ pub fn emit_navigation_events(
let mut phase1 = vec![ let mut phase1 = vec![
CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() }, CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() },
CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() }, CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() },
CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": "text/html", "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() }, CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": nav_mime, "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() },
CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() }, CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() },
]; ];
let world_names: Vec<String> = if ctx.isolated_worlds.is_empty() { let world_names: Vec<String> = if ctx.isolated_worlds.is_empty() {
@@ -359,6 +379,9 @@ pub async fn handle(
Ok(json!({})) Ok(json!({}))
} }
"setInterceptFileChooserDialog" => Ok(json!({})), "setInterceptFileChooserDialog" => Ok(json!({})),
// Obscura does not download files to disk, so there is no behavior to
// configure; ack it so clients that set it do not warn (issue #340).
"setDownloadBehavior" => Ok(json!({})),
"getLayoutMetrics" => { "getLayoutMetrics" => {
// Obscura has no visual layout engine, so we return a fixed // Obscura has no visual layout engine, so we return a fixed
// 1280x720 viewport (Chrome's default) and try to derive the // 1280x720 viewport (Chrome's default) and try to derive the