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

(cherry picked from commit 04597c20cd)
This commit is contained in:
Jad Seifeddine
2026-10-11 03:30:17 +07:00
committed by Dave Kempe
parent a68a2b76c2
commit cb425ee92c
2 changed files with 69 additions and 1 deletions
+33
View File
@@ -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:"
```
+36 -1
View File
@@ -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,
@@ -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.