fabro/lib/crates/fabro-server/src/canonical_host.rs
Scott Werner 2bd04c7935
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
Demote control-plane config to plain String; native FABRO_WEB_URL read (#510)
Third reducing PR of the interpolation unification (**D11 resolution
(c)**): the control plane never interpolates. `InterpString` is now
strictly the user-facing workflow config language; server identity,
storage, listen, object-store, and GitHub App identifiers are plain
`String`, consumed where needed with no resolution point.

## Demoted to `String` (was `InterpString`)

Both the layer and resolved types:

- `server.listen.unix.path`, `server.api.url`, `server.web.url`
- `server.storage.root`, `server.artifacts.prefix`,
`server.slatedb.prefix`
- object store: `Local.root`, S3 `bucket` / `region` / `endpoint`
(shared by artifacts + slatedb)
- `github.app_id` / `client_id` / `slug`

**Kept `InterpString`:** `slack.default_channel` (run-time consumption —
the one server-defined survivor). `server.listen.tcp.address` stays the
`SocketAddr` `parsed_value` special case.

## Native `FABRO_WEB_URL` read

Deployment-time late binding now goes through a native env read instead
of a `{{ env.* }}` token: `FABRO_WEB_URL` overrides `server.web.url`
(**env override > settings literal > default**), applied in
`canonical_origin` and reused by the JWT issuer, cookie-secure check,
and system-info. `docker/split-web` no longer ferries the value through
a settings token (compose still sets the env var). `canonical_origin`'s
error message now advertises a knob that is actually true for everyone.

## Behavior change (release notes)

- `{{ env.* }}` / `{{ vars.* }}` tokens in the demoted server fields are
now **literal text**, not interpolated. The resolve layer emits
`warn_if_demoted_template` for every demoted field, so operators with
tokens still in server config **fail loud** rather than silently
treating the token as a literal.
- Operators who relied on env-based storage location should use the
existing native `FABRO_STORAGE_DIR` (`--storage-dir`) override.
`FABRO_STORAGE_ROOT` promotion is intentionally deferred (not a proven
need).

## Cleanup

`fabro-server`'s `crate::interp` shrinks to just the process-env lookup
facade; `resolve_interp` / `_path` / `_with` and the
`AppState::resolve_interp` seam are deleted (nothing resolves
server-scope `InterpString` anymore).

## Verification

- `cargo build --workspace` 
- `cargo +nightly clippy --workspace --all-targets -- -D warnings` 
(incl. the `as_source` gate)
- `cargo +nightly fmt --check --all` 
- `cargo nextest run --workspace`: 6305 passed; added two tests covering
the `FABRO_WEB_URL` override precedence (env-wins and settings-literal
fallback).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:03:14 -04:00

438 lines
13 KiB
Rust

#![expect(
clippy::disallowed_types,
reason = "Canonical host middleware parses validated public server.web.url for authority comparison and raw redirect transit; logs use DisplaySafeUrl."
)]
use std::sync::Arc;
use axum::extract::{Request, State};
use axum::http::uri::PathAndQuery;
use axum::http::{HeaderValue, Method, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Redirect, Response};
use fabro_redact::DisplaySafeUrl;
use tracing::debug;
use url::Url;
use crate::github_webhooks::WEBHOOK_ROUTE;
use crate::server::AppState;
#[derive(Clone)]
pub(crate) struct Config {
pub(crate) state: Arc<AppState>,
pub(crate) web_enabled: bool,
}
pub(crate) async fn redirect_middleware(
State(config): State<Config>,
request: Request,
next: Next,
) -> Response {
let path = request.uri().path();
if !redirect_applies_to_request(config.web_enabled, request.method(), path) {
return next.run(request).await;
}
let Ok(canonical_origin) = config.state.canonical_origin() else {
return next.run(request).await;
};
let Ok(canonical_url) = Url::parse(&canonical_origin) else {
return next.run(request).await;
};
let Some(request_host) = request.headers().get(header::HOST) else {
return next.run(request).await;
};
if authority_matches(&canonical_url, request_host) {
return next.run(request).await;
}
let path_and_query = request
.uri()
.path_and_query()
.map_or(path, PathAndQuery::as_str);
let target = format!(
"{}{}",
canonical_origin.trim_end_matches('/'),
path_and_query
);
debug!(
target = %redacted_url_for_log(&target),
"redirecting to canonical host"
);
Redirect::permanent(&target).into_response()
}
fn redirect_applies_to_request(web_enabled: bool, method: &Method, path: &str) -> bool {
if !web_enabled || redirect_bypass_path(path) {
return false;
}
browser_auth_path(path) || matches!(method, &Method::GET | &Method::HEAD)
}
fn redirect_bypass_path(path: &str) -> bool {
path == "/health"
|| path == WEBHOOK_ROUTE
|| path == "/api"
|| path.starts_with("/api/")
|| matches!(
path,
"/auth/cli/token" | "/auth/cli/refresh" | "/auth/cli/logout"
)
}
fn browser_auth_path(path: &str) -> bool {
matches!(
path,
"/auth/login/github"
| "/auth/callback/github"
| "/auth/login/dev-token"
| "/auth/logout"
| "/auth/cli/start"
| "/auth/cli/resume"
)
}
fn authority_matches(canonical_url: &Url, request_host: &HeaderValue) -> bool {
let Some(canonical_authority) = canonical_authority(canonical_url) else {
return false;
};
let Ok(request_host) = request_host.to_str() else {
return false;
};
let request_authority = request_authority(request_host, canonical_url.scheme());
canonical_authority == request_authority
}
fn canonical_authority(canonical_url: &Url) -> Option<String> {
let mut authority = normalize_host(canonical_url.host_str()?);
if let Some(port) = canonical_url.port_or_known_default() {
if Some(port) != default_port(canonical_url.scheme()) {
authority.push(':');
authority.push_str(&port.to_string());
}
}
Some(authority)
}
fn request_authority(request_host: &str, canonical_scheme: &str) -> String {
let request_host = request_host.trim().to_ascii_lowercase();
let Some(default_port) = default_port(canonical_scheme) else {
return request_host;
};
let default_port_suffix = format!(":{default_port}");
request_host
.strip_suffix(&default_port_suffix)
.filter(|authority| !authority.is_empty())
.unwrap_or(&request_host)
.to_string()
}
fn normalize_host(host: &str) -> String {
let host = host.to_ascii_lowercase();
if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) {
format!("[{host}]")
} else {
host
}
}
fn default_port(scheme: &str) -> Option<u16> {
match scheme {
"http" => Some(80),
"https" => Some(443),
_ => None,
}
}
fn redacted_url_for_log(url: &str) -> String {
DisplaySafeUrl::parse(url)
.map_or_else(|_| "<invalid url>".to_string(), |url| url.redacted_string())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode, header};
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Router, middleware};
use fabro_config::{RunLayer, ServerSettingsBuilder};
use tower::ServiceExt;
use url::Url;
use super::*;
use crate::github_webhooks::WEBHOOK_ROUTE;
use crate::server::AppState;
macro_rules! assert_status {
($response:expr, $expected:expr) => {
fabro_test::assert_axum_status($response, $expected, concat!(file!(), ":", line!()))
};
}
macro_rules! checked_response {
($response:expr, $expected:expr) => {
fabro_test::expect_axum_status($response, $expected, concat!(file!(), ":", line!()))
};
}
fn canonical_url(raw: &str) -> Url {
Url::parse(raw).expect("canonical URL should parse")
}
fn host(value: &'static str) -> axum::http::HeaderValue {
axum::http::HeaderValue::from_static(value)
}
#[test]
fn authority_comparison_detects_different_loopback_hosts() {
assert!(!authority_matches(
&canonical_url("http://127.0.0.1:32276"),
&host("localhost:32276")
));
}
#[test]
fn authority_comparison_matches_default_https_port() {
assert!(authority_matches(
&canonical_url("https://example.com"),
&host("example.com")
));
assert!(authority_matches(
&canonical_url("https://example.com"),
&host("example.com:443")
));
}
#[test]
fn authority_comparison_rejects_non_default_port() {
assert!(!authority_matches(
&canonical_url("https://example.com"),
&host("example.com:8443")
));
}
#[test]
fn authority_comparison_matches_ipv6_authority() {
assert!(authority_matches(
&canonical_url("http://[::1]:32276"),
&host("[::1]:32276")
));
}
#[test]
fn authority_comparison_is_case_insensitive() {
assert!(authority_matches(
&canonical_url("https://EXAMPLE.com"),
&host("example.com")
));
}
fn settings_with_web_url(web_url: &str) -> fabro_types::ServerSettings {
ServerSettingsBuilder::from_toml(&format!(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
[server.web]
url = "{web_url}"
"#
))
.expect("test server settings should resolve")
}
fn state_with_web_url(web_url: &str) -> Arc<AppState> {
crate::test_support::test_app_state_with_options(
settings_with_web_url(web_url),
RunLayer::default(),
5,
)
}
fn invalid_canonical_origin_state() -> Arc<AppState> {
crate::test_support::test_app_state_with_env_lookup(
settings_with_web_url("http://valid.example.com"),
RunLayer::default(),
5,
|_| Some("/relative".to_string()),
)
}
fn test_app(state: Arc<AppState>, web_enabled: bool) -> Router {
Router::new()
.route("/login", get(|| async { StatusCode::OK }))
.route("/auth/login/github", get(|| async { StatusCode::OK }))
.route("/auth/login/dev-token", post(|| async { StatusCode::OK }))
.route("/auth/logout", post(|| async { StatusCode::OK }))
.route("/auth/cli/start", get(|| async { StatusCode::OK }))
.route(
"/auth/cli/resume",
get(|| async { StatusCode::OK }).post(|| async { StatusCode::OK }),
)
.route("/auth/cli/token", post(|| async { StatusCode::OK }))
.route("/auth/cli/refresh", post(|| async { StatusCode::OK }))
.route("/auth/cli/logout", post(|| async { StatusCode::OK }))
.route("/api/v1/runs", get(|| async { StatusCode::OK }))
.route(WEBHOOK_ROUTE, post(|| async { StatusCode::OK }))
.route("/health", get(|| async { StatusCode::OK }))
.fallback(|| async { StatusCode::OK.into_response() })
.layer(middleware::from_fn_with_state(
Config { state, web_enabled },
redirect_middleware,
))
}
async fn request(
app: Router,
method: Method,
uri: &str,
host: Option<&str>,
) -> axum::response::Response {
let mut builder = Request::builder().method(method).uri(uri);
if let Some(host) = host {
builder = builder.header(header::HOST, host);
}
app.oneshot(builder.body(Body::empty()).unwrap())
.await
.unwrap()
}
async fn assert_redirect(
app: Router,
method: Method,
uri: &str,
host: &str,
expected_location: &str,
) {
let response = request(app, method, uri, Some(host)).await;
let response = checked_response!(response, StatusCode::PERMANENT_REDIRECT).await;
assert_eq!(
response.headers().get(header::LOCATION).unwrap(),
expected_location
);
}
#[tokio::test]
async fn redirects_browser_page_route_to_canonical_host() {
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), true);
assert_redirect(
app,
Method::GET,
"/login",
"localhost:32276",
"http://127.0.0.1:32276/login",
)
.await;
}
#[tokio::test]
async fn redirects_browser_auth_route_and_preserves_query() {
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), true);
assert_redirect(
app,
Method::GET,
"/auth/login/github?return_to=/runs",
"localhost:32276",
"http://127.0.0.1:32276/auth/login/github?return_to=/runs",
)
.await;
}
#[tokio::test]
async fn redirects_browser_auth_post_route_to_canonical_host() {
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), true);
assert_redirect(
app,
Method::POST,
"/auth/login/dev-token",
"localhost:32276",
"http://127.0.0.1:32276/auth/login/dev-token",
)
.await;
}
#[tokio::test]
async fn matching_host_runs_inner_handler() {
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), true);
let response = request(app, Method::GET, "/login", Some("127.0.0.1:32276")).await;
assert_status!(response, StatusCode::OK).await;
}
#[tokio::test]
async fn bypasses_health_and_api_routes() {
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), true);
let health_response =
request(app.clone(), Method::GET, "/health", Some("localhost:32276")).await;
assert_status!(health_response, StatusCode::OK).await;
let webhook_response = request(
app.clone(),
Method::POST,
WEBHOOK_ROUTE,
Some("localhost:32276"),
)
.await;
assert_status!(webhook_response, StatusCode::OK).await;
let runs_response =
request(app, Method::GET, "/api/v1/runs", Some("localhost:32276")).await;
assert_status!(runs_response, StatusCode::OK).await;
}
#[tokio::test]
async fn bypasses_cli_token_api_routes_with_authorization() {
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), true);
for path in ["/auth/cli/token", "/auth/cli/refresh", "/auth/cli/logout"] {
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(path)
.header(header::HOST, "localhost:32276")
.header(header::AUTHORIZATION, "Bearer test")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_status!(response, StatusCode::OK).await;
}
}
#[tokio::test]
async fn bypasses_when_web_is_disabled() {
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), false);
let response = request(app, Method::GET, "/login", Some("localhost:32276")).await;
assert_status!(response, StatusCode::OK).await;
}
#[tokio::test]
async fn bypasses_when_canonical_origin_is_invalid() {
let app = test_app(invalid_canonical_origin_state(), true);
let response = request(app, Method::GET, "/login", Some("localhost:32276")).await;
assert_status!(response, StatusCode::OK).await;
}
#[tokio::test]
async fn bypasses_when_host_header_is_missing() {
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), true);
let response = request(app, Method::GET, "/login", None).await;
assert_status!(response, StatusCode::OK).await;
}
}