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
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,33 @@ jobs:
run: |
$HOME/.cargo/bin/cargo install cargo-audit
$HOME/.cargo/bin/cargo audit

e2e:
name: Browser tests
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v4
with:
node-version: 22

- name: Install Playwright
working-directory: tests/e2e
run: |
npm ci
npx playwright install --with-deps chromium

- name: End-to-end tests
# The real static pages against a stubbed API: see tests/e2e/README.md.
working-directory: tests/e2e
run: npm test

- name: Keep traces of failures
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: tests/e2e/test-results/
if-no-files-found: ignore
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
38 changes: 34 additions & 4 deletions static/connections.html
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@
.entries-table tbody tr:last-child td { border-bottom: none; }
.entries-table td:last-child { text-align: right; white-space: nowrap; }

.entries-table .actions-cell { white-space: nowrap; text-align: right; }
.entries-table .actions-cell .btn-small { margin-left: var(--s-1); }

/* Bulk selection (admins). */
.entries-table .sel-cell { width: 2em; text-align: center; padding-right: 0; }
.entries-table .sel-cell input { width: auto; margin: 0; cursor: pointer; }
Expand Down Expand Up @@ -165,6 +168,28 @@
color: var(--text-muted); font-size: var(--fz-md); font-style: italic;
}

/* Narrow desktops (a 1710px-wide MacBook display, 1600px monitors):
the 18px monospace table plus three padded action cells overran
the main column by ~300px, with "delete" cut off at the right.
Tighten the chrome rather than hide columns: smaller side
padding and sidebar, 16px cells with less padding, a narrower
Connect button. Everything then fits with ~250px to spare. */
@media (max-width: 1800px) {
/* page padding at this width lives in rustguac.css, shared by
every page, so switching tabs does not shift the gutter */
.layout { gap: var(--s-4); }
.sidebar { min-width: 240px; max-width: 280px; padding-right: var(--s-3); }
.entries-table th, .entries-table td {
padding: var(--s-2) var(--s-3);
font-size: var(--fz-sm);
}
.entries-table th { font-size: var(--fz-xs); letter-spacing: 0.08em; }
.btn-connect { min-width: 110px; padding: 0 var(--s-3); }
.btn-connect-win { min-width: 0; padding: 0 var(--s-1); }
.folder-actions strong { font-size: var(--fz-lg); }
.folder-actions .desc { font-size: var(--fz-sm); }
}

/* Active sessions strip */
.active-sessions { margin-bottom: var(--s-5); }
.session-grid { display: flex; gap: var(--s-3); overflow-x: auto; padding-bottom: var(--s-2); }
Expand Down Expand Up @@ -2394,7 +2419,7 @@ <h3 id="cred-modal-title">Enter Credentials</h3>
var html = '<table class="entries-table"><thead><tr>';
if (isAdmin) html += '<th class="sel-cell"><input type="checkbox" id="sel-all" title="Select all"></th>';
html += '<th>Name</th><th>Type</th><th>Host</th><th>User</th><th></th>';
if (isAdmin) html += '<th></th><th></th><th></th>';
if (isAdmin) html += '<th></th>';
html += '</tr></thead><tbody>';

entries.forEach(function(e, idx) {
Expand Down Expand Up @@ -2435,9 +2460,14 @@ <h3 id="cred-modal-title">Enter Credentials</h3>
html += '<td class="connect-cell"><button class="btn-connect" data-connect="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '">' + connectLabel + '</button>'
+ '<button class="btn-connect btn-connect-win" title="Open in a new window" data-connect-win="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '">&#x29c9;</button></td>';
if (isAdmin) {
html += '<td><button class="btn-small" data-edit-entry="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '" data-type="' + escapeAttr(e.session_type) + '">edit</button></td>';
html += '<td><button class="btn-small" data-clone-entry="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '">clone</button></td>';
html += '<td><button class="btn-small" data-delete-entry="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '">delete</button></td>';
// One cell for the three admin actions: three cells cost
// two extra runs of cell padding on every row, which is
// what pushed "delete" off the right edge at 1710px wide.
html += '<td class="actions-cell">'
+ '<button class="btn-small" data-edit-entry="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '" data-type="' + escapeAttr(e.session_type) + '">edit</button>'
+ '<button class="btn-small" data-clone-entry="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '">clone</button>'
+ '<button class="btn-small" data-delete-entry="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '">delete</button>'
+ '</td>';
}
html += '</tr>';
});
Expand Down
13 changes: 13 additions & 0 deletions static/recordings.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,19 @@
#player-time { color: var(--text-muted); font-size: var(--fz-sm); white-space: nowrap; }
.btn-delete { color: var(--primary); }
.btn-delete:hover { color: var(--primary-hover); }
/* Narrow desktops (a 1710px MacBook display): the 18px table with
20px cell padding ran past the right edge. Same treatment as the
connections table: 16px cells, tighter padding, 14px headers, and
the actions kept on one line. Nothing is hidden. */
@media (max-width: 1800px) {
table th, table td {
padding: var(--s-2) var(--s-3);
font-size: var(--fz-sm);
}
table th { font-size: var(--fz-xs); letter-spacing: 0.08em; }
table td:last-child { white-space: nowrap; }
.btn-small { padding: 0 var(--s-1); }
}
</style>
<script>(function(){var c=localStorage.getItem('rustguac_theme_colors');if(c){try{var o=JSON.parse(c),r=document.documentElement.style;for(var k in o)r.setProperty('--'+k.replace(/_/g,'-'),o[k]);if(o.bg_pattern&&o.bg_pattern!=='none'){var s=document.createElement('style');s.id='bg-pattern-style';s.textContent='body{background-image:'+o.bg_pattern+';background-attachment:fixed}';document.head.appendChild(s)}}catch(e){}}})();</script>

Expand Down
15 changes: 15 additions & 0 deletions static/reports.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@
font-size: var(--fz-sm);
}
.panel th { background: transparent; padding-top: 0; }
/* Narrow desktops (a 1710px MacBook display): the 18px table with
20px cell padding ran past the right edge. Same treatment as the
connections table: 16px cells, tighter padding, 14px headers, and
the actions kept on one line. Nothing is hidden. */
@media (max-width: 1800px) {
#history-table th, #history-table td,
#recordings-table th, #recordings-table td,
table.list th, table.list td {
padding: var(--s-2) var(--s-3);
font-size: var(--fz-sm);
}
#history-table th, #recordings-table th, table.list th { font-size: var(--fz-xs); letter-spacing: 0.08em; }
#history-table td:last-child, #recordings-table td:last-child { white-space: nowrap; }
.btn-small { padding: 0 var(--s-1); }
}
</style>
<script>(function(){var c=localStorage.getItem('rustguac_theme_colors');if(c){try{var o=JSON.parse(c),r=document.documentElement.style;for(var k in o)r.setProperty('--'+k.replace(/_/g,'-'),o[k]);if(o.bg_pattern&&o.bg_pattern!=='none'){var s=document.createElement('style');s.id='bg-pattern-style';s.textContent='body{background-image:'+o.bg_pattern+';background-attachment:fixed}';document.head.appendChild(s)}}catch(e){}}})();</script>
</head>
Expand Down
7 changes: 7 additions & 0 deletions static/rustguac.css
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ body {
margin: 0;
}

/* Narrow desktops (a 1710px MacBook display, 1600px monitors): a tighter
page gutter. Shared here so every page shifts together and switching
tabs does not snap the content sideways. */
@media (max-width: 1800px) {
body { padding: var(--s-4) var(--s-4); }
}

h1 { color: var(--primary); margin: 0; font-size: 30px; letter-spacing: 0.02em; font-weight: bold; }
h2 { color: var(--primary); font-size: var(--fz-lg); margin-top: var(--s-5); letter-spacing: 0.02em; }
h3 { color: var(--text); font-size: var(--fz-md); margin-top: var(--s-4); letter-spacing: 0.02em; }
Expand Down
3 changes: 3 additions & 0 deletions tests/e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
test-results/
playwright-report/
40 changes: 40 additions & 0 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# End-to-end tests

Playwright specs that drive the real pages in `static/` against a stubbed
API. Nothing here needs Vault, guacd, Docker or a browser session: `serve.mjs`
serves the static tree over HTTP and every spec installs `lib/api.mjs`, which
answers `/api/*` from a small fixture address book (two named tenants and
twenty generic ones, two sessions, two recordings) and records every request
the page makes so a spec can assert on what was sent.

```sh
cd tests/e2e
npm ci
npx playwright install chromium # once
npm test # all specs
npx playwright test specs/global-search.spec.mjs
npx playwright test --ui # step through a spec
```

`PW_CHROMIUM=/path/to/chrome` runs against an already installed Chromium
instead of the one Playwright downloads. CI runs the suite in the `e2e` job.

One spec per change, named after the branch that introduced it:

| spec | pins |
|---|---|
| `tables-1710` | #252: no horizontal overflow, admin table fits at 1710px |
| `connections-sidebar` | #254: independent sidebar scroll, selected row marked and kept in view |
| `connections-folder-filter` | #254: live filter box, ARIA tree, arrow keys, `f` shortcut |
| `connections-app-shell` | #254: clipped body, two scroll panes, folder change scrolls the pane not the page |
| `role-nav` | one nav on every page, gated by `data-role-level` |
| `sessions-adhoc-modal` | ad-hoc form opens as a modal |
| `facet-filters` | shared facets narrow lists, persist, combine with search |
| `folder-display-name` | display name over the key, modal field round-trips |
| `themed-dialogs` | `rgConfirm`/`rgAlert` replace native dialogs |
| `global-search` | `/` opens the overlay on every page, folder view untouched |
| `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.
Loading