diff --git a/app/meta-signin.js b/app/meta-signin.js index b955623..90c55ee 100644 --- a/app/meta-signin.js +++ b/app/meta-signin.js @@ -32,6 +32,16 @@ function allowedUrl(raw) { return underDomain(u.hostname, ALLOWED); } +// Frames inside the page: any HTTPS address (Meta's login can embed captcha and CDN +// frames from other sites), or an empty frame; never data:, blob:, javascript:, http:, +// file: or any other scheme. Frames can't reach the cookie read or IPC (main process only). +function allowedSubframeUrl(raw) { + if (/^about:(?:blank|srcdoc)$/.test(String(raw))) return true; + let u; + try { u = new URL(String(raw)); } catch { return false; } + return u.protocol === "https:" && !u.username && !u.password; +} + const onOculus = (raw) => { try { return underDomain(new URL(raw).hostname, ["oculus.com"]); } catch { return false; } }; // A cheap first look, so a signed-out or app value is never sent anywhere; the server's @@ -147,10 +157,11 @@ function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, pare // Navigation stays on Meta's login sites. A link elsewhere opens in the system // browser; a redirect elsewhere is just stopped (it may carry sign-in data). - // Every frame too: an iframe can't wander off the list either (it is just stopped). + // Every frame: the window itself stays on the list; frames inside it may load any + // HTTPS page (captchas), and one going anywhere else is just stopped. wc.on("will-navigate", (e, target) => { if (!allowedUrl(target)) { e.preventDefault(); openOutside(target); } }); wc.on("will-frame-navigate", (e) => { - if (allowedUrl(e.url) || (!e.isMainFrame && /^about:(?:blank|srcdoc)$/.test(String(e.url)))) return; + if (e.isMainFrame ? allowedUrl(e.url) : allowedSubframeUrl(e.url)) return; e.preventDefault(); }); wc.on("will-redirect", (e, target) => { if (!allowedUrl(target)) e.preventDefault(); }); @@ -194,4 +205,4 @@ function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, pare return { signIn, signOut, isOpen: () => !!active }; } -module.exports = { createMetaSignIn, allowedUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE, ALLOWED }; +module.exports = { createMetaSignIn, allowedUrl, allowedSubframeUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE, ALLOWED }; diff --git a/app/test/meta-signin.test.js b/app/test/meta-signin.test.js index df20bb4..8b27b26 100644 --- a/app/test/meta-signin.test.js +++ b/app/test/meta-signin.test.js @@ -288,19 +288,27 @@ function frameNav(wc, url, isMainFrame) { return prevented; } -test("every frame stays on Meta's sites; a frame going elsewhere is stopped, not opened", async () => { +test("the window stays on Meta's sites; frames inside it may load any HTTPS page, nothing else", async () => { const f = fakes(); f.flow.signIn(); const wc = f.windows[0].webContents; - assert.strictEqual(frameNav(wc, "https://evil.example/phish", false), true); - assert.strictEqual(frameNav(wc, "http://www.facebook.com/plugin", false), true); - assert.strictEqual(frameNav(wc, "https://www.facebook.com/plugin", false), false); - assert.strictEqual(frameNav(wc, "about:blank", false), false); - assert.strictEqual(frameNav(wc, "about:srcdoc", false), false); - assert.strictEqual(frameNav(wc, "about:blank", true), true); // the window itself: Meta's sites only + // Frames: captcha and CDN frames from other sites load; other schemes are stopped. + for (const ok of ["https://client-api.arkoselabs.com/fc/gc/", "https://newassets.hcaptcha.com/captcha/v1/x", + "https://www.google.com/recaptcha/api2/anchor", "https://static.xx.fbcdn.net/x", + "https://www.facebook.com/plugin", "about:blank", "about:srcdoc"]) { + assert.strictEqual(frameNav(wc, ok, false), false, ok); + } + for (const bad of ["http://www.facebook.com/plugin", "data:text/html,", "blob:https://evil.example/1", + "javascript:alert(1)", "file:///etc/passwd", "ftp://example.com/", "https://u:p@evil.example/", + "chrome://settings", "not a url"]) { + assert.strictEqual(frameNav(wc, bad, false), true, bad); + } + // The window itself: Meta's sites only. + assert.strictEqual(frameNav(wc, "about:blank", true), true); assert.strictEqual(frameNav(wc, "https://evil.example/", true), true); + assert.strictEqual(frameNav(wc, "https://client-api.arkoselabs.com/", true), true); assert.strictEqual(frameNav(wc, "https://auth.meta.com/login/", true), false); - assert.deepStrictEqual(f.opened, []); + assert.deepStrictEqual(f.opened, []); // stopped, never opened outside f.windows[0].destroy(); }); diff --git a/docs/meta-library.md b/docs/meta-library.md index cf1855f..7b9309b 100644 --- a/docs/meta-library.md +++ b/docs/meta-library.md @@ -45,9 +45,14 @@ closes the window. The window: or check (camera, notifications…) refused; - only goes to `https://` pages on `oculus.com`, `meta.com` and `facebook.com` (and their subdomains). A link anywhere else opens in your system browser; a - redirect anywhere else is stopped. Frames inside the page are held to the - same list (plus `about:blank`), and one going elsewhere is just stopped. - Pop-ups on those sites open in the same window; + redirect anywhere else is stopped. Pop-ups on those sites open in the same + window. Frames inside the page may load any `https://` page (Meta's login + can embed captcha and CDN frames from other sites, such as Arkose Labs, + hCaptcha, reCAPTCHA or fbcdn) or an empty frame (`about:blank`, + `about:srcdoc`); a frame going to any other scheme (`http:`, `data:`, + `blob:`, `javascript:`, `file:`…) is just stopped. Frames can't reach the + token: the cookie is read by Frame Control's main process, and the window + has no IPC; - sends a cookie value on only if it looks like a user token (`OC` or `FRL`, mixed case, no `%`, `|` or `:`), and each value once. If the server refuses it, the window stays open; close it to see why; @@ -306,7 +311,8 @@ and cookies (`app/test/meta-signin.test.js`): the window's options (its own partition, sandbox, context isolation, no Node, no preload) and Meta's login address, no developer tools; permission requests and checks refused; the navigation allow-list (links elsewhere open in the system browser, redirects -and frames going elsewhere are stopped, pop-ups never become windows); a +elsewhere are stopped, pop-ups never become windows); frames allowed any +HTTPS page but no other scheme, the window itself only Meta's sites; a page's beforeunload not keeping the window open; the cookie read only from `persist:meta-signin` and only for `oculus.com`, even when the default session and another partition hold one; signed-out values ignored; a value @@ -331,8 +337,7 @@ working token. Meta account was used, and nothing was sent to Meta): whether Meta's login still goes only through `oculus.com`, `meta.com` and `facebook.com`, whether it uses pop-ups (they open in the same window here, which would break a -pop-up that reports back to its opener), whether it needs frames from other -sites such as a captcha (those are stopped), whether `oc_ac_at` is still set on +pop-up that reports back to its opener), whether `oc_ac_at` is still set on `oculus.com` after login, and whether Meta accepts the window (Frame Control removes Electron's name from the user agent as a precaution). A real Meta token and Meta's real answers (none was available; nothing was sent to Meta), real downloads and their redirects,