diff --git a/src/api/admin.rs b/src/api/admin.rs index 4232e90..91a966d 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -16,8 +16,10 @@ use std::sync::Arc; use axum::{ - extract::{ConnectInfo, Path, State}, - http::HeaderMap, + extract::{ConnectInfo, Path, Request, State}, + http::{HeaderMap, HeaderValue, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, routing::{delete, get, post}, Json, Router, }; @@ -78,6 +80,58 @@ fn pubkey_prefix(pubkey: &str) -> String { pubkey.chars().take(PUBKEY_PREFIX_LEN).collect() } +/// Whether a request `Host` is allowed on the admin plane. A missing Host is +/// permitted (a DNS-rebinding attack must supply the attacker's Host, and the +/// loopback bind is the primary control); a PRESENT host must be loopback or the +/// configured onion. Defeats DNS-rebinding against the loopback socket. +fn host_allowed(host: &str, onion: Option<&str>) -> bool { + if host.is_empty() { + return true; + } + let h = host.to_ascii_lowercase(); + // IPv6 loopback (with or without :port), e.g. "[::1]" / "[::1]:8081". + if h.starts_with("[::1]") || h == "::1" { + return true; + } + let bare = h.split(':').next().unwrap_or(&h); + if matches!(bare, "localhost" | "127.0.0.1") { + return true; + } + if let Some(o) = onion { + let o = o.to_ascii_lowercase(); + if !o.is_empty() && (bare == o || h == o) { + return true; + } + } + false +} + +/// Admin-plane middleware: Host allowlist (anti DNS-rebinding) + anti-clickjacking +/// response headers. The loopback bind is the primary control; these are +/// defense-in-depth for the Tor/tunnel paths and any browser-reachable case. +pub async fn admin_plane_guard( + State(state): State>, + req: Request, + next: Next, +) -> Response { + let host = req + .headers() + .get(axum::http::header::HOST) + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + if !host_allowed(host, state.config.admin.onion.as_deref()) { + return (StatusCode::FORBIDDEN, "forbidden").into_response(); + } + let mut resp = next.run(req).await; + let h = resp.headers_mut(); + h.insert("x-frame-options", HeaderValue::from_static("DENY")); + h.insert( + "content-security-policy", + HeaderValue::from_static("frame-ancestors 'none'"), + ); + resp +} + /// Validate an x-only secp256k1 pubkey: exactly 64 lowercase hex chars. fn validate_admin_pubkey(pubkey: &str) -> Result<(), AppError> { let ok = pubkey.len() == 64 @@ -611,3 +665,27 @@ pub fn routes() -> Router> { .route("/admin/keys/:id", delete(admin_keys_revoke)) .route("/admin/keys/:id/rotate", post(admin_keys_rotate)) } + +#[cfg(test)] +mod tests { + use super::host_allowed; + + #[test] + fn host_allowlist() { + // Missing host is allowed (rebinding must supply a host; bind is primary). + assert!(host_allowed("", None)); + // Loopback forms. + assert!(host_allowed("localhost", None)); + assert!(host_allowed("localhost:8081", None)); + assert!(host_allowed("127.0.0.1", None)); + assert!(host_allowed("127.0.0.1:8081", None)); + assert!(host_allowed("[::1]:8081", None)); + // A foreign host (DNS-rebinding) is rejected. + assert!(!host_allowed("evil.example", None)); + assert!(!host_allowed("attacker.test:8081", None)); + // The configured onion is allowed; others still rejected. + assert!(host_allowed("abc.onion", Some("abc.onion"))); + assert!(host_allowed("abc.onion:80", Some("abc.onion"))); + assert!(!host_allowed("evil.onion", Some("abc.onion"))); + } +} diff --git a/src/api/landing.rs b/src/api/landing.rs new file mode 100644 index 0000000..b11d1b6 --- /dev/null +++ b/src/api/landing.rs @@ -0,0 +1,159 @@ +//! Public landing page + `server-info` read model (operator §2.3). +//! +//! Treated as ONE fingerprint surface, not N independent fields. Default-OFF +//! (`PUBLIC_LANDING_ENABLED`); when on, the default public set is minimal — +//! `{ software.name, status }` — and every other field is individually opt-in. +//! Built from a hand-written allowlist struct (never by serializing config or +//! state), so a secret can't leak by accident. The enabled-feature tuple is +//! NEVER emitted anonymously here (connected clients use `/capabilities`). + +use std::sync::Arc; + +use axum::{ + extract::State, + http::StatusCode, + response::{Html, IntoResponse, Response}, + routing::get, + Json, Router, +}; +use serde::Serialize; + +use crate::config::Config; +use crate::error::AppError; +use crate::AppState; + +#[derive(Debug, Serialize)] +pub struct Software { + pub name: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +#[derive(Debug, Serialize)] +pub struct PriceFeedInfo { + pub enabled: bool, +} + +/// The public projection. Optional fields are omitted unless explicitly opted in, +/// so the default JSON is exactly `{ "software": { "name": ... }, "status": ... }`. +#[derive(Debug, Serialize)] +pub struct ServerInfo { + pub software: Software, + pub status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub chains: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub price_feed: Option, + /// Boolean `up` only (never an uptime duration — reboot timing is recon). + #[serde(skip_serializing_if = "Option::is_none")] + pub up: Option, +} + +/// Coarse `major.minor` (patch is recon-only). +fn coarse_version() -> String { + let v = env!("CARGO_PKG_VERSION"); + let mut parts = v.split('.'); + match (parts.next(), parts.next()) { + (Some(major), Some(minor)) => format!("{major}.{minor}"), + _ => v.to_string(), + } +} + +fn enabled_chain_symbols(config: &Config) -> Vec { + let c = &config.features.chains; + [ + ("btc", c.btc), + ("ltc", c.ltc), + ("xmr", c.xmr), + ("wow", c.wow), + ("grin", c.grin), + ] + .iter() + .filter(|(_, on)| *on) + .map(|(sym, _)| sym.to_string()) + .collect() +} + +/// Build the public projection from config, honoring each per-field toggle. +pub fn build_server_info(config: &Config) -> ServerInfo { + let l = &config.landing; + ServerInfo { + software: Software { + name: "smirk-backend-core", + version: l.expose_version.then(coarse_version), + }, + status: "ok", + title: l.title.clone(), + chains: l.expose_chains.then(|| enabled_chain_symbols(config)), + price_feed: l.expose_price_feed.then_some(PriceFeedInfo { + enabled: config.features.prices, + }), + up: l.expose_uptime.then_some(true), + } +} + +/// `GET /api/v1/server-info` — the landing read model. `404` when landing is off +/// (so it does not hint that an admin plane exists). +#[allow(clippy::result_large_err)] +pub async fn server_info(State(state): State>) -> Result, AppError> { + if !state.config.landing.enabled { + return Err(AppError::NotFound("not found".into())); + } + Ok(Json(build_server_info(&state.config))) +} + +/// `GET /` — minimal HTML rendered from the read model; bare `404` when off. +pub async fn root(State(state): State>) -> Response { + if !state.config.landing.enabled { + return StatusCode::NOT_FOUND.into_response(); + } + let title = state + .config + .landing + .title + .as_deref() + .unwrap_or("smirk-backend-core"); + let t = html_escape(title); + let html = format!( + "\ + \ + {t}

{t}

status: ok

" + ); + Html(html).into_response() +} + +/// Minimal HTML entity escaping for the operator-supplied title. +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +/// `server-info` route, RELATIVE to the `/api/v1` mount point. Public. +pub fn routes() -> Router> { + Router::new().route("/server-info", get(server_info)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn html_escape_neutralizes_script() { + assert_eq!( + html_escape(""), + "<script>alert(1)</script>" + ); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index a4ba8de..c769084 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -4,6 +4,7 @@ pub mod admin; pub mod auth; pub mod capabilities; pub mod health; +pub mod landing; pub mod middleware; pub mod nip05; pub mod openapi; diff --git a/src/config.rs b/src/config.rs index 2211c94..c8878a6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -271,12 +271,26 @@ pub struct AdminConfig { pub pubkeys: Vec, pub max_keys: u32, pub pending_key_ttl_days: u32, + /// Allow the admin plane to bind a non-loopback address (default false → + /// startup refuses a public bind; confidentiality is by socket). + pub allow_public_bind: bool, + /// Tor onion host the admin plane is reached at (added to the Host allowlist; + /// never logged or surfaced publicly). + pub onion: Option, } /// Public landing page. Off by default; full + per-field tunable when enabled. #[derive(Clone)] pub struct LandingConfig { pub enabled: bool, + /// Operator free text (HTML-escaped on render). `None` = omitted. + pub title: Option, + /// Emit a coarse (major.minor) version. Default off (version is recon). + pub expose_version: bool, + /// Emit the enabled chain symbols. Default off. + pub expose_chains: bool, + /// Emit `price_feed.enabled`. Default off. + pub expose_price_feed: bool, pub expose_features: bool, pub expose_uptime: bool, pub stats_enabled: bool, @@ -420,9 +434,15 @@ impl Config { pubkeys: env_list("ADMIN_PUBKEYS"), max_keys: env_parse("ADMIN_MAX_KEYS", 8u32)?, pending_key_ttl_days: env_parse("ADMIN_PENDING_KEY_TTL_DAYS", 7u32)?, + allow_public_bind: env_bool("ADMIN_ALLOW_PUBLIC_BIND", false), + onion: env_opt("TOR_ADMIN_ONION"), }, landing: LandingConfig { enabled: env_bool("PUBLIC_LANDING_ENABLED", false), + title: env_opt("PUBLIC_LANDING_TITLE"), + expose_version: env_bool("PUBLIC_EXPOSE_VERSION", false), + expose_chains: env_bool("PUBLIC_EXPOSE_CHAINS", false), + expose_price_feed: env_bool("PUBLIC_EXPOSE_PRICE_FEED", false), expose_features: env_bool("PUBLIC_EXPOSE_FEATURES", false), expose_uptime: env_bool("PUBLIC_EXPOSE_UPTIME", false), stats_enabled: env_bool("PUBLIC_STATS_ENABLED", false), @@ -677,9 +697,15 @@ mod tests { pubkeys: vec![], max_keys: 8, pending_key_ttl_days: 7, + allow_public_bind: false, + onion: None, }, landing: LandingConfig { enabled: false, + title: None, + expose_version: false, + expose_chains: false, + expose_price_feed: false, expose_features: false, expose_uptime: false, stats_enabled: false, diff --git a/src/lib.rs b/src/lib.rs index 045c5c9..10ac6ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -105,11 +105,13 @@ pub fn build_router(state: Arc) -> Router { .merge(api::wallet::routes()) .merge(api::capabilities::routes()) .merge(api::prices::routes()) + .merge(api::landing::routes()) .layer(normal), ); Router::new() .route("/health", get(api::health::health)) + .route("/", get(api::landing::root)) .merge(api::nip05::routes()) .nest("/api/v1", api_v1) .with_state(state) @@ -133,6 +135,11 @@ pub fn build_router(state: Arc) -> Router { pub fn admin_router(state: Arc) -> Router { Router::new() .merge(api::admin::routes()) + // Host allowlist (anti DNS-rebinding) + anti-clickjacking headers. + .layer(axum::middleware::from_fn_with_state( + state.clone(), + api::admin::admin_plane_guard, + )) .with_state(state) .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .layer(TimeoutLayer::new(REQUEST_TIMEOUT)) diff --git a/src/main.rs b/src/main.rs index 79f45e4..df06525 100644 --- a/src/main.rs +++ b/src/main.rs @@ -171,11 +171,17 @@ async fn main() -> Result<(), Box> { // to the configured (loopback-default) address and warn if it isn't local. if state.config.admin.enabled { let admin_addr = state.config.admin.bind.clone(); - if !admin_addr.starts_with("127.") && !admin_addr.starts_with("[::1]") { - tracing::warn!( - bind = %admin_addr, - "admin plane is not bound to loopback — ensure this is intentional" - ); + // Fail-closed bind guard: refuse a non-loopback admin bind unless the + // operator explicitly opts in (confidentiality is by socket). + let is_loopback = admin_addr.starts_with("127.") + || admin_addr.starts_with("[::1]") + || admin_addr.starts_with("localhost"); + if !is_loopback && !state.config.admin.allow_public_bind { + return Err(format!( + "ADMIN_BIND {admin_addr} is not loopback; set ADMIN_ALLOW_PUBLIC_BIND=true to override \ + (and front it with Tor client-auth / a trusted proxy)" + ) + .into()); } let admin_app = admin_router(state.clone()); let admin_listener = tokio::net::TcpListener::bind(&admin_addr).await?; diff --git a/tests/admin_plane.rs b/tests/admin_plane.rs new file mode 100644 index 0000000..4a2f0a4 --- /dev/null +++ b/tests/admin_plane.rs @@ -0,0 +1,80 @@ +//! L1 integration: admin-plane posture (operator §2.1/§2.2) — Host allowlist +//! (anti DNS-rebinding) + anti-clickjacking headers. Uses a raw oneshot so it can +//! set the Host header and read response headers. + +mod common; + +use axum::body::Body; +use axum::extract::ConnectInfo; +use axum::http::{Request, StatusCode}; +use std::net::SocketAddr; +use tower::ServiceExt; + +fn admin_req(host: &str) -> Request { + Request::builder() + .method("GET") + .uri("/admin/me") + .header("host", host) + .extension(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0)))) + .body(Body::empty()) + .unwrap() +} + +#[tokio::test] +async fn host_allowlist_and_anti_clickjacking() { + std::env::set_var("ADMIN_ENABLED", "true"); + std::env::set_var( + "ADMIN_JWT_SECRET", + "admin-jwt-secret-at-least-32-bytes-long!!", + ); + std::env::set_var( + "ADMIN_KEY_INTEGRITY_SECRET", + "admin-integrity-secret-at-least-32-bytes!", + ); + std::env::set_var("ADMIN_PUBLIC_URL", "http://127.0.0.1:8081"); + + let Some(app) = common::try_app().await else { + return; + }; + let admin = app.admin_router(); + + // A foreign Host (DNS-rebinding) is rejected before auth even runs. + let resp = admin + .clone() + .oneshot(admin_req("evil.example")) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "rebinding host rejected" + ); + + // A loopback Host reaches the guard (401 unauthenticated, not 403) and the + // response carries the anti-clickjacking headers. + let resp = admin.oneshot(admin_req("127.0.0.1:8081")).await.unwrap(); + assert_ne!( + resp.status(), + StatusCode::FORBIDDEN, + "loopback host accepted" + ); + assert_eq!( + resp.headers().get("x-frame-options").map(|v| v.as_bytes()), + Some(b"DENY".as_ref()) + ); + assert_eq!( + resp.headers() + .get("content-security-policy") + .map(|v| v.as_bytes()), + Some(b"frame-ancestors 'none'".as_ref()) + ); + + for k in [ + "ADMIN_ENABLED", + "ADMIN_JWT_SECRET", + "ADMIN_KEY_INTEGRITY_SECRET", + "ADMIN_PUBLIC_URL", + ] { + std::env::remove_var(k); + } +} diff --git a/tests/landing.rs b/tests/landing.rs new file mode 100644 index 0000000..d5ebfdb --- /dev/null +++ b/tests/landing.rs @@ -0,0 +1,46 @@ +//! L1 integration: the public landing surface (operator §2.3). Default-OFF; when +//! on, the default projection is the minimal `{ software.name, status }` and `/` +//! serves HTML. One sequential test (it toggles process env). + +mod common; + +use axum::http::StatusCode; + +#[tokio::test] +async fn landing_off_then_on_minimal() { + // Off (default): both the read model and `/` are 404 (no admin-plane hint). + std::env::remove_var("PUBLIC_LANDING_ENABLED"); + let Some(app) = common::try_app().await else { + return; + }; + let (st, _) = app.request("GET", "/api/v1/server-info", None, None).await; + assert_eq!(st, StatusCode::NOT_FOUND); + let (st, _) = app.request("GET", "/", None, None).await; + assert_eq!(st, StatusCode::NOT_FOUND); + + // On, default toggles: minimal projection — only software.name + status. + std::env::set_var("PUBLIC_LANDING_ENABLED", "true"); + let app = common::try_app().await.expect("app"); + let (st, body) = app.request("GET", "/api/v1/server-info", None, None).await; + assert_eq!(st, StatusCode::OK); + let obj = body.as_object().expect("object"); + assert_eq!(obj.len(), 2, "only software + status by default: {body}"); + assert_eq!(body["software"]["name"], "smirk-backend-core"); + assert_eq!(body["status"], "ok"); + assert!( + body["software"] + .as_object() + .unwrap() + .get("version") + .is_none(), + "version omitted by default" + ); + for hidden in ["chains", "price_feed", "up", "features"] { + assert!(obj.get(hidden).is_none(), "{hidden} omitted by default"); + } + + let (st, _) = app.request("GET", "/", None, None).await; + assert_eq!(st, StatusCode::OK, "landing HTML served when enabled"); + + std::env::remove_var("PUBLIC_LANDING_ENABLED"); +}