Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/reverse-proxies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
```
37 changes: 36 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TlsEnabled>,
request: Request,
Expand All @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
26 changes: 25 additions & 1 deletion static/client.html
Original file line number Diff line number Diff line change
Expand Up @@ -1466,14 +1466,37 @@ <h2 id="disconnected-title">Session Ended</h2>
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');
Expand Down Expand Up @@ -1526,6 +1549,7 @@ <h2 id="disconnected-title">Session Ended</h2>
function rgOnFullscreenChange() {
var inFs = !!rgFullscreenEl();
var bar = document.getElementById('fs-bar');
if (inFs) rgReleaseAllInput();
if (bar) {
if (inFs) {
bar.classList.add('visible');
Expand Down