feat(admin,landing): admin-plane hardening + public landing [operator §2]

Admin posture (§2.1/§2.2):
- Hard fail-closed bind guard in main: a non-loopback ADMIN_BIND refuses startup
  unless ADMIN_ALLOW_PUBLIC_BIND=true (was a warning).
- admin_plane_guard middleware on the admin router: Host allowlist
  (localhost/127.0.0.1/[::1]/configured TOR_ADMIN_ONION; a missing Host is allowed
  since rebinding must supply one and the bind is the primary control) to defeat
  DNS-rebinding against the loopback socket, plus X-Frame-Options: DENY +
  CSP frame-ancestors 'none' (anti-clickjacking). Still no CORS on the admin plane.

Public landing (§2.3), default-OFF, one fingerprint surface:
- GET /api/v1/server-info — built from a hand-written ServerInfo allowlist (never
  by serializing config/state). Default projection is minimal { software.name,
  status }; version (coarse major.minor), chains, price_feed, and uptime (bool
  `up` only) are each individually opt-in. The enabled-feature tuple is NEVER
  emitted anonymously (connected clients use /capabilities).
- GET / — minimal HTML (operator title HTML-escaped); bare 404 when off (does not
  hint an admin plane exists). server-info also 404s when off.
- config: ADMIN_ALLOW_PUBLIC_BIND, TOR_ADMIN_ONION; PUBLIC_LANDING_TITLE +
  per-field PUBLIC_EXPOSE_VERSION/CHAINS/PRICE_FEED toggles.

Tests: host_allowlist + html_escape unit tests; landing off->on minimal-projection
(asserts only software+status, no version/chains/features); admin-plane Host-403 +
anti-clickjacking headers. Landing/admin stay off the public OpenAPI.

Deferred (documented): §2.5 NIP-05 consent-gating (changes existing
/.well-known/nostr.json behavior — needs the standing v0.2.4-compat verification
first), §2.3 k-anon public stats, response-header/TLS-fingerprint stripping (proxy
/ops-doc concern).
This commit is contained in:
jwinterm
2026-06-27 15:32:11 -04:00
parent 67faf8f762
commit 51a3371be1
8 changed files with 410 additions and 7 deletions
+80 -2
View File
@@ -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<Arc<AppState>>,
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<Arc<AppState>> {
.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")));
}
}
+159
View File
@@ -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<String>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chains: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub price_feed: Option<PriceFeedInfo>,
/// Boolean `up` only (never an uptime duration — reboot timing is recon).
#[serde(skip_serializing_if = "Option::is_none")]
pub up: Option<bool>,
}
/// 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<String> {
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<Arc<AppState>>) -> Result<Json<ServerInfo>, 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<Arc<AppState>>) -> 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!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
<title>{t}</title></head><body><h1>{t}</h1><p>status: ok</p></body></html>"
);
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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#x27;"),
_ => out.push(c),
}
}
out
}
/// `server-info` route, RELATIVE to the `/api/v1` mount point. Public.
pub fn routes() -> Router<Arc<AppState>> {
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>"),
"&lt;script&gt;alert(1)&lt;/script&gt;"
);
}
}
+1
View File
@@ -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;
+26
View File
@@ -271,12 +271,26 @@ pub struct AdminConfig {
pub pubkeys: Vec<String>,
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<String>,
}
/// 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<String>,
/// 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,
+7
View File
@@ -105,11 +105,13 @@ pub fn build_router(state: Arc<AppState>) -> 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<AppState>) -> Router {
pub fn admin_router(state: Arc<AppState>) -> 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))
+11 -5
View File
@@ -171,11 +171,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 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?;
+80
View File
@@ -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<Body> {
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);
}
}
+46
View File
@@ -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");
}