mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-19 00:08:39 +02:00
9 confirmed findings (review wbdf3c0sd), all fixed:
S4 (capabilities):
- REGRESSION: /capabilities reported grin_relay:false for a working relay (S4
wrongly coupled it to Grin chain serviceability). The relay is a non-custodial
mailbox — wallet broadcasts locally — so it is decoupled again. Removed the now-
dead grin_relay downgrade note.
S3 (bootstrap latch):
- A DELETE of the latch row escaped Locked without Tampered detection on a
CLI-bootstrapped-but-no-users instance (adoption keyed off `users` only). Boot
now adopts on has_any_users() OR has_any_admin_keys(), so a deleted latch
re-latches Locked instead of silently dropping to uninitialized.
- bootstrap_admin's single-bootstrap guard was a check-then-insert race (two
concurrent `setup` could seed two admins). It now takes the admin-keys mutation
advisory lock (shared with add/revoke/rotate) at the top of its tx.
S2 (admin posture + landing):
- Hard bind guard matched loopback by string prefix ("localhost".starts_with) —
"localhost.evil.com" slipped past. Now resolves via to_socket_addrs and requires
EVERY resolved addr to be loopback.
- host_allowed's IPv6 loopback check was an unanchored prefix ("[::1]" matched
"[::1].evil"); now anchored to exactly "[::1]" / "[::1]:port". +regression tests.
- Anti-clickjacking headers (X-Frame-Options/CSP) are now applied on the 403
Host-reject path too (computed on both branches, headers added once).
- GET /api/v1/server-info now returns a BARE 404 when landing is off (the
{error,code} envelope itself hinted the route exists).
- Public landing `chains` now uses the SAME serviceability predicate as
/capabilities (no over-advertising an unserviceable chain).
- Removed the dead/misleading PUBLIC_EXPOSE_FEATURES toggle (never read; the
feature tuple is intentionally never emitted anonymously).
Dismissed after verification: latch chains-by-flag (dup), title length bound
(operator-owned + escaped), prices:true with empty PRICES_ASSETS (documented).
140 lib + 140 integration green, no openapi drift, scans clean.
154 lines
5.0 KiB
Rust
154 lines
5.0 KiB
Rust
//! 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::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> {
|
|
// Use the SAME serviceability predicate as /capabilities, so the public
|
|
// landing never advertises a chain that capabilities reports as off.
|
|
["btc", "ltc", "xmr", "wow", "grin"]
|
|
.iter()
|
|
.filter(|sym| crate::api::capabilities::chain_serviceable(config, sym))
|
|
.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. A BARE `404` when landing
|
|
/// is off (matching an unmatched route — the `{error,code}` envelope would itself
|
|
/// hint the route exists, so it is deliberately not used here).
|
|
pub async fn server_info(State(state): State<Arc<AppState>>) -> Response {
|
|
if !state.config.landing.enabled {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
}
|
|
Json(build_server_info(&state.config)).into_response()
|
|
}
|
|
|
|
/// `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("&"),
|
|
'<' => 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<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>"),
|
|
"<script>alert(1)</script>"
|
|
);
|
|
}
|
|
}
|