fix: implement 22 security pentest remediation fixes

Server-side session management with SHA-256 hashed tokens and HttpOnly
cookies. Auth middleware gating all RPC/WS/proxy routes with method
allowlist. Login rate limiting (5/60s per IP). CORS restricted to
config origin. Docker registry allowlist. App ID and path validation.
P2P message sanitization (HTML + log injection). Onion address and
known-peer validation. Nginx security headers (CSP, X-Frame-Options,
etc.) and AIUI proxy auth. Systemd hardening (non-root, NoNewPrivileges,
ProtectSystem).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 03:26:56 +00:00
parent 6623dbc4ab
commit 6656d2f1d9
12 changed files with 503 additions and 77 deletions

View File

@@ -2,6 +2,7 @@ use crate::api::rpc::RpcHandler;
use crate::electrs_status;
use crate::node_message as node_msg;
use crate::config::Config;
use crate::session::{self, SessionStore};
use crate::state::StateManager;
use anyhow::Result;
use futures_util::{SinkExt, StreamExt};
@@ -12,25 +13,52 @@ use tokio::sync::broadcast;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info};
const CORS_ANY: &str = "*";
pub struct ApiHandler {
_config: Config,
config: Config,
rpc_handler: Arc<RpcHandler>,
state_manager: Arc<StateManager>,
session_store: SessionStore,
}
impl ApiHandler {
pub async fn new(config: Config, state_manager: Arc<StateManager>) -> Result<Self> {
let rpc_handler = Arc::new(RpcHandler::new(config.clone(), state_manager.clone()).await?);
let session_store = SessionStore::new();
let rpc_handler = Arc::new(
RpcHandler::new(config.clone(), state_manager.clone(), session_store.clone()).await?,
);
Ok(Self {
_config: config,
config,
rpc_handler,
state_manager,
session_store,
})
}
/// Check if the request has a valid session cookie.
async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool {
match session::extract_session_cookie(headers) {
Some(token) => self.session_store.validate(&token).await,
None => false,
}
}
/// Build a 401 Unauthorized JSON response.
fn unauthorized() -> Response<hyper::Body> {
let body = serde_json::json!({ "error": "Unauthorized" });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body_bytes))
.unwrap()
}
/// Derive the allowed CORS origin from the config host IP.
fn cors_origin(&self) -> String {
format!("http://{}", self.config.host_ip)
}
pub async fn handle_request(
&self,
req: Request<hyper::Body>,
@@ -38,12 +66,30 @@ impl ApiHandler {
let path = req.uri().path().to_string();
let method = req.method().clone();
// WebSocket upgrade must be handled before consuming the body
// Handle CORS preflight for all routes
if method == Method::OPTIONS {
let origin = self.cors_origin();
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.header("Access-Control-Allow-Origin", &origin)
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type")
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::empty())
.unwrap());
}
// WebSocket upgrade — validate session before upgrading
if method == Method::GET && path == "/ws/db" {
if !self.is_authenticated(req.headers()).await {
return Ok(Self::unauthorized());
}
return Self::handle_websocket(req, self.state_manager.clone()).await;
}
// Convert body to bytes for non-WS routes
let headers = req.headers().clone();
let (parts, body) = req.into_parts();
let body_bytes = hyper::body::to_bytes(body).await
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
@@ -52,21 +98,39 @@ impl ApiHandler {
debug!("{} {}", method, path);
match (method, path.as_str()) {
// RPC — auth is handled inside rpc handler per-method
(Method::POST, "/rpc/v1") => self.rpc_handler.handle(req_with_bytes).await,
// Health — unauthenticated
(Method::GET, "/health") => Ok(Response::builder()
.status(StatusCode::OK)
.body(hyper::Body::from("OK"))
.unwrap()),
// Node message — P2P endpoint (authenticated by source validation, not cookie)
(Method::POST, "/archipelago/node-message") => {
Self::handle_node_message(body_bytes).await
}
// Electrs status — unauthenticated (read-only sync status)
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
// Container logs — requires session
(Method::GET, path) if path.starts_with("/api/container/logs") => {
Self::handle_container_logs_http(self.rpc_handler.clone(), path).await
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &self.cors_origin()).await
}
// LND proxy — requires session
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
Self::handle_lnd_proxy(path).await
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
Self::handle_lnd_proxy(path, &self.cors_origin()).await
}
_ => Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(hyper::Body::from("Not Found"))
@@ -77,6 +141,7 @@ impl ApiHandler {
async fn handle_container_logs_http(
rpc: Arc<RpcHandler>,
path: &str,
cors_origin: &str,
) -> Result<Response<hyper::Body>> {
let query = path
.strip_prefix("/api/container/logs")
@@ -92,7 +157,20 @@ impl ApiHandler {
Some((k, v))
})
.collect();
let app_id = params.get("app_id").map(|s| s.as_str()).unwrap_or("lnd");
// Validate app_id format
if !is_valid_app_id(app_id) {
let body = serde_json::json!({ "error": "Invalid app_id" });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body_bytes))
.unwrap());
}
let lines = params
.get("lines")
.and_then(|s| s.parse::<u32>().ok())
@@ -105,7 +183,9 @@ impl ApiHandler {
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", CORS_ANY)
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::from(body_bytes))
.unwrap())
}
@@ -115,7 +195,9 @@ impl ApiHandler {
Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", CORS_ANY)
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::from(body_bytes))
.unwrap())
}
@@ -133,13 +215,26 @@ impl ApiHandler {
message: None,
});
if let (Some(from), Some(msg)) = (incoming.from_pubkey, incoming.message) {
tracing::info!("📩 Received message from {}: {}", from, msg);
node_msg::store_received(&from, &msg).await;
// Validate from_pubkey is a valid hex ed25519 pubkey
if !is_valid_pubkey_hex(&from) {
return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST)
.header("Content-Type", "application/json")
.body(hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#))
.unwrap());
}
// Sanitize log output to prevent log injection
let safe_from = sanitize_log_string(&from);
let safe_msg = sanitize_log_string(&msg);
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
// Sanitize stored message content (strip HTML entities)
let clean_from = sanitize_html(&from);
let clean_msg = sanitize_html(&msg);
node_msg::store_received(&clean_from, &clean_msg).await;
}
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", CORS_ANY)
.body(hyper::Body::from(r#"{"ok":true}"#))
.unwrap())
}
@@ -150,12 +245,11 @@ impl ApiHandler {
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", CORS_ANY)
.body(hyper::Body::from(body))
.unwrap())
}
async fn handle_lnd_proxy(path: &str) -> Result<Response<hyper::Body>> {
async fn handle_lnd_proxy(path: &str, cors_origin: &str) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
let url = format!("http://127.0.0.1:8080{}", suffix);
match reqwest::get(&url).await {
@@ -170,7 +264,9 @@ impl ApiHandler {
}
}
builder
.header("Access-Control-Allow-Origin", CORS_ANY)
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::from(body))
.map_err(|e| anyhow::anyhow!("response build: {}", e))
}
@@ -180,7 +276,9 @@ impl ApiHandler {
Ok(Response::builder()
.status(StatusCode::BAD_GATEWAY)
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", CORS_ANY)
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::from(body_bytes))
.unwrap())
}
@@ -194,7 +292,6 @@ impl ApiHandler {
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
// Spawn a task to hold the connection open if upgrade future exists
if let Some(ws_fut) = ws_fut_opt {
tokio::spawn(async move {
let ws_stream: WsStream = match ws_fut.await {
@@ -209,10 +306,9 @@ impl ApiHandler {
}
};
info!("WebSocket /ws/db connected");
let (mut tx, mut rx) = ws_stream.split();
// Send initial data dump
let initial_msg = state_manager.get_initial_message().await;
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
if let Err(e) = tx.send(Message::Text(json_msg)).await {
@@ -221,15 +317,11 @@ impl ApiHandler {
}
debug!("Sent initial data dump at revision {}", initial_msg.rev);
}
// Subscribe to state updates
let mut state_rx = state_manager.subscribe();
// Send periodic pings to keep connection alive
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
tokio::pin!(ping_interval);
// Keep connection open and forward state updates to client
loop {
tokio::select! {
_ = ping_interval.tick() => {
@@ -238,7 +330,6 @@ impl ApiHandler {
break;
}
}
// Forward state updates from broadcast channel to WebSocket
update = state_rx.recv() => {
match update {
Ok(msg) => {
@@ -252,7 +343,6 @@ impl ApiHandler {
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
debug!("Client lagged behind, skipped {} messages", skipped);
// Continue receiving - the client will get the next update
}
Err(broadcast::error::RecvError::Closed) => {
debug!("Broadcast channel closed");
@@ -286,3 +376,32 @@ impl ApiHandler {
Ok(response)
}
}
/// Validate that an app ID matches the safe pattern: lowercase alphanumeric + hyphens.
fn is_valid_app_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& id.as_bytes()[0] != b'-'
}
/// Validate that a pubkey is a 64-char hex string.
fn is_valid_pubkey_hex(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
/// Strip newlines and ANSI escape sequences from strings before logging.
fn sanitize_log_string(s: &str) -> String {
s.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\x1b', "")
}
/// Strip HTML-sensitive characters to prevent XSS when stored/rendered.
fn sanitize_html(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}