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(); +});