mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 23:41:33 +02:00
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).
81 lines
2.2 KiB
Rust
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);
|
|
}
|
|
}
|