Poll for the decision when the popup's opener is severed

crossOriginIsolated was the test for "the popup can't message us back",
but being isolated also requires COEP. A site sending COOP: same-origin
on its own still has its opener relationship severed when it opens the
Puter popup, and took the watch-the-window path instead — where the
detached proxy reports closed === true on the first tick, so
requestPermission resolved false about a second after the popup opened,
while the user was still reading the dialog. Their "Allow" then had
nowhere to go. Treat an already-closed popup as severed and poll.

Pin the expected event.source before those early returns. popupWindow
was assigned after them, so for the whole consent-dialog wait — as long
as the user takes to click Continue — the handler accepted a decision
from any window on the GUI origin. A forged answer is only advisory
since the grant is written server-side, but the check may as well hold.

Settle instead of rejecting when the consent dialog can't be appended:
document.body is null in a <head> script, and the throw both rejected a
promise documented to resolve to a boolean and left the message listener
behind.
This commit is contained in:
jelveh
2026-07-25 21:56:45 -07:00
parent 7e22facc36
commit 7efc0c0b55
+24 -8
View File
@@ -1194,15 +1194,23 @@ class UI extends EventListener {
settle(false);
return;
}
if ( window.crossOriginIsolated ) {
// COOP severs the opener relationship: the popup can't
// postMessage back and `popup.closed` is meaningless.
// Poll the permission check instead (mirrors signIn's
// /login/wait fallback), giving up after a timeout.
// Pin the expected event.source before anything can return
// early: until this is set the message handler accepts a
// decision from any window on the GUI origin, and the wait for
// the consent dialog's Continue click is user-paced.
popupWindow = popup;
// A severed opener relationship means the popup can't
// postMessage back and `popup.closed` tells us nothing about
// the window the user is looking at — it reads `true` for a
// detached proxy. Poll the permission check instead (mirrors
// signIn's /login/wait fallback), giving up after a timeout.
// `crossOriginIsolated` alone misses this: COOP severs the
// relationship on its own, while being isolated also requires
// COEP.
if ( window.crossOriginIsolated || popup.closed ) {
pollDecision();
return;
}
popupWindow = popup;
checkClosed = setInterval(() => {
if ( ! popup.closed ) return;
// The GUI posts the decision and then closes the popup,
@@ -1264,8 +1272,16 @@ class UI extends EventListener {
onCancel: () => settle(false),
});
consentDialog = dialog;
document.body.appendChild(dialog);
dialog.open();
try {
document.body.appendChild(dialog);
dialog.open();
} catch (e) {
// Nothing to show the user (e.g. called from a <head>
// script, so there is no body yet). This resolves to a
// boolean for every other caller, so deny rather than
// reject — and let cleanup drop the message listener.
settle(false);
}
}
});
};