Skip to content
Merged
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
38 changes: 38 additions & 0 deletions docs/validation/iframe-url-history-896-898.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Iframe URL reflection and nested history — 21 September 2026

The native `iframe-navigation-lifecycle` gate fails on clean merged WebScene
`06d6a4f5` and `99cf2f86`: its `src` attribute is silently rewritten from
`./fake.html?id=allowed` to an absolute URL after remote navigation.
The gate otherwise observes 101 beforeunload events and the ordered
pagehide/visibilitychange/unload sequence. This establishes #896 as a
merged-main bug, separate from the merged parent-messaging PR #895.

The focused dependent changes are #896 (`61415b87`: preserve the authored
attribute and store the pinned resolved URL separately for fetching,
origin checks and speculative preparation), #897 (`1fe801ca`: resolve
fragment-only and query-only references against the current document path),
and #898 (forward `popstate`/`hashchange` to a same-origin stable WindowProxy,
retain at most 128 listeners per frame/event and release them at detach,
while refusing to construct an owner document for an opaque proxy).
Cross-document traversal may serve one of its four document loads from a
fresh origin-partitioned cache entry; the gate accepts three or four
network requests while requiring four exact page/document replacements
and the browser-order lifecycle events.

On the top branch, a native Release build on macOS with the portable local
V8 monolith and V8 Inspector disabled passed:

| Focused gate | Result |
| --- | --- |
| `iframe-navigation-lifecycle` | 100 create/load/write/replace/detach cycles, 1 MiB HTML, p95 3.63 ms, heap 1,849,528 bytes before and after; opaque and cross-origin DOM denial and history checks passed |
| `iframe-cross-origin-parent-message` | 71.36 ms; exact-origin transfer, blocked parent DOM and source identity passed |
| `history-same-document` | pass; no unintended layout or scene work |
| `query-iframe-worker-bootstrap` | 20 cycles, p95 145.27 ms, heap 1,849,540 bytes before and after |

The direct URL assertions cover `#one` retaining the path and query,
`?next=1#two` replacing the query without dropping the path, and ordinary
relative sibling traversal. Upstream Linux native/portable V8 and metadata
CI on the top PR remain required before merging the stack. The unchanged
Code OSS installed Release must then be rebuilt from exact merged SDK heads
for Markdown visuals and runtime acceptance under #264/#267; this native
fixture alone does not qualify the webview pane.
Original file line number Diff line number Diff line change
Expand Up @@ -1480,6 +1480,7 @@
frame_local_storage.erase(id);
frame_load_listeners.erase(id);
frame_dom_content_loaded_listeners.erase(id);
frame_history_event_listeners.erase(id);
}
for (auto& [type, listeners] : frame_event_listeners) {
static_cast<void>(type);
Expand Down Expand Up @@ -2086,6 +2087,7 @@
frame_local_storage.erase(id);
frame_load_listeners.erase(id);
frame_dom_content_loaded_listeners.erase(id);
frame_history_event_listeners.erase(id);
window_selections.erase(id);
}
if (node_ids.contains(focused_document_owner_id)) {
Expand Down Expand Up @@ -3135,14 +3137,19 @@
const auto source = node.attributes.contains("src") ? node.attributes["src"] : "about:blank";
location->Set(local_context, js_string(isolate, "href"), js_string(isolate, source.c_str())).Check();
object->Set(local_context, js_string(isolate, "location"), location).Check();
const auto actual_document = actual_frame_documents.find(node.id);
object->Set(
local_context,
js_string(isolate, "document"),
actual_document != actual_frame_documents.end()
&& !actual_document->second.IsEmpty()
? v8::Local<v8::Value>(actual_document->second.Get(isolate))
: v8::Local<v8::Value>(frame_document(node))).Check();
// A masked cross-origin WindowProxy must not create or publish an
// owner-realm provisional document. The active child document belongs
// to its own realm and cannot be read from this owner.
if (iframe_owner_allows_same_origin_access(node)) {
const auto actual_document = actual_frame_documents.find(node.id);
object->Set(
local_context,
js_string(isolate, "document"),
actual_document != actual_frame_documents.end()
&& !actual_document->second.IsEmpty()
? v8::Local<v8::Value>(actual_document->second.Get(isolate))
: v8::Local<v8::Value>(frame_document(node))).Check();
}
object->Set(local_context, js_string(isolate, "window"), object).Check();
object->Set(local_context, js_string(isolate, "self"), object).Check();
object->Set(local_context, js_string(isolate, "parent"), local_context->Global()).Check();
Expand All @@ -3160,9 +3167,11 @@
local_context,
js_string(isolate, "blur"),
v8::Function::New(local_context, cross_origin_window_noop).ToLocalChecked()).Check();
install_screen(local_context, object);
install_performance_clock(local_context, object);
install_performance_timeline_on_object(local_context, object);
if (iframe_owner_allows_same_origin_access(node)) {
install_screen(local_context, object);
install_performance_clock(local_context, object);
install_performance_timeline_on_object(local_context, object);
}
initializing_frame_window_id = 0U;
frame_windows[node.id].Reset(isolate, object);
return object;
Expand Down Expand Up @@ -6757,12 +6766,65 @@
static_cast<void>(dispatcher.As<v8::Function>()->Call(
local_context, target, 1, arguments));
perform_microtask_checkpoint();
if (!try_catch.HasCaught()) return true;
frame_last_error_value = "Nested history event dispatch failed: "
+ describe_reported_exception(try_catch, local_context);
++frame_script_error_count;
report_document_script_error(frame_last_error_value);
return false;
if (try_catch.HasCaught()) {
frame_last_error_value = "Nested history event dispatch failed: "
+ describe_reported_exception(try_catch, local_context);
++frame_script_error_count;
report_document_script_error(frame_last_error_value);
return false;
}
// The stable parent-owned WindowProxy is a separate V8 object from
// the inner global. Its listeners must receive the same event when
// the owner can still access this browsing context's origin.
auto* frame = active_nested_frame_for_context(local_context);
if (frame == nullptr || !iframe_owner_allows_same_origin_access(*frame))
return true;
const auto frame_id = frame->id;
const auto known = frame_history_event_listeners.find(frame_id);
if (known == frame_history_event_listeners.end()) return true;
const auto by_type = known->second.find(std::string(type));
if (by_type == known->second.end()) return true;
std::vector<v8::Local<v8::Function>> listeners;
for (auto& persistent : by_type->second) {
if (!persistent.IsEmpty()) listeners.push_back(persistent.Get(isolate));
}
auto owner_context = context.Get(isolate);
v8::Context::Scope owner_scope(owner_context);
auto proxy = frame_window(*frame);
auto owner_event = create_event_instance(owner_context);
owner_event->Set(owner_context, js_string(isolate, "type"),
js_string(isolate, std::string(type).c_str())).Check();
owner_event->Set(owner_context, js_string(isolate, "target"), proxy).Check();
owner_event->Set(owner_context, js_string(isolate, "currentTarget"), proxy).Check();
owner_event->Set(owner_context, js_string(isolate, "bubbles"),
v8::False(isolate)).Check();
owner_event->Set(owner_context, js_string(isolate, "cancelable"),
v8::False(isolate)).Check();
if (type == "popstate") {
owner_event->Set(owner_context, js_string(isolate, "state"), state).Check();
} else {
owner_event->Set(owner_context, js_string(isolate, "oldURL"),
js_string(isolate, std::string(old_url).c_str())).Check();
owner_event->Set(owner_context, js_string(isolate, "newURL"),
js_string(isolate, std::string(new_url).c_str())).Check();
}
v8::Local<v8::Value> owner_arguments[]{owner_event};
for (auto callback : listeners) {
if (document.find_by_native_id(frame_id) == nullptr
|| !iframe_owner_allows_same_origin_access(*frame)) break;
v8::TryCatch owner_try_catch(isolate);
static_cast<void>(callback->Call(
owner_context, proxy, 1, owner_arguments));
if (owner_try_catch.HasCaught()) {
frame_last_error_value = "Iframe owner history callback failed: "
+ describe_reported_exception(owner_try_catch, owner_context);
++frame_script_error_count;
report_document_script_error(frame_last_error_value);
return false;
}
}
perform_microtask_checkpoint();
return true;
}

static std::string strip_url_query_and_fragment(std::string value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,16 @@
&& existing.Get(info.GetIsolate())->StrictEquals(callback);
});
if (!duplicate) listeners.emplace_back(info.GetIsolate(), callback);
} else if (type == "popstate" || type == "hashchange") {
auto& listeners = self->frame_history_event_listeners[node->id][type];
const auto callback = info[1].As<v8::Function>();
const auto duplicate = std::any_of(
listeners.begin(), listeners.end(), [&](auto& existing) {
return !existing.IsEmpty()
&& existing.Get(info.GetIsolate())->StrictEquals(callback);
});
if (!duplicate && listeners.size() < 128U)
listeners.emplace_back(info.GetIsolate(), callback);
}
}

Expand All @@ -1260,6 +1270,21 @@
if (node == nullptr || info.Length() < 2 || !info[1]->IsFunction()) return;
const auto type = to_utf8(info.GetIsolate(), info[0]);
const auto callback = info[1].As<v8::Function>();
if (type == "popstate" || type == "hashchange") {
const auto frame = self->frame_history_event_listeners.find(node->id);
if (frame == self->frame_history_event_listeners.end()) return;
const auto found = frame->second.find(type);
if (found == frame->second.end()) return;
std::erase_if(found->second, [&](auto& existing) {
const auto matches = !existing.IsEmpty()
&& existing.Get(info.GetIsolate())->StrictEquals(callback);
if (matches) existing.Reset();
return matches;
});
if (found->second.empty()) frame->second.erase(found);
if (frame->second.empty()) self->frame_history_event_listeners.erase(frame);
return;
}
if (type == "DOMContentLoaded") {
const auto found = self->frame_dom_content_loaded_listeners.find(node->id);
if (found == self->frame_dom_content_loaded_listeners.end()) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,9 @@
std::unordered_map<uint32_t, v8::Global<v8::Function>> frame_load_listeners;
std::unordered_map<uint32_t, std::vector<v8::Global<v8::Function>>>
frame_dom_content_loaded_listeners;
std::unordered_map<uint32_t,
std::unordered_map<std::string, std::vector<v8::Global<v8::Function>>>>
frame_history_event_listeners;
std::unordered_map<std::string, std::vector<event_listener_registration>>
frame_event_listeners;
struct drag_session_state final {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -760,9 +760,14 @@ void test_same_origin_iframe_navigation_document_replacement()
"nested cross-document history diverged: "
+ evaluate(engine, "JSON.stringify(__crossDocumentHistory)",
"iframe-cross-document-history-result-detail.js"));
require(server.requests.load(std::memory_order_relaxed)
== requests_before_cross_document_history + 4,
"nested cross-document traversal admitted an unexpected resource request");
const auto cross_document_requests = server.requests.load(
std::memory_order_relaxed) - requests_before_cross_document_history;
// The fourth navigation may reuse the fresh, origin-partitioned page-b
// response. Both paths must avoid any new unload/reentrant request.
require(cross_document_requests >= 3 && cross_document_requests <= 4,
"nested cross-document traversal admitted an unexpected resource request: before="
+ std::to_string(requests_before_cross_document_history)
+ " after=" + std::to_string(server.requests.load(std::memory_order_relaxed)));
execute(engine, R"JS(
globalThis.__iframeLifecycleGate = {
complete:false, error:null, cycles:0, maximumLiveFrames:0,
Expand Down
Loading