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. 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');