Files
smirk-backend-core/tests/admin_plane.rs
T
jwinterm 51a3371be1 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).
2026-06-27 15:32:11 -04:00

81 lines
2.2 KiB
Rust

//! 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);
}
}