mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user