Skip to content
Draft
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
128 changes: 114 additions & 14 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1396,7 +1396,13 @@ async fn run_server(config: Config, database: Db) {
.route("/reports.html", get(serve_branded_page))
.route("/admin.html", get(serve_branded_page))
.route("/tokens.html", get(serve_branded_page))
.route("/docs.html", get(serve_branded_page));
.route("/docs.html", get(serve_branded_page))
// Optional auth only: an anonymous request still gets the page (the
// page itself sends the user to log in), but a logged-in one gets
// the variant with its navigation already right.
.layer(middleware::from_fn(auth::optional_auth))
.layer(Extension(ws_ticket_store.clone()))
.layer(Extension(database.clone()));

// Build full router (all Router<()> at this point)
let mut app: Router<()> = Router::new()
Expand Down Expand Up @@ -1582,23 +1588,63 @@ fn build_guacd_tls(config: &Config) -> Option<tokio_rustls::TlsConnector> {
Some(tokio_rustls::TlsConnector::from(Arc::new(tls_config)))
}

/// One pre-branded HTML page and the ETag its bytes hash to.
/// One pre-branded HTML page, in one variant per role level, each with the
/// ETag its bytes hash to.
///
/// The pages are branded once at startup and cannot change until a restart,
/// so the tag is computed there too -- serving one is a string compare.
/// so the tags are computed there too -- serving one is a string compare.
///
/// The variants differ only in the `data-role-level` attribute written on
/// `<html>`: the stylesheet gates navigation links and admin-only controls on
/// it, so the header is right on first paint for whoever the page is served
/// to, instead of hiding everything gated and showing it after `/api/me`
/// answers, which flashed and reflowed on every navigation.
struct BrandedPage {
/// Indexed by role level, 0 (anonymous) to 4 (admin).
variants: Vec<BrandedVariant>,
}

struct BrandedVariant {
html: String,
etag: String,
}

/// Highest role level, the index of the last page variant.
const MAX_ROLE_LEVEL: u8 = 4;

impl BrandedPage {
fn new(html: String) -> Self {
use sha2::{Digest, Sha256};
// Quoted, because an ETag is a quoted-string on the wire. A bare hash
// tends to be ignored rather than rejected, which would look exactly
// like the stale-cache bug this exists to fix.
let etag = format!("\"{}\"", hex::encode(Sha256::digest(html.as_bytes())));
Self { html, etag }
let variants = (0..=MAX_ROLE_LEVEL)
.map(|level| {
let html = role_variant_html(&html, level);
// Quoted, because an ETag is a quoted-string on the wire. A
// bare hash tends to be ignored rather than rejected, which
// would look exactly like the stale-cache bug this exists to
// fix.
let etag = format!("\"{}\"", hex::encode(Sha256::digest(html.as_bytes())));
BrandedVariant { html, etag }
})
.collect();
Self { variants }
}

fn variant(&self, level: u8) -> &BrandedVariant {
&self.variants[level.min(MAX_ROLE_LEVEL) as usize]
}
}

/// Write the caller's role level onto the page's `<html>` tag. A page that
/// has no bare `<html>` start tag is returned unchanged. Extracted for test.
fn role_variant_html(html: &str, level: u8) -> String {
match html.find("<html>") {
Some(i) => format!(
"{}<html data-role-level=\"{}\">{}",
&html[..i],
level,
&html[i + "<html>".len()..]
),
None => html.to_string(),
}
}

Expand All @@ -1624,10 +1670,15 @@ fn etag_matches(if_none_match: &str, etag: &str) -> bool {
/// costs a 304 and a rebuilt one lands on the next load. Without it these
/// responses carry no freshness information at all and Chrome caches them
/// heuristically, serving a stale page long after a restart.
fn page_response(page: &BrandedPage, headers: &axum::http::HeaderMap) -> axum::response::Response {
fn page_response(
page: &BrandedPage,
level: u8,
headers: &axum::http::HeaderMap,
) -> axum::response::Response {
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;

let page = page.variant(level);
let cache_headers = [
(header::CACHE_CONTROL, "no-cache"),
(header::ETAG, page.etag.as_str()),
Expand Down Expand Up @@ -1745,7 +1796,12 @@ fn asset_hash(
if !url.starts_with('/') || url.starts_with("//") || url.contains('?') || url.contains('#') {
return None;
}
if !(url.ends_with(".js") || url.ends_with(".css")) {
if !(url.ends_with(".js")
|| url.ends_with(".css")
|| url.ends_with(".svg")
|| url.ends_with(".png")
|| url.ends_with(".ico"))
{
return None;
}
let rel = url.trim_start_matches('/');
Expand Down Expand Up @@ -1774,14 +1830,21 @@ fn asset_hash(
/// Serve a branded HTML page from the pre-processed in-memory map.
async fn serve_branded_page(
Extension(pages): Extension<BrandedPages>,
identity: Option<Extension<auth::AuthIdentity>>,
request: axum::extract::Request,
) -> axum::response::Response {
use axum::http::StatusCode;
use axum::response::IntoResponse;
let path = request.uri().path().trim_start_matches('/');
let key = if path.is_empty() { "index.html" } else { path };
// Whoever the page is for decides which navigation it carries. An API-key
// user sends no credential on the page GET and gets the anonymous
// variant; the page reconciles that from /api/me.
let level = identity
.map(|Extension(id)| auth::role_level(id.role()))
.unwrap_or(0);
match pages.get(key) {
Some(page) => page_response(page, request.headers()),
Some(page) => page_response(page, level, request.headers()),
None => StatusCode::NOT_FOUND.into_response(),
}
}
Expand All @@ -1794,7 +1857,7 @@ async fn serve_client_page(
) -> axum::response::Response {
use axum::response::IntoResponse;
match pages.get("client.html") {
Some(page) => page_response(page, &headers),
Some(page) => page_response(page, 0, &headers),
// Unreachable: the map always carries the embedded copy.
None => Html(include_str!("../static/client.html")).into_response(),
}
Expand Down Expand Up @@ -1835,6 +1898,27 @@ mod tests {

use super::*;

#[test]
fn role_variant_writes_level_on_html_tag_only() {
let page = "<!DOCTYPE html>\n<html>\n<head><title>x</title></head><body><html>text</html></body></html>";
let v = role_variant_html(page, 4);
assert!(v.starts_with("<!DOCTYPE html>\n<html data-role-level=\"4\">\n<head>"));
// only the first bare start tag is touched; the literal later on is content
assert_eq!(v.matches("data-role-level").count(), 1);
assert_eq!(
role_variant_html("<p>no html tag</p>", 2),
"<p>no html tag</p>"
);
let branded = BrandedPage::new(page.to_string());
assert_eq!(branded.variants.len(), 5);
assert_ne!(branded.variant(0).etag, branded.variant(4).etag);
assert_eq!(
branded.variant(9).etag,
branded.variant(4).etag,
"levels clamp to admin"
);
}

#[test]
fn test_csp_allows_data_and_blob_images() {
// Safari and Firefox draw Guacamole image streams through data: URIs
Expand Down Expand Up @@ -1870,6 +1954,21 @@ mod tests {
assert!(out.ends_with("></script>"));
}

#[test]
fn test_version_assets_versions_the_logo() {
// The logo used to be revalidated on every page load, which blanked
// it for a moment on each navigation in some browsers. It is content
// addressed like the scripts now.
let static_path = std::path::Path::new("static");
let mut hashes = std::collections::HashMap::new();
let out = version_assets(
"<img id=\"site-logo\" src=\"/logo.svg\" alt=\"\">",
static_path,
&mut hashes,
);
assert!(out.contains("src=\"/logo.svg?v="), "{out}");
}

#[test]
fn test_version_assets_leaves_everything_else_alone() {
let static_path = std::path::Path::new("static");
Expand All @@ -1880,8 +1979,8 @@ mod tests {
"<script src=\"//cdn.example/x.js\"></script>",
// Already says something about this URL.
"<script src=\"/guac/Client.js?debug=1\"></script>",
// Not a kind that goes stale behind a stable name.
"<img src=\"/logo.svg\">",
// Not a kind we version (a page, a download).
"<a href=\"/docs.html\">",
// No such file: a hash of nothing is worse than none.
"<script src=\"/guac/NoSuchFile.js\"></script>",
// Traversal, which these paths have no business doing.
Expand Down Expand Up @@ -1931,6 +2030,7 @@ mod tests {
fn test_branded_page_etag_tracks_content() {
let a = BrandedPage::new("<html>one</html>".to_string());
let b = BrandedPage::new("<html>two</html>".to_string());
let (a, b) = (a.variant(0), b.variant(0));
assert_ne!(a.etag, b.etag);
// Quoted, or caches ignore it.
assert!(a.etag.starts_with('"') && a.etag.ends_with('"'));
Expand Down
10 changes: 6 additions & 4 deletions static/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ <h1>rustguac</h1>
</div>
<nav>
<a href="/connections.html">Connections</a>
<a href="/sessions.html" id="sessions-link">Sessions</a>
<a href="/sessions.html" id="sessions-link" data-min-role="poweruser">Sessions</a>
<a href="/recordings.html">Recordings</a>
<a href="/reports.html" id="reports-link" style="display:none">Reports</a>
<a href="/reports.html" id="reports-link" data-min-role="poweruser">Reports</a>
<a href="/docs.html">Docs</a>
<a href="/tokens.html">Tokens</a>
<a href="/admin.html" class="active">Admin</a>
<a href="/tokens.html" id="tokens-link" data-min-role="operator">Tokens</a>
<a href="/admin.html" class="active" id="admin-link" data-min-role="admin">Admin</a>
<a href="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables"><span class="nav-key">&#128273;</span> Credentials</a>
<span id="user-menu-wrapper"><a href="#" id="logout-item">Logout</a><span id="user-menu-btn" title="Preferences">&#9881; Settings</span><div id="user-menu"><div class="um-section-label">Theme</div><div id="um-theme-list"></div></div></span>
</nav>

Expand Down Expand Up @@ -155,6 +156,7 @@ <h2>Connections Audit Log</h2>
return res.json();
})
.then(function(data) {
if (data && data.role) document.documentElement.setAttribute('data-role-level', String({admin:4,poweruser:3,operator:2,viewer:1}[data.role] || 0));
if (!data || data.role !== 'admin') {
window.location.href = '/connections.html';
return;
Expand Down
Loading
Loading