From ea456b696fa8c40e858b2f522bd84ddb708c2c41 Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Tue, 13 Oct 2026 14:01:33 +0700 Subject: [PATCH] connect: zero-click fullscreen via a same-tab iframe, sized to the measured viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser only lets the tab that holds the Connect gesture go fullscreen; opening a new tab drops that gesture, so a fullscreen-on-connect entry always fell back to a manual click. Connect now fullscreens the CURRENT tab on the click — the one moment a valid gesture exists — and loads the session in a full-viewport same-origin iframe (rgLaunchFullscreen in search.js, shared by the connections page and the global search): overlay, spinner and an Exit button, torn down on Exit, on fullscreen exit or on a session-end message from the embedded client. The client in ?embedded=1 mode skips all in-client fullscreen logic (the parent tab owns it) and posts ready/end to the parent. X-Frame-Options becomes SAMEORIGIN (was DENY) with CSP frame-ancestors 'self', so a same-origin page can host the client in a frame while cross-origin framing stays blocked. The final viewport size cannot be predicted (a fullscreen viewport on a notched Mac is shorter than the screen), so it is measured: the launcher enters fullscreen, waits until the viewport has held one size for 150ms, then sends the connect with that width and height; a refused request connects at the tab's size at once and a 2.5s cap never leaves the user on a spinner. The embedded client shows its connect cover from load and lifts it — then runs the post-connect fit sends — only on the launcher's 'viewport' message, so the user sees only the final picture. The ⧉ (open in a new window) button opens the client explicitly windowed (fs=0) on a small splash page, static/launch.html, which measures its own viewport once stable and posts it to the opener (rgOpenSessionWindow); the session is created at that size, so nothing resizes on load. No screen_* is sent for a popup. When the browser refuses the load-time request, the next click or key enters fullscreen, taken in the capture phase and never sent to the remote, and everything is released once fullscreen lands. Fixes #257 tests: same-origin framing only, and the zero-click fullscreen launcher The CSP test pins frame-ancestors to exactly 'self'. The browser spec drives Connect on a fullscreen-on-connect entry: the launcher overlay opens in the same tab, the connect request carries the measured viewport and no screen size, the embedded client loads in the frame, and Exit tears it down. --- src/main.rs | 26 +- static/client.html | 310 +++++++++++++++--- static/connections.html | 49 ++- static/launch.html | 59 ++++ static/search.js | 188 ++++++++++- tests/e2e/README.md | 1 + .../e2e/specs/zero-click-fullscreen.spec.mjs | 57 ++++ 7 files changed, 620 insertions(+), 70 deletions(-) create mode 100644 static/launch.html create mode 100644 tests/e2e/specs/zero-click-fullscreen.spec.mjs diff --git a/src/main.rs b/src/main.rs index 86f1e0b..00dd11a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -668,7 +668,8 @@ const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \ script-src 'self' 'unsafe-inline'; \ style-src 'self' 'unsafe-inline'; \ connect-src 'self' wss: ws:; \ - img-src 'self' data: blob:"; + img-src 'self' data: blob:; \ + frame-ancestors 'self'"; async fn security_headers( tls: Extension, @@ -678,7 +679,11 @@ async fn security_headers( let mut response = next.run(request).await; let headers = response.headers_mut(); headers.insert("X-Content-Type-Options", "nosniff".parse().unwrap()); - headers.insert("X-Frame-Options", "DENY".parse().unwrap()); + // SAMEORIGIN (not DENY) so the Connections page can host a session in a + // full-viewport same-origin iframe for zero-click fullscreen-on-connect. + // Cross-origin framing stays blocked (clickjacking); CSP frame-ancestors + // 'self' is the modern equivalent for browsers that honour it. + headers.insert("X-Frame-Options", "SAMEORIGIN".parse().unwrap()); headers.insert( "Referrer-Policy", "strict-origin-when-cross-origin".parse().unwrap(), @@ -1919,6 +1924,23 @@ mod tests { ); } + #[test] + fn test_csp_allows_same_origin_framing_only() { + // Zero-click fullscreen loads the client in a same-origin iframe + // (#257), which needs frame-ancestors 'self'; anything wider would + // reopen clickjacking from other origins. + let directives: Vec<&str> = CONTENT_SECURITY_POLICY.split(';').map(str::trim).collect(); + let fa = directives + .iter() + .find(|d| d.starts_with("frame-ancestors ")) + .expect("CSP must carry a frame-ancestors directive"); + let sources: Vec<&str> = fa.split_whitespace().skip(1).collect(); + assert_eq!( + sources, + vec!["'self'"], + "only the same origin may frame the client" + ); + } #[test] fn test_csp_allows_data_and_blob_images() { // Safari and Firefox draw Guacamole image streams through data: URIs diff --git a/static/client.html b/static/client.html index 8cc6cce..3a262b9 100644 --- a/static/client.html +++ b/static/client.html @@ -68,6 +68,38 @@ cursor: pointer; } #banner-continue:hover { background: var(--primary-hover); } + /* Fullscreen-on-connect gate: covers the session until the browser + is fullscreen, so the first picture the user sees is the one that + fits (the fullscreen desktop), not a scaled-down windowed fit. */ + #fs-gate { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: var(--bg); + z-index: 3100; /* above the side tabs (3001); hidden before the disconnect overlay (3000) is shown */ + justify-content: center; + align-items: center; + cursor: pointer; + } + #fs-gate.visible { display: flex; } + #fs-gate-box { + text-align: center; + font-family: monospace; + color: var(--text); + max-width: 600px; + padding: 2em 3em; + } + #fs-gate-spinner { + width: 30px; height: 30px; margin: 0 auto 1.2em; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: fs-gate-spin 0.8s linear infinite; + } + @keyframes fs-gate-spin { to { transform: rotate(360deg); } } + #fs-gate-title { font-size: 1.3em; font-weight: bold; margin-bottom: 0.5em; } + #fs-gate-status { color: var(--text-dim); font-size: 0.95em; min-height: 1.2em; letter-spacing: 0.02em; } + #fs-gate-status.connected { color: var(--accent); } #disconnected-overlay { display: none; position: fixed; @@ -239,6 +271,13 @@
+
+
+
+
+
+
+
+ + diff --git a/static/search.js b/static/search.js index b40757b..0dbec27 100644 --- a/static/search.js +++ b/static/search.js @@ -291,17 +291,58 @@ content.scrollTop = 0; } + // "Open in a new window" (⧉): open a sized popup synchronously (inside the + // click, so it is not blocked) on the splash page /launch.html, which + // measures its own viewport once laid out and posts it back. The caller + // creates the session at exactly that size, so nothing resizes after the + // first paint. A popup's size cannot be read from here right after + // window.open — it reports the opener's size until the window is laid out + // (that was the RDP resize on ⧉). Resolves {width,height} either way: + // defaults if the popup was blocked, or after 2.5s if it never answers. + function rgOpenSessionWindow(name) { + var win = window.open('/launch.html?name=' + encodeURIComponent(name), '_blank', + 'width=1280,height=800,menubar=no,toolbar=no,location=no,status=no'); + var size = new Promise(function (resolve) { + var done = false; + function fin(w, h) { + if (done) return; done = true; + window.removeEventListener('message', onMsg); clearTimeout(cap); + resolve({ width: w > 0 ? w : 1280, height: h > 0 ? h : 800 }); + } + function onMsg(e) { + if (e.origin !== location.origin || !e.data || e.data.rgLaunch !== 'size' || e.source !== win) return; + fin(e.data.width, e.data.height); + } + var cap = null; + if (!win) { fin(0, 0); return; } + window.addEventListener('message', onMsg); + cap = setTimeout(function () { + var w = 0, h = 0; try { w = win.innerWidth; h = win.innerHeight; } catch (e) {} + fin(w, h); + }, 2500); + }); + return { win: win, size: size }; + } + window.rgOpenSessionWindow = rgOpenSessionWindow; + // Connect to an entry: open the client in a window opened within this click // (keeps activation for fullscreen). Missing credentials send the user to // the connections page to set them. - function doConnect(scope, folder, name, btn) { - var win = window.open('', '_blank'); + function doConnect(scope, folder, name, btn, newWindow) { + // The ⧉ button gets a sized popup (measured, see above), a plain Connect a tab. + var pop = newWindow ? rgOpenSessionWindow(name) : null; + var win = pop ? pop.win : window.open('', '_blank'); if (btn) { btn.classList.add('loading'); btn.disabled = true; } var reset = function () { if (btn) { btn.classList.remove('loading'); btn.disabled = false; } }; var body = { width: window.innerWidth, height: window.innerHeight, dpi: Math.round((window.devicePixelRatio || 1) * 96) }; - if (window.screen && screen.width) { body.screen_width = screen.width; body.screen_height = screen.height; } - fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name) + '/connect', - { method: 'POST', headers: headers({ 'Content-Type': 'application/json' }), credentials: 'same-origin', body: JSON.stringify(body) }) + // screen_* only for the tab path (the server prefers it for a + // fullscreen bitmap entry); a popup is sized by its measurement alone. + if (!newWindow && window.screen && screen.width) { body.screen_width = screen.width; body.screen_height = screen.height; } + (pop ? pop.size : Promise.resolve(null)).then(function (sz) { + if (sz) { body.width = sz.width; body.height = sz.height; } + return fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name) + '/connect', + { method: 'POST', headers: headers({ 'Content-Type': 'application/json' }), credentials: 'same-origin', body: JSON.stringify(body) }); + }) .then(function (res) { if (!res.ok) { if (win && !win.closed) win.close(); @@ -312,12 +353,134 @@ }) .then(function (data) { if (!data) return; - var url = data.client_url + '?name=' + encodeURIComponent(name); + var _row = null, _si = searchIndex || []; + for (var _k = 0; _k < _si.length; _k++) { var _r = _si[_k]; if (_r.scope === scope && _r.folderPath === folder && _r.entry.name === name) { _row = _r; break; } } + var _fs = _row && _row.entry && _row.entry.fullscreen_on_connect; + // A new window is explicitly windowed: fs=0 tells the client + // not to fullscreen-on-connect even if the entry asks for it. + var url = data.client_url + '?name=' + encodeURIComponent(name) + (newWindow ? '&fs=0' : (_fs ? '&fs=1' : '')); if (win && !win.closed) win.location.href = url; else window.open(url, '_blank'); }) .catch(function () { if (win && !win.closed) win.close(); }) .then(reset, reset); } + // Zero-click fullscreen: a browser only lets the tab that holds the Connect + // gesture go fullscreen, so for a fullscreen-on-connect entry we fullscreen + // THIS tab now and load the session in a full-viewport iframe (the embedded + // client fills it). No new tab, no second click. If the browser refuses + // fullscreen the session still loads in the overlay, filling the tab. + function rgLaunchFullscreen(scope, folder, name, btn) { + var el = document.documentElement; + var req = el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen || el.msRequestFullscreen; + // The session must be created at the viewport's FINAL size — the one + // the frame will paint into — or the first picture is a fit of some + // other size that the post-connect resize then snaps. That size cannot + // be predicted: screen.width/height overshoot on a notched Mac (the + // strip beside the camera is never part of a fullscreen viewport) and + // the transition is asynchronous. So: enter fullscreen first, wait for + // the viewport to stop changing, measure it, then connect. Refused (or + // no Fullscreen API): the overlay fills the tab as it is, so measure + // that straight away. Costs the transition time (~half a second) + // before the connect request goes out; buys zero resizes. + var fsState = null, frameReady = false; // null = pending, true = entered, false = refused + var started = false, settleTimer = null, lastW = 0, lastH = 0; + var w0 = window.innerWidth, h0 = window.innerHeight, fsAt = 0; // pre-fullscreen size + function pushViewport() { + if (!frameReady || fsState === null) return; + var fr = document.getElementById('rg-session-frame'); + try { if (fr && fr.contentWindow) fr.contentWindow.postMessage({ rgLauncher: 'viewport', fullscreen: fsState }, location.origin); } catch (e) {} + } + // Connect once the viewport has held the same size for 150ms. + function armSettle() { + lastW = window.innerWidth; lastH = window.innerHeight; + clearTimeout(settleTimer); + settleTimer = setTimeout(function () { + if (window.innerWidth !== lastW || window.innerHeight !== lastH) { armSettle(); return; } + // Entered fullscreen but the viewport still has its windowed + // size: the resize has not landed yet (some browsers fire + // fullscreenchange first). Keep waiting, up to 1.5s. + if (fsState === true && window.innerWidth === w0 && window.innerHeight === h0 && Date.now() - fsAt < 1500) { armSettle(); return; } + startConnect(); + }, 150); + } + function onResize() { if (!started) armSettle(); } + function fsRefused() { if (fsState === null) { fsState = false; pushViewport(); startConnect(); } } + window.addEventListener('resize', onResize); + if (req) { + try { var fp = req.call(el); if (fp && fp.catch) fp.catch(fsRefused); } catch (e) { fsRefused(); } + // No fullscreenchange within 3s (a browser that returns no promise + // and never enters): treat as refused. + setTimeout(fsRefused, 3000); + } else fsRefused(); + var ov = document.createElement('div'); ov.id = 'rg-session-overlay'; + ov.innerHTML = '
' + + '' + + ''; + document.body.appendChild(ov); + if (btn) { btn.classList.add('loading'); btn.disabled = true; } + var done = false, enteredFs = false; + function teardown() { + if (done) return; done = true; + window.removeEventListener('message', onMsg); + window.removeEventListener('resize', onResize); + clearTimeout(settleTimer); + document.removeEventListener('fullscreenchange', onFsChange); + var o = document.getElementById('rg-session-overlay'); if (o) o.remove(); + if (btn) { btn.classList.remove('loading'); btn.disabled = false; } + try { if (document.fullscreenElement && document.exitFullscreen) document.exitFullscreen(); } catch (e) {} + } + function onMsg(e) { + if (e.origin !== location.origin || !e.data) return; + if (e.data.rgSession === 'ready') { var sp = document.getElementById('rg-session-spinner'); if (sp) sp.style.display = 'none'; frameReady = true; pushViewport(); } + else if (e.data.rgSession === 'end') teardown(); + } + function onFsChange() { + if (document.fullscreenElement) { + enteredFs = true; + if (fsState === null) { fsState = true; fsAt = Date.now(); pushViewport(); } + // Entered: the viewport may already be final or still animating; + // the settle check covers both. + if (!started) armSettle(); + } + else if (enteredFs) { teardown(); } + } + window.addEventListener('message', onMsg); + document.addEventListener('fullscreenchange', onFsChange); + document.getElementById('rg-session-exit').addEventListener('click', teardown); + // Create the session at the measured viewport size — the size the + // frame paints into — so there is nothing to resize afterwards. + function startConnect() { + if (started || done) return; + started = true; + clearTimeout(settleTimer); + window.removeEventListener('resize', onResize); + // Window size only: the measured viewport IS the fullscreen size. + // No screen_width/height — the server prefers those for a + // fullscreen bitmap session, and the physical screen overshoots + // the viewport on a notched Mac (that was the RDP resize). + var body = { dpi: Math.round((window.devicePixelRatio || 1) * 96), width: window.innerWidth, height: window.innerHeight }; + fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name) + '/connect', + { method: 'POST', headers: headers({ 'Content-Type': 'application/json' }), credentials: 'same-origin', body: JSON.stringify(body) }) + .then(function (res) { + if (!res.ok) { if (res.status === 412) { teardown(); openFolder(scope, folder); return null; } return res.text().then(function (t) { throw new Error(t); }); } + return res.json(); + }) + .then(function (data) { + if (!data) return; + var fr = document.getElementById('rg-session-frame'); + if (!fr) return; + fr.src = data.client_url + '?name=' + encodeURIComponent(name) + '&embedded=1'; + // Fallback: hide the spinner if 'ready' never arrives. + setTimeout(function () { var sp = document.getElementById('rg-session-spinner'); if (sp) sp.style.display = 'none'; }, 8000); + }) + .catch(function (err) { teardown(); if (window.rgAlert) rgAlert('Connect failed: ' + err.message); }); + } + // Whatever happens, never leave the user on a spinner: connect at the + // current size if nothing has settled it within 2.5s. + setTimeout(startConnect, 2500); + } + window.rgLaunchFullscreen = rgLaunchFullscreen; + function openFolder(scope, path) { if (typeof window.rgSelectFolder === 'function') { close(); window.rgSelectFolder(scope, path); return; } location.href = '/connections.html#open=' + encodeURIComponent(scope + '/' + path); @@ -344,7 +507,18 @@ var open = t.closest && t.closest('.search-open-folder'); if (open) { e.preventDefault(); openFolder(open.getAttribute('data-open-scope'), open.getAttribute('data-open-folder')); return; } var conn = t.closest && t.closest('[data-sc-connect]'); - if (conn) { e.preventDefault(); doConnect(conn.getAttribute('data-scope'), conn.getAttribute('data-folder'), conn.getAttribute('data-sc-connect'), conn); } + if (conn) { + e.preventDefault(); + var sc = conn.getAttribute('data-scope'), fo = conn.getAttribute('data-folder'), nm = conn.getAttribute('data-sc-connect'); + var row = null, si = searchIndex || []; + for (var k = 0; k < si.length; k++) { if (si[k].scope === sc && si[k].folderPath === fo && si[k].entry.name === nm) { row = si[k]; break; } } + var fsoc = row && row.entry && row.entry.fullscreen_on_connect; + // The regular Connect goes zero-click fullscreen in this tab; the + // ⧉ button is an explicit "open in a new window", so it keeps the + // new-window path. + if (fsoc && !conn.classList.contains('btn-connect-win')) { close(); rgLaunchFullscreen(sc, fo, nm, conn); } + else doConnect(sc, fo, nm, conn, conn.classList.contains('btn-connect-win')); + } }); // Escape closes the overlay from anywhere (not only the search box); // capture phase so it runs before page-level handlers. If a facet menu diff --git a/tests/e2e/README.md b/tests/e2e/README.md index c4be179..4cfd0d7 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -32,6 +32,7 @@ One spec per change, named after the branch that introduced it: | `themed-dialogs` | `rgConfirm`/`rgAlert` replace native dialogs | | `global-search` | `/` opens the overlay on every signed-in page, folder view untouched; nothing on the login page, for anonymous or viewer callers, or from a previous login's cache | | `search-under-modals` | a page modal covers the search box | +| `zero-click-fullscreen` | #257: same-tab iframe sized from the measured viewport | The fixture suppresses the first-visit onboarding tour (`options.onboarding` re-enables it) and defaults to the admin role; pass `role` to test gating. diff --git a/tests/e2e/specs/zero-click-fullscreen.spec.mjs b/tests/e2e/specs/zero-click-fullscreen.spec.mjs new file mode 100644 index 0000000..b9cb908 --- /dev/null +++ b/tests/e2e/specs/zero-click-fullscreen.spec.mjs @@ -0,0 +1,57 @@ +// #257: Connect on a fullscreen-on-connect entry fullscreens the current tab +// and loads the session in a same-origin iframe sized to the measured +// viewport. Headless Chromium refuses the fullscreen request, which is the +// documented fallback: the launcher connects at the tab's size at once, the +// frame still loads the embedded client, and Exit tears everything down. +import { test, expect } from '@playwright/test'; +import { installApi, settle } from '../lib/api.mjs'; + +test('Connect on a fullscreen entry launches the embedded frame with the measured size', async ({ page }) => { + const api = await installApi(page); + await page.goto('/connections.html'); + await settle(page); + const acme = page.locator('.folder-row').filter({ hasText: /acme/ }).first(); + await acme.click(); + const kiosk = page.locator('.entries-table tbody tr').filter({ hasText: 'kiosk' }); + await expect(kiosk).toHaveCount(1); + await kiosk.locator('button', { hasText: /^Connect/ }).first().click(); + + // The launcher overlay appears in this tab, no new tab or window. + const overlay = page.locator('#rg-session-overlay'); + await expect(overlay).toBeVisible(); + await expect(page.locator('#rg-session-exit')).toBeVisible(); + + // The connect request carries the viewport the frame will be shown at. + await expect.poll(() => api.calls.find(c => c.method === 'POST' && c.path.endsWith('/entries/kiosk/connect'))).toBeTruthy(); + const connect = api.calls.find(c => c.method === 'POST' && c.path.endsWith('/entries/kiosk/connect')); + const vp = page.viewportSize(); + expect(connect.body.width).toBe(vp.width); + expect(connect.body.height).toBe(vp.height); + expect(connect.body.screen_width, 'a framed session is sized from the viewport, not the screen').toBeUndefined(); + + // The embedded client loads inside the frame, same origin. + const frame = page.locator('#rg-session-frame'); + await expect(frame).toHaveAttribute('src', /client\.html\?.*embedded=1/); + const src = await frame.getAttribute('src'); + expect(src.startsWith('/') || src.startsWith(page.url().split('/connections')[0])).toBe(true); + + // Exit tears it down and the page underneath is unchanged. + await page.locator('#rg-session-exit').click(); + await expect(overlay).toHaveCount(0); + await expect(page.locator('#entries-title')).toBeVisible(); +}); + +test('a non-fullscreen entry connects the old way, with no frame', async ({ page }) => { + const api = await installApi(page); + await page.goto('/connections.html'); + await settle(page); + await page.locator('.folder-row').filter({ hasText: /acme/ }).first().click(); + const row = page.locator('.entries-table tbody tr').filter({ hasText: 'web01' }); + const [popup] = await Promise.all([ + page.waitForEvent('popup', { timeout: 5000 }).catch(() => null), + row.locator('button', { hasText: /^Connect/ }).first().click() + ]); + await expect.poll(() => api.calls.some(c => c.method === 'POST' && c.path.endsWith('/entries/web01/connect'))).toBe(true); + expect(await page.locator('#rg-session-overlay').count()).toBe(0); + if (popup) await popup.close(); +});