Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .zap/rules.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@
# proxy-owned or genuinely-not-applicable alerts, leaving real seedling-web
# issues to fail the build.
10035 IGNORE (Strict-Transport-Security header — TLS is terminated and HSTS set by Caddy)
10027 IGNORE (Suspicious Comments — false positive matching tokens in the minified frontend bundle)
10049 IGNORE (Non-Storable Content — the SPA shell is served no-store by design, see spa.delivery)
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ quinn.workspace = true
rustls.workspace = true
rust-embed.workspace = true
wtransport.workspace = true

[dev-dependencies]
tower = { version = "0.5.3", default-features = false, features = ["util"] }
90 changes: 84 additions & 6 deletions crates/web/src/http.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,63 @@
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header};
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::json;
use tower_http::set_header::SetResponseHeaderLayer;

use crate::auth::{self, ConnectRequest};
use crate::spa;
use crate::state::AppState;

// Restricts scripts and plugin content to same-origin and denies framing.
// `style-src` allows inline styles because the UI toolkit (MUI/Emotion, xterm)
// injects `<style>` blocks at runtime; `connect-src` allows any `https:` origin
// because the SPA opens a WebTransport session to the WebTransport endpoint,
// which is a different (and operator-configurable) origin to this one.
const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
script-src 'self'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data:; \
font-src 'self' data:; \
connect-src 'self' https:; \
object-src 'none'; \
base-uri 'self'; \
form-action 'self'; \
frame-ancestors 'none'";

// Denies access to browser features the interface does not use.
const PERMISSIONS_POLICY: &str = "accelerometer=(), autoplay=(), camera=(), \
display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), \
gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), usb=()";

// w[transport.http]
pub fn router(state: AppState) -> Router {
Router::new()
.route("/healthz", get(healthz))
.route("/connect", post(connect))
.fallback(spa::handler)
.with_state(state)
security_headers(
Router::new()
.route("/healthz", get(healthz))
.route("/connect", post(connect))
.fallback(spa::handler),
)
.with_state(state)
}

// w[transport.http.security-headers]
fn security_headers<S: Clone + Send + Sync + 'static>(router: Router<S>) -> Router<S> {
let set_header = |name: HeaderName, value: &'static str| {
SetResponseHeaderLayer::overriding(name, HeaderValue::from_static(value))
};
router
.layer(set_header(
header::CONTENT_SECURITY_POLICY,
CONTENT_SECURITY_POLICY,
))
.layer(set_header(header::X_FRAME_OPTIONS, "DENY"))
.layer(set_header(header::X_CONTENT_TYPE_OPTIONS, "nosniff"))
.layer(set_header(
HeaderName::from_static("permissions-policy"),
PERMISSIONS_POLICY,
))
}

async fn healthz() -> impl IntoResponse {
Expand All @@ -34,3 +76,39 @@ async fn connect(
.map(str::to_owned);
auth::handle_connect(state, headers, host, body).await
}

#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt as _;

use super::*;

// w[verify transport.http.security-headers] Every response carries the
// baseline security headers, and the CSP keeps the policies the SPA relies
// on: inline styles, data: images/fonts, and WebTransport to an https origin
// that is not 'self'.
#[tokio::test]
async fn responses_carry_security_headers() {
let router: Router<()> = security_headers(Router::new().route("/", get(|| async { "ok" })));
let res = router
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();

let headers = res.headers();
assert_eq!(headers[header::X_FRAME_OPTIONS], "DENY");
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
assert!(headers.contains_key("permissions-policy"));

let csp = headers[header::CONTENT_SECURITY_POLICY].to_str().unwrap();
assert!(csp.contains("frame-ancestors 'none'"));
assert!(csp.contains("object-src 'none'"));
assert!(csp.contains("style-src 'self' 'unsafe-inline'"));
assert!(csp.contains("img-src 'self' data:"));
// WebTransport opens an https origin other than 'self'; the policy must
// not regress to connect-src 'self'.
assert!(csp.contains("connect-src 'self' https:"));
}
}
10 changes: 10 additions & 0 deletions docs/spec/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ Absent specification bugs, anything not defined here is either defined in anothe
> This endpoint is designed to sit behind a TLS-terminating reverse proxy in non-loopback deployments.
> Browsers treat loopback origins as secure contexts, so TLS is not required for local development.

> w[transport.http.security-headers]
> Every response from the plain-HTTP endpoint carries a baseline set of browser security headers:
>
> - A Content Security Policy that denies framing, forbids plugin/object content, and restricts scripts to same-origin. The policy must still permit the SPA to load its own styles (which the UI toolkit injects inline at runtime), `data:` images and fonts, and to open WebTransport sessions to the configured WebTransport endpoint (a different origin to the HTTP endpoint).
> - An explicit anti-clickjacking header denying framing, for agents that predate Content Security Policy `frame-ancestors`.
> - Content-type sniffing disabled.
> - A permissions policy that denies access to browser features the interface does not use.
>
> Transport-security headers (such as HSTS) are owned by the TLS-terminating reverse proxy, not this endpoint.

> w[daemon.connect-retry]
> If the initial connection to the seedling daemon fails at startup, the web interface must retry with exponential backoff rather than exiting.
> The retry interval starts at one second and doubles on each attempt up to a maximum of thirty seconds.
Expand Down
Loading