mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-20 00:11:34 +00:00
## Summary The terminal WebSocket origin check (`origin_allowed` in `handler/sandbox.rs`) rejected browser requests when the `Host` header omitted the default port for the scheme. Result: clicking the **Terminal** tab on `/runs/<id>/sandbox` returned **403 Forbidden** and the UI showed "Terminal WebSocket connection failed." Other tabs (Services, Filesystem, VNC) worked because their WebSockets either don't traverse the server (VNC connects directly to Daytona's signed preview URL) or aren't WebSocket upgrades. ## Root cause Browsers send `Origin: https://example.com` and `Host: example.com` (no `:443`) on default HTTPS. The previous logic always constructed the origin authority *with* the default port, then string-compared against the raw `Host` header: ```rust let origin_authority = match origin_url.port_or_known_default() { Some(port) => format!("{origin_host}:{port}"), None => origin_host.to_string(), }; origin_authority.eq_ignore_ascii_case(host) ``` So `"example.com:443"` got compared against `"example.com"` and never matched. Every browser-driven WS upgrade to a default-port HTTPS deployment failed. ## Fix Parse the `Host` header through the origin's scheme into another `Url`, then compare `host_str()` and `port_or_known_default()` on both sides. This normalizes default ports symmetrically. ```rust let Ok(host_url) = url::Url::parse(&format!("{}://{host}", origin_url.scheme())) else { return false; }; origin_url.host_str() == host_url.host_str() && origin_url.port_or_known_default() == host_url.port_or_known_default() ``` Reproduced in a production deployment of the nightly image behind Caddy doing TLS termination on a public IP. Before the fix the terminal WS handshake returned 403 every time; with the fix the handshake completes and the terminal session attaches. ## Tests Added four new cases alongside the existing two: - `origin_validation_allows_default_https_port_omitted_from_host` — the bug case (browser-style `Origin: https://host` + `Host: host`). - `origin_validation_allows_default_http_port_omitted_from_host` — same for plain HTTP. - `origin_validation_allows_explicit_default_port_in_host` — `Host: example.com:443` still matches `Origin: https://example.com`. - `origin_validation_rejects_scheme_mismatch_on_default_port` — `Origin: http://example.com` + `Host: example.com:443` is still rejected (different effective ports). All six `origin_validation_*` tests pass; the full `fabro-server` suite stays green (679/679). ## Test plan - [x] `cargo nextest run -p fabro-server origin_validation` — 6 passed - [x] `cargo nextest run -p fabro-server` — 679 passed - [x] `cargo +nightly-2026-04-14 fmt --check --all` - [x] `cargo +nightly-2026-04-14 clippy -p fabro-server --all-targets -- -D warnings` - [x] Manual: terminal tab in the SPA against a TLS-terminated default-port deployment 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|---|---|---|
| .. | ||
| build-support | ||
| fabro-acp | ||
| fabro-agent | ||
| fabro-api | ||
| fabro-auth | ||
| fabro-checkpoint | ||
| fabro-cli | ||
| fabro-client | ||
| fabro-config | ||
| fabro-core | ||
| fabro-dev | ||
| fabro-devcontainer | ||
| fabro-dump | ||
| fabro-github | ||
| fabro-graphviz | ||
| fabro-hooks | ||
| fabro-http | ||
| fabro-install | ||
| fabro-interview | ||
| fabro-llm | ||
| fabro-macros | ||
| fabro-manifest | ||
| fabro-mcp | ||
| fabro-mcp-server | ||
| fabro-model | ||
| fabro-oauth | ||
| fabro-options-metadata | ||
| fabro-proc | ||
| fabro-redact | ||
| fabro-sandbox | ||
| fabro-server | ||
| fabro-slack | ||
| fabro-spa | ||
| fabro-static | ||
| fabro-store | ||
| fabro-telemetry | ||
| fabro-template | ||
| fabro-test | ||
| fabro-tool | ||
| fabro-tracker | ||
| fabro-types | ||
| fabro-util | ||
| fabro-validate | ||
| fabro-vault | ||
| fabro-workflow | ||