From 04597c20cdf3d202f1584ffdd34037941ea6a5f6 Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Sun, 11 Oct 2026 03:30:17 +0700 Subject: [PATCH 1/2] csp: allow data: and blob: images so Safari and Firefox can draw The Content-Security-Policy had no img-src, so default-src 'self' governed images and refused the data: URIs the Guacamole client draws every tile from on browsers without WebCodecs ImageDecoder. Safari and Firefox rendered SSH and RDP sessions black with only rect fills and the cursor cell showing; Chrome decodes straight from the stream and never fetches a URL, which hid it. The same directive blocks the remote mouse cursor (cursor: url(data:...)) and blob: image URLs. Move the policy into a documented constant, add img-src 'self' data: blob:, and add a unit test that fails if img-src loses either source. Document the proxy-side header replacement for releases up to v1.10.5. Fixes #249 --- docs/reverse-proxies.md | 33 +++++++++++++++++++++++++++++++++ src/main.rs | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/docs/reverse-proxies.md b/docs/reverse-proxies.md index 5fc65aae..1b9d58c6 100644 --- a/docs/reverse-proxies.md +++ b/docs/reverse-proxies.md @@ -218,3 +218,36 @@ trusted_proxies = ["127.0.0.1/32"] Without this setting, all requests from behind the proxy appear to come from the proxy's own IP. + +## Content-Security-Policy + +rustguac sends its own `Content-Security-Policy` on every response. Leave +it alone: the Guacamole client needs `img-src 'self' data: blob:` because +browsers without WebCodecs `ImageDecoder` (Safari, Firefox) draw every +image tile from a `data:` URI. If your proxy sets a CSP of its own, keep +that directive, or SSH/RDP sessions render black in those browsers while +Chrome looks fine. + +Releases up to v1.10.5 shipped a policy with no `img-src`, which is +exactly that failure (#249). If you can't upgrade, have the proxy replace +the header. The whole policy has to be repeated because the header is +replaced, not merged: + +HAProxy (backend section): + +``` +http-response set-header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:" +``` + +nginx: + +```nginx +proxy_hide_header Content-Security-Policy; +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:" always; +``` + +Caddy: + +``` +header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:" +``` diff --git a/src/main.rs b/src/main.rs index 4bd2991b..06f018b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -653,6 +653,23 @@ fn cmd_map_group(database: &Db, group: &str, role: &str, force: bool) { #[derive(Clone)] struct TlsEnabled(bool); +/// Content-Security-Policy sent with every response. +/// +/// `img-src` must allow `data:` and `blob:`. The Guacamole client draws each +/// `img` stream by loading a `data:image/...;base64,...` URI into an `Image` +/// (the `DataURIReader` path in `Display.drawStream`) on browsers without +/// WebCodecs `ImageDecoder`, which is Safari and Firefox. Without `img-src` +/// the `default-src 'self'` fallback refuses those loads, the client silently +/// skips the draw, and SSH/RDP sessions render black with only rect fills +/// showing (#249). Chromium decodes straight from the stream bytes and never +/// fetches a URL, so it hid the problem. The remote mouse cursor +/// (`cursor: url(data:...)`) and blob: image URLs need the same allowance. +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:"; + async fn security_headers( tls: Extension, request: Request, @@ -672,7 +689,7 @@ async fn security_headers( ); headers.insert( "Content-Security-Policy", - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:".parse().unwrap(), + CONTENT_SECURITY_POLICY.parse().unwrap(), ); if tls.0 .0 { headers.insert( @@ -1818,6 +1835,24 @@ mod tests { use super::*; + #[test] + fn test_csp_allows_data_and_blob_images() { + // Safari and Firefox draw Guacamole image streams through data: URIs + // (#249). Chromium uses ImageDecoder and would not catch a regression. + let directives: Vec<&str> = CONTENT_SECURITY_POLICY.split(';').map(str::trim).collect(); + let img_src = directives + .iter() + .find(|d| d.starts_with("img-src ")) + .expect("CSP must carry an explicit img-src directive"); + let sources: Vec<&str> = img_src.split_whitespace().skip(1).collect(); + assert!(sources.contains(&"'self'"), "img-src must allow 'self'"); + assert!(sources.contains(&"data:"), "img-src must allow data: URIs"); + assert!(sources.contains(&"blob:"), "img-src must allow blob: URLs"); + // The string continuation must not leave stray whitespace runs. + assert!(!CONTENT_SECURITY_POLICY.contains(" ")); + assert!(!CONTENT_SECURITY_POLICY.contains('\n')); + } + #[test] fn test_version_assets_rewrites_local_js_and_css() { // Tests run from the crate root, so this is the real static tree. From ee03b3e65cf9c2848fc2d8eea67395bc32c8d3b9 Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Sun, 11 Oct 2026 07:00:08 +0700 Subject: [PATCH 2/2] client: keep the fullscreen gesture press off the remote, release all input after the transition With fullscreen-on-connect, the first mousedown after CONNECTED is the browser's fullscreen gesture, but Guacamole.Mouse forwarded it to guacd as well, and the transition that followed swallowed its release. guacd was left with the button held and the terminal selected text on every pointer move; the same with a key press as the gesture. Stop the gesture event before the display's handlers see it, and on entering fullscreen release everything explicitly: keyboard.reset() and a mouse state with no buttons at the last pointer position, so a release lost for any other reason during the transition cannot stick either. Fixes #256 --- static/client.html | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/static/client.html b/static/client.html index cf477195..d5f71664 100644 --- a/static/client.html +++ b/static/client.html @@ -1466,14 +1466,37 @@

Session Ended

if (x) { try { x.call(document); } catch (e) {} } } function rgArmFullscreenGesture() { - var fire = function() { + var fire = function(e) { document.removeEventListener('mousedown', fire, true); document.removeEventListener('keydown', fire, true); + // This press is the browser's fullscreen gesture, not + // input for the remote. The transition it starts re-lays + // out the display and swallows the matching release, and + // a press guacd never sees released turns every later + // pointer move into a drag selection (#256). Keep it from + // the display's handlers; rgReleaseAllInput() covers a + // release lost for any other reason once fullscreen lands. + e.stopPropagation(); + e.preventDefault(); rgEnterFullscreen(); }; document.addEventListener('mousedown', fire, true); document.addEventListener('keydown', fire, true); } + // Tell the remote that nothing is held: every key the keyboard + // tracks as down, and all mouse buttons at the last pointer + // position. Used after a fullscreen transition, during which the + // browser may not deliver the releases to the display element. + function rgReleaseAllInput() { + try { if (keyboard) keyboard.reset(); } catch (e) {} + try { + if (!mouse || !mouse.currentState) return; + var st = new Guacamole.Mouse.State(mouse.currentState); + st.left = st.middle = st.right = st.up = st.down = false; + if (fillX !== 1 || fillY !== 1) { st.x /= fillX; st.y /= fillY; } + client.sendMouseState(st, true); + } catch (e) {} + } function rgShowEscNoticeOnce() { if (sessionStorage.getItem('rustguac_esc_notice_seen')) return; sessionStorage.setItem('rustguac_esc_notice_seen', '1'); @@ -1526,6 +1549,7 @@

Session Ended

function rgOnFullscreenChange() { var inFs = !!rgFullscreenEl(); var bar = document.getElementById('fs-bar'); + if (inFs) rgReleaseAllInput(); if (bar) { if (inFs) { bar.classList.add('visible');