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:
parent
6623dbc4ab
commit
6656d2f1d9
@ -41,7 +41,9 @@ archipelago-parmanode = { path = "../parmanode" }
|
|||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
bcrypt = "0.15"
|
bcrypt = "0.15"
|
||||||
|
sha2 = "0.10"
|
||||||
uuid = { version = "1.0", features = ["v4"] }
|
uuid = { version = "1.0", features = ["v4"] }
|
||||||
|
regex = "1.10"
|
||||||
|
|
||||||
# Node identity (Ed25519)
|
# Node identity (Ed25519)
|
||||||
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
|
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
|
||||||
|
|||||||
@ -2,6 +2,7 @@ use crate::api::rpc::RpcHandler;
|
|||||||
use crate::electrs_status;
|
use crate::electrs_status;
|
||||||
use crate::node_message as node_msg;
|
use crate::node_message as node_msg;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::session::{self, SessionStore};
|
||||||
use crate::state::StateManager;
|
use crate::state::StateManager;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
@ -12,25 +13,52 @@ use tokio::sync::broadcast;
|
|||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
const CORS_ANY: &str = "*";
|
|
||||||
|
|
||||||
pub struct ApiHandler {
|
pub struct ApiHandler {
|
||||||
_config: Config,
|
config: Config,
|
||||||
rpc_handler: Arc<RpcHandler>,
|
rpc_handler: Arc<RpcHandler>,
|
||||||
state_manager: Arc<StateManager>,
|
state_manager: Arc<StateManager>,
|
||||||
|
session_store: SessionStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiHandler {
|
impl ApiHandler {
|
||||||
pub async fn new(config: Config, state_manager: Arc<StateManager>) -> Result<Self> {
|
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 {
|
Ok(Self {
|
||||||
_config: config,
|
config,
|
||||||
rpc_handler,
|
rpc_handler,
|
||||||
state_manager,
|
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(
|
pub async fn handle_request(
|
||||||
&self,
|
&self,
|
||||||
req: Request<hyper::Body>,
|
req: Request<hyper::Body>,
|
||||||
@ -38,12 +66,30 @@ impl ApiHandler {
|
|||||||
let path = req.uri().path().to_string();
|
let path = req.uri().path().to_string();
|
||||||
let method = req.method().clone();
|
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 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;
|
return Self::handle_websocket(req, self.state_manager.clone()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert body to bytes for non-WS routes
|
// Convert body to bytes for non-WS routes
|
||||||
|
let headers = req.headers().clone();
|
||||||
let (parts, body) = req.into_parts();
|
let (parts, body) = req.into_parts();
|
||||||
let body_bytes = hyper::body::to_bytes(body).await
|
let body_bytes = hyper::body::to_bytes(body).await
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
|
||||||
@ -52,21 +98,39 @@ impl ApiHandler {
|
|||||||
debug!("{} {}", method, path);
|
debug!("{} {}", method, path);
|
||||||
|
|
||||||
match (method, path.as_str()) {
|
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,
|
(Method::POST, "/rpc/v1") => self.rpc_handler.handle(req_with_bytes).await,
|
||||||
|
|
||||||
|
// Health — unauthenticated
|
||||||
(Method::GET, "/health") => Ok(Response::builder()
|
(Method::GET, "/health") => Ok(Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.body(hyper::Body::from("OK"))
|
.body(hyper::Body::from("OK"))
|
||||||
.unwrap()),
|
.unwrap()),
|
||||||
|
|
||||||
|
// Node message — P2P endpoint (authenticated by source validation, not cookie)
|
||||||
(Method::POST, "/archipelago/node-message") => {
|
(Method::POST, "/archipelago/node-message") => {
|
||||||
Self::handle_node_message(body_bytes).await
|
Self::handle_node_message(body_bytes).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Electrs status — unauthenticated (read-only sync status)
|
||||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||||
|
|
||||||
|
// Container logs — requires session
|
||||||
(Method::GET, path) if path.starts_with("/api/container/logs") => {
|
(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/") => {
|
(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()
|
_ => Ok(Response::builder()
|
||||||
.status(StatusCode::NOT_FOUND)
|
.status(StatusCode::NOT_FOUND)
|
||||||
.body(hyper::Body::from("Not Found"))
|
.body(hyper::Body::from("Not Found"))
|
||||||
@ -77,6 +141,7 @@ impl ApiHandler {
|
|||||||
async fn handle_container_logs_http(
|
async fn handle_container_logs_http(
|
||||||
rpc: Arc<RpcHandler>,
|
rpc: Arc<RpcHandler>,
|
||||||
path: &str,
|
path: &str,
|
||||||
|
cors_origin: &str,
|
||||||
) -> Result<Response<hyper::Body>> {
|
) -> Result<Response<hyper::Body>> {
|
||||||
let query = path
|
let query = path
|
||||||
.strip_prefix("/api/container/logs")
|
.strip_prefix("/api/container/logs")
|
||||||
@ -92,7 +157,20 @@ impl ApiHandler {
|
|||||||
Some((k, v))
|
Some((k, v))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let app_id = params.get("app_id").map(|s| s.as_str()).unwrap_or("lnd");
|
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
|
let lines = params
|
||||||
.get("lines")
|
.get("lines")
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
@ -105,7 +183,9 @@ impl ApiHandler {
|
|||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header("Content-Type", "application/json")
|
.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))
|
.body(hyper::Body::from(body_bytes))
|
||||||
.unwrap())
|
.unwrap())
|
||||||
}
|
}
|
||||||
@ -115,7 +195,9 @@ impl ApiHandler {
|
|||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
.header("Content-Type", "application/json")
|
.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))
|
.body(hyper::Body::from(body_bytes))
|
||||||
.unwrap())
|
.unwrap())
|
||||||
}
|
}
|
||||||
@ -133,13 +215,26 @@ impl ApiHandler {
|
|||||||
message: None,
|
message: None,
|
||||||
});
|
});
|
||||||
if let (Some(from), Some(msg)) = (incoming.from_pubkey, incoming.message) {
|
if let (Some(from), Some(msg)) = (incoming.from_pubkey, incoming.message) {
|
||||||
tracing::info!("📩 Received message from {}: {}", from, msg);
|
// Validate from_pubkey is a valid hex ed25519 pubkey
|
||||||
node_msg::store_received(&from, &msg).await;
|
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()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Access-Control-Allow-Origin", CORS_ANY)
|
|
||||||
.body(hyper::Body::from(r#"{"ok":true}"#))
|
.body(hyper::Body::from(r#"{"ok":true}"#))
|
||||||
.unwrap())
|
.unwrap())
|
||||||
}
|
}
|
||||||
@ -150,12 +245,11 @@ impl ApiHandler {
|
|||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Access-Control-Allow-Origin", CORS_ANY)
|
|
||||||
.body(hyper::Body::from(body))
|
.body(hyper::Body::from(body))
|
||||||
.unwrap())
|
.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 suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||||
let url = format!("http://127.0.0.1:8080{}", suffix);
|
let url = format!("http://127.0.0.1:8080{}", suffix);
|
||||||
match reqwest::get(&url).await {
|
match reqwest::get(&url).await {
|
||||||
@ -170,7 +264,9 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
builder
|
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))
|
.body(hyper::Body::from(body))
|
||||||
.map_err(|e| anyhow::anyhow!("response build: {}", e))
|
.map_err(|e| anyhow::anyhow!("response build: {}", e))
|
||||||
}
|
}
|
||||||
@ -180,7 +276,9 @@ impl ApiHandler {
|
|||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::BAD_GATEWAY)
|
.status(StatusCode::BAD_GATEWAY)
|
||||||
.header("Content-Type", "application/json")
|
.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))
|
.body(hyper::Body::from(body_bytes))
|
||||||
.unwrap())
|
.unwrap())
|
||||||
}
|
}
|
||||||
@ -194,7 +292,6 @@ impl ApiHandler {
|
|||||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
.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 {
|
if let Some(ws_fut) = ws_fut_opt {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let ws_stream: WsStream = match ws_fut.await {
|
let ws_stream: WsStream = match ws_fut.await {
|
||||||
@ -212,7 +309,6 @@ impl ApiHandler {
|
|||||||
|
|
||||||
let (mut tx, mut rx) = ws_stream.split();
|
let (mut tx, mut rx) = ws_stream.split();
|
||||||
|
|
||||||
// Send initial data dump
|
|
||||||
let initial_msg = state_manager.get_initial_message().await;
|
let initial_msg = state_manager.get_initial_message().await;
|
||||||
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
|
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
|
||||||
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
||||||
@ -222,14 +318,10 @@ impl ApiHandler {
|
|||||||
debug!("Sent initial data dump at revision {}", initial_msg.rev);
|
debug!("Sent initial data dump at revision {}", initial_msg.rev);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to state updates
|
|
||||||
let mut state_rx = state_manager.subscribe();
|
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));
|
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||||
tokio::pin!(ping_interval);
|
tokio::pin!(ping_interval);
|
||||||
|
|
||||||
// Keep connection open and forward state updates to client
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = ping_interval.tick() => {
|
_ = ping_interval.tick() => {
|
||||||
@ -238,7 +330,6 @@ impl ApiHandler {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Forward state updates from broadcast channel to WebSocket
|
|
||||||
update = state_rx.recv() => {
|
update = state_rx.recv() => {
|
||||||
match update {
|
match update {
|
||||||
Ok(msg) => {
|
Ok(msg) => {
|
||||||
@ -252,7 +343,6 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||||
debug!("Client lagged behind, skipped {} messages", skipped);
|
debug!("Client lagged behind, skipped {} messages", skipped);
|
||||||
// Continue receiving - the client will get the next update
|
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Closed) => {
|
Err(broadcast::error::RecvError::Closed) => {
|
||||||
debug!("Broadcast channel closed");
|
debug!("Broadcast channel closed");
|
||||||
@ -286,3 +376,32 @@ impl ApiHandler {
|
|||||||
Ok(response)
|
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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|||||||
@ -17,6 +17,22 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing manifest_path"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing manifest_path"))?;
|
||||||
|
|
||||||
|
// Validate manifest path: reject path traversal and paths outside apps/
|
||||||
|
if manifest_path.contains("..") {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid manifest_path: path traversal not allowed"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let path = std::path::Path::new(manifest_path);
|
||||||
|
if path.is_absolute() {
|
||||||
|
let apps_dir = self.config.data_dir.join("apps");
|
||||||
|
if !path.starts_with(&apps_dir) {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid manifest_path: must be under the apps directory"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load manifest
|
// Load manifest
|
||||||
let manifest_content = tokio::fs::read_to_string(manifest_path)
|
let manifest_content = tokio::fs::read_to_string(manifest_path)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -10,10 +10,12 @@ use crate::auth::AuthManager;
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::container::DevContainerOrchestrator;
|
use crate::container::DevContainerOrchestrator;
|
||||||
use crate::port_allocator::PortAllocator;
|
use crate::port_allocator::PortAllocator;
|
||||||
|
use crate::session::{self, LoginRateLimiter, SessionStore};
|
||||||
use crate::state::StateManager;
|
use crate::state::StateManager;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use hyper::{Request, Response, StatusCode};
|
use hyper::{Request, Response, StatusCode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::IpAddr;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
|
||||||
@ -39,16 +41,29 @@ struct RpcError {
|
|||||||
/// Default dev password when no user is set up (matches mock-backend).
|
/// Default dev password when no user is set up (matches mock-backend).
|
||||||
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
|
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
|
||||||
|
|
||||||
|
/// Methods that do not require a valid session cookie.
|
||||||
|
const UNAUTHENTICATED_METHODS: &[&str] = &[
|
||||||
|
"auth.login",
|
||||||
|
"auth.isOnboardingComplete",
|
||||||
|
"health",
|
||||||
|
];
|
||||||
|
|
||||||
pub struct RpcHandler {
|
pub struct RpcHandler {
|
||||||
config: Config,
|
config: Config,
|
||||||
auth_manager: AuthManager,
|
auth_manager: AuthManager,
|
||||||
orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||||
state_manager: Arc<StateManager>,
|
state_manager: Arc<StateManager>,
|
||||||
port_allocator: Arc<Mutex<PortAllocator>>,
|
port_allocator: Arc<Mutex<PortAllocator>>,
|
||||||
|
pub session_store: SessionStore,
|
||||||
|
login_rate_limiter: LoginRateLimiter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub async fn new(config: Config, state_manager: Arc<StateManager>) -> Result<Self> {
|
pub async fn new(
|
||||||
|
config: Config,
|
||||||
|
state_manager: Arc<StateManager>,
|
||||||
|
session_store: SessionStore,
|
||||||
|
) -> Result<Self> {
|
||||||
let auth_manager = AuthManager::new(config.data_dir.clone());
|
let auth_manager = AuthManager::new(config.data_dir.clone());
|
||||||
let orchestrator = if config.dev_mode {
|
let orchestrator = if config.dev_mode {
|
||||||
Some(Arc::new(
|
Some(Arc::new(
|
||||||
@ -65,6 +80,8 @@ impl RpcHandler {
|
|||||||
orchestrator,
|
orchestrator,
|
||||||
state_manager,
|
state_manager,
|
||||||
port_allocator,
|
port_allocator,
|
||||||
|
session_store,
|
||||||
|
login_rate_limiter: LoginRateLimiter::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,8 +89,10 @@ impl RpcHandler {
|
|||||||
&self,
|
&self,
|
||||||
req: Request<hyper::Body>,
|
req: Request<hyper::Body>,
|
||||||
) -> Result<Response<hyper::Body>> {
|
) -> Result<Response<hyper::Body>> {
|
||||||
// Read request body
|
// Extract session cookie before consuming the request
|
||||||
let (_, body) = req.into_parts();
|
let (parts, body) = req.into_parts();
|
||||||
|
let session_token = session::extract_session_cookie(&parts.headers);
|
||||||
|
|
||||||
let body_bytes = hyper::body::to_bytes(body).await
|
let body_bytes = hyper::body::to_bytes(body).await
|
||||||
.context("Failed to read body")?;
|
.context("Failed to read body")?;
|
||||||
|
|
||||||
@ -82,6 +101,55 @@ impl RpcHandler {
|
|||||||
|
|
||||||
debug!("RPC method: {}", rpc_req.method);
|
debug!("RPC method: {}", rpc_req.method);
|
||||||
|
|
||||||
|
// Enforce authentication for non-allowlisted methods
|
||||||
|
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
|
||||||
|
if !is_unauthenticated {
|
||||||
|
let authenticated = match &session_token {
|
||||||
|
Some(token) => self.session_store.validate(token).await,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
if !authenticated {
|
||||||
|
let rpc_resp = RpcResponse {
|
||||||
|
result: None,
|
||||||
|
error: Some(RpcError {
|
||||||
|
code: 401,
|
||||||
|
message: "Unauthorized".to_string(),
|
||||||
|
data: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let resp_body = serde_json::to_vec(&rpc_resp)
|
||||||
|
.context("Failed to serialize response")?;
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::UNAUTHORIZED)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(hyper::Body::from(resp_body))
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit login attempts
|
||||||
|
if rpc_req.method == "auth.login" {
|
||||||
|
let client_ip = extract_client_ip(&parts.headers);
|
||||||
|
if !self.login_rate_limiter.check(client_ip).await {
|
||||||
|
let rpc_resp = RpcResponse {
|
||||||
|
result: None,
|
||||||
|
error: Some(RpcError {
|
||||||
|
code: 429,
|
||||||
|
message: "Too many login attempts. Try again later.".to_string(),
|
||||||
|
data: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let resp_body = serde_json::to_vec(&rpc_resp)
|
||||||
|
.context("Failed to serialize response")?;
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::TOO_MANY_REQUESTS)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Retry-After", "60")
|
||||||
|
.body(hyper::Body::from(resp_body))
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Route to handler
|
// Route to handler
|
||||||
let result = match rpc_req.method.as_str() {
|
let result = match rpc_req.method.as_str() {
|
||||||
"echo" => self.handle_echo(rpc_req.params).await,
|
"echo" => self.handle_echo(rpc_req.params).await,
|
||||||
@ -158,14 +226,46 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = serde_json::to_vec(&rpc_resp)
|
let resp_body = serde_json::to_vec(&rpc_resp)
|
||||||
.context("Failed to serialize response")?;
|
.context("Failed to serialize response")?;
|
||||||
|
|
||||||
Ok(Response::builder()
|
let mut response = Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(hyper::Body::from(body))
|
.body(hyper::Body::from(resp_body))
|
||||||
.unwrap())
|
.unwrap();
|
||||||
|
|
||||||
|
// Track failed login attempts for rate limiting
|
||||||
|
if rpc_req.method == "auth.login" && rpc_resp.error.is_some() {
|
||||||
|
let client_ip = extract_client_ip(&parts.headers);
|
||||||
|
self.login_rate_limiter.record_failure(client_ip).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On successful login, create a session and set the cookie
|
||||||
|
if rpc_req.method == "auth.login" && rpc_resp.error.is_none() {
|
||||||
|
let token = self.session_store.create().await;
|
||||||
|
response.headers_mut().insert(
|
||||||
|
"Set-Cookie",
|
||||||
|
format!("session={}; HttpOnly; SameSite=Strict; Path=/", token)
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// On logout, invalidate session and expire the cookie
|
||||||
|
if rpc_req.method == "auth.logout" {
|
||||||
|
if let Some(token) = &session_token {
|
||||||
|
self.session_store.remove(token).await;
|
||||||
|
}
|
||||||
|
response.headers_mut().insert(
|
||||||
|
"Set-Cookie",
|
||||||
|
"session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_echo(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
async fn handle_echo(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
@ -177,3 +277,14 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract the client IP from request headers (X-Real-IP or X-Forwarded-For).
|
||||||
|
fn extract_client_ip(headers: &hyper::HeaderMap) -> IpAddr {
|
||||||
|
headers
|
||||||
|
.get("x-real-ip")
|
||||||
|
.or_else(|| headers.get("x-forwarded-for"))
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.split(',').next())
|
||||||
|
.and_then(|s| s.trim().parse::<IpAddr>().ok())
|
||||||
|
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
|
||||||
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ impl RpcHandler {
|
|||||||
.get("id")
|
.get("id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
validate_app_id(package_id)?;
|
||||||
|
|
||||||
let docker_image = params
|
let docker_image = params
|
||||||
.get("dockerImage")
|
.get("dockerImage")
|
||||||
@ -565,6 +566,7 @@ impl RpcHandler {
|
|||||||
.get("id")
|
.get("id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
validate_app_id(package_id)?;
|
||||||
let preserve_data = params
|
let preserve_data = params
|
||||||
.get("preserve_data")
|
.get("preserve_data")
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
@ -711,6 +713,7 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Get all container names for an app (handles multi-container apps like mempool)
|
/// Get all container names for an app (handles multi-container apps like mempool)
|
||||||
async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
|
async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
|
||||||
|
validate_app_id(package_id)?;
|
||||||
let output = tokio::process::Command::new("sudo")
|
let output = tokio::process::Command::new("sudo")
|
||||||
.args(["podman", "ps", "-a", "--format", "{{.Names}}"])
|
.args(["podman", "ps", "-a", "--format", "{{.Names}}"])
|
||||||
.output()
|
.output()
|
||||||
@ -759,7 +762,8 @@ async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get data directories to clean for an app
|
/// Get data directories to clean for an app.
|
||||||
|
/// Caller must validate package_id before calling.
|
||||||
fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||||
let base = "/var/lib/archipelago";
|
let base = "/var/lib/archipelago";
|
||||||
match package_id {
|
match package_id {
|
||||||
@ -781,20 +785,39 @@ fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate Docker image name format
|
/// Trusted Docker registries. Only images from these sources are allowed.
|
||||||
/// Prevents command injection via malicious image names
|
const TRUSTED_REGISTRIES: &[&str] = &[
|
||||||
|
"docker.io/",
|
||||||
|
"ghcr.io/",
|
||||||
|
"localhost/",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Validate Docker image against trusted registry allowlist.
|
||||||
fn is_valid_docker_image(image: &str) -> bool {
|
fn is_valid_docker_image(image: &str) -> bool {
|
||||||
|
if image.is_empty() || image.len() > 256 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Reject shell metacharacters
|
||||||
let dangerous_chars = ['&', '|', ';', '`', '$', '(', ')', '<', '>', '\n', '\r'];
|
let dangerous_chars = ['&', '|', ';', '`', '$', '(', ')', '<', '>', '\n', '\r'];
|
||||||
if image.chars().any(|c| dangerous_chars.contains(&c)) {
|
if image.chars().any(|c| dangerous_chars.contains(&c)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if !image.chars().any(|c| c.is_alphanumeric()) {
|
// Must come from a trusted registry
|
||||||
return false;
|
TRUSTED_REGISTRIES.iter().any(|r| image.starts_with(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that a package/app ID is safe (lowercase alphanumeric + hyphens, 1-64 chars).
|
||||||
|
fn validate_app_id(id: &str) -> Result<()> {
|
||||||
|
if id.is_empty() || id.len() > 64 {
|
||||||
|
anyhow::bail!("Invalid app id: must be 1-64 characters");
|
||||||
}
|
}
|
||||||
if image.len() > 256 {
|
if !id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') {
|
||||||
return false;
|
anyhow::bail!("Invalid app id: only lowercase letters, digits, and hyphens allowed");
|
||||||
}
|
}
|
||||||
true
|
if id.starts_with('-') {
|
||||||
|
anyhow::bail!("Invalid app id: must not start with a hyphen");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-app Linux capabilities needed beyond the default cap-drop=ALL.
|
/// Per-app Linux capabilities needed beyond the default cap-drop=ALL.
|
||||||
|
|||||||
@ -60,6 +60,19 @@ impl RpcHandler {
|
|||||||
.get("message")
|
.get("message")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||||
|
|
||||||
|
// Validate onion is a known peer to prevent SSRF to arbitrary Tor destinations
|
||||||
|
let known_peers = peers::load_peers(&self.config.data_dir).await?;
|
||||||
|
let is_known = known_peers.iter().any(|p| {
|
||||||
|
p.onion == onion || p.onion == format!("{}.onion", onion)
|
||||||
|
|| format!("{}.onion", p.onion) == onion
|
||||||
|
});
|
||||||
|
if !is_known {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Onion address not in known peers list. Add the peer first."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let pubkey = data.server_info.pubkey.clone();
|
let pubkey = data.server_info.pubkey.clone();
|
||||||
node_message::send_to_peer(onion, &pubkey, message).await?;
|
node_message::send_to_peer(onion, &pubkey, message).await?;
|
||||||
|
|||||||
@ -18,6 +18,7 @@ mod node_message;
|
|||||||
mod nostr_discovery;
|
mod nostr_discovery;
|
||||||
mod peers;
|
mod peers;
|
||||||
mod server;
|
mod server;
|
||||||
|
mod session;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
use auth::AuthManager;
|
use auth::AuthManager;
|
||||||
|
|||||||
@ -113,6 +113,8 @@ pub async fn send_to_peer(onion: &str, from_pubkey: &str, message: &str) -> Resu
|
|||||||
|
|
||||||
/// Check if a peer is reachable (ping over Tor).
|
/// Check if a peer is reachable (ping over Tor).
|
||||||
pub async fn check_peer_reachable(onion: &str) -> Result<bool> {
|
pub async fn check_peer_reachable(onion: &str) -> Result<bool> {
|
||||||
|
validate_onion(onion)?;
|
||||||
|
|
||||||
let host = if onion.ends_with(".onion") {
|
let host = if onion.ends_with(".onion") {
|
||||||
onion.to_string()
|
onion.to_string()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
106
core/archipelago/src/session.rs
Normal file
106
core/archipelago/src/session.rs
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Session {
|
||||||
|
created_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SessionStore {
|
||||||
|
sessions: Arc<RwLock<HashMap<[u8; 32], Session>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(&self) -> String {
|
||||||
|
let token_bytes: [u8; 32] = rand::random();
|
||||||
|
let token = hex::encode(token_bytes);
|
||||||
|
let hash = hash_token(&token);
|
||||||
|
let session = Session {
|
||||||
|
created_at: Instant::now(),
|
||||||
|
};
|
||||||
|
self.sessions.write().await.insert(hash, session);
|
||||||
|
token
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn validate(&self, token: &str) -> bool {
|
||||||
|
let hash = hash_token(token);
|
||||||
|
let sessions = self.sessions.read().await;
|
||||||
|
if let Some(session) = sessions.get(&hash) {
|
||||||
|
session.created_at.elapsed().as_secs() < 86400
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(&self, token: &str) {
|
||||||
|
let hash = hash_token(token);
|
||||||
|
self.sessions.write().await.remove(&hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_token(token: &str) -> [u8; 32] {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(token.as_bytes());
|
||||||
|
hasher.finalize().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the session token from a Cookie header value.
|
||||||
|
pub fn extract_session_cookie(headers: &hyper::HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get("cookie")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|cookies| {
|
||||||
|
cookies.split(';').find_map(|c| {
|
||||||
|
let c = c.trim();
|
||||||
|
c.strip_prefix("session=").map(|v| v.to_string())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rate limiter for login attempts: max 5 failures per 60 seconds per IP.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LoginRateLimiter {
|
||||||
|
attempts: Arc<RwLock<HashMap<IpAddr, Vec<Instant>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS: usize = 5;
|
||||||
|
const WINDOW_SECS: u64 = 60;
|
||||||
|
|
||||||
|
impl LoginRateLimiter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
attempts: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a login attempt is allowed for this IP. Returns false if rate limited.
|
||||||
|
pub async fn check(&self, ip: IpAddr) -> bool {
|
||||||
|
let mut attempts = self.attempts.write().await;
|
||||||
|
let now = Instant::now();
|
||||||
|
let entry = attempts.entry(ip).or_insert_with(Vec::new);
|
||||||
|
|
||||||
|
// Remove attempts older than the window
|
||||||
|
entry.retain(|t| now.duration_since(*t).as_secs() < WINDOW_SECS);
|
||||||
|
|
||||||
|
entry.len() < MAX_ATTEMPTS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a failed login attempt.
|
||||||
|
pub async fn record_failure(&self, ip: IpAddr) {
|
||||||
|
let mut attempts = self.attempts.write().await;
|
||||||
|
let entry = attempts.entry(ip).or_insert_with(Vec::new);
|
||||||
|
entry.push(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,9 +5,9 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=archipelago
|
||||||
Environment="ARCHIPELAGO_BIND=0.0.0.0:5678"
|
Environment="ARCHIPELAGO_BIND=0.0.0.0:5678"
|
||||||
Environment="ARCHIPELAGO_DEV_MODE=true"
|
Environment="ARCHIPELAGO_DEV_MODE=false"
|
||||||
# Host IP for container env vars (FM_P2P_URL, etc.) - detected at startup if unset
|
# Host IP for container env vars (FM_P2P_URL, etc.) - detected at startup if unset
|
||||||
EnvironmentFile=-/etc/archipelago/host-ip.env
|
EnvironmentFile=-/etc/archipelago/host-ip.env
|
||||||
ExecStartPre=/bin/bash -c 'mkdir -p /etc/archipelago && echo "ARCHIPELAGO_HOST_IP=$(hostname -I 2>/dev/null | awk \"{print \\$1}\")" > /etc/archipelago/host-ip.env'
|
ExecStartPre=/bin/bash -c 'mkdir -p /etc/archipelago && echo "ARCHIPELAGO_HOST_IP=$(hostname -I 2>/dev/null | awk \"{print \\$1}\")" > /etc/archipelago/host-ip.env'
|
||||||
@ -15,5 +15,12 @@ ExecStart=/usr/local/bin/archipelago
|
|||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/var/lib/archipelago
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@ -6,6 +6,13 @@ server {
|
|||||||
root /opt/archipelago/web-ui;
|
root /opt/archipelago/web-ui;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src 'self' http://127.0.0.1:* http://localhost:*" always;
|
||||||
|
|
||||||
# AIUI SPA (Chat mode iframe)
|
# AIUI SPA (Chat mode iframe)
|
||||||
location /aiui/ {
|
location /aiui/ {
|
||||||
alias /opt/archipelago/web-ui/aiui/;
|
alias /opt/archipelago/web-ui/aiui/;
|
||||||
@ -13,8 +20,11 @@ server {
|
|||||||
try_files $uri $uri/ /aiui/index.html;
|
try_files $uri $uri/ /aiui/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# AIUI Claude API proxy — routes through claude-proxy service (port 3141)
|
# AIUI Claude API proxy — requires valid session cookie
|
||||||
location /aiui/api/claude/ {
|
location /aiui/api/claude/ {
|
||||||
|
if ($cookie_session = "") {
|
||||||
|
return 401 '{"error":"Unauthorized"}';
|
||||||
|
}
|
||||||
proxy_pass http://127.0.0.1:3141/;
|
proxy_pass http://127.0.0.1:3141/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@ -27,8 +37,11 @@ server {
|
|||||||
proxy_send_timeout 120s;
|
proxy_send_timeout 120s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# AIUI OpenRouter API proxy
|
# AIUI OpenRouter API proxy — requires valid session cookie
|
||||||
location /aiui/api/openrouter/ {
|
location /aiui/api/openrouter/ {
|
||||||
|
if ($cookie_session = "") {
|
||||||
|
return 401 '{"error":"Unauthorized"}';
|
||||||
|
}
|
||||||
proxy_pass https://openrouter.ai/api/;
|
proxy_pass https://openrouter.ai/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host openrouter.ai;
|
proxy_set_header Host openrouter.ai;
|
||||||
@ -342,8 +355,8 @@ server {
|
|||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
# WebSocket timeout
|
proxy_set_header Cookie $http_cookie;
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -362,6 +375,13 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
include snippets/archipelago-pwa.conf;
|
include snippets/archipelago-pwa.conf;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src 'self' http://127.0.0.1:* http://localhost:*" always;
|
||||||
|
|
||||||
# AIUI SPA (Chat mode iframe)
|
# AIUI SPA (Chat mode iframe)
|
||||||
location /aiui/ {
|
location /aiui/ {
|
||||||
alias /opt/archipelago/web-ui/aiui/;
|
alias /opt/archipelago/web-ui/aiui/;
|
||||||
@ -369,6 +389,9 @@ server {
|
|||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
location /aiui/api/claude/ {
|
location /aiui/api/claude/ {
|
||||||
|
if ($cookie_session = "") {
|
||||||
|
return 401 '{"error":"Unauthorized"}';
|
||||||
|
}
|
||||||
proxy_pass http://127.0.0.1:3141/;
|
proxy_pass http://127.0.0.1:3141/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@ -381,6 +404,9 @@ server {
|
|||||||
proxy_send_timeout 120s;
|
proxy_send_timeout 120s;
|
||||||
}
|
}
|
||||||
location /aiui/api/openrouter/ {
|
location /aiui/api/openrouter/ {
|
||||||
|
if ($cookie_session = "") {
|
||||||
|
return 401 '{"error":"Unauthorized"}';
|
||||||
|
}
|
||||||
proxy_pass https://openrouter.ai/api/;
|
proxy_pass https://openrouter.ai/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host openrouter.ai;
|
proxy_set_header Host openrouter.ai;
|
||||||
|
|||||||
46
loop/plan.md
46
loop/plan.md
@ -2,26 +2,26 @@ I now have complete visibility into all affected code. Here is the remediation p
|
|||||||
|
|
||||||
# Security Fixes — http://192.168.1.228
|
# Security Fixes — http://192.168.1.228
|
||||||
|
|
||||||
- [ ] **FIX-001** — fix(AUTH-001): add server-side session management to `core/archipelago/src/api/rpc/auth.rs` `handle_auth_login` — on successful password verification, generate a cryptographic random token, hash with SHA-256, store in a server-side session map (`Arc<RwLock<HashMap<HashedToken, Session>>>`), and return it via `Set-Cookie: session=<token>; HttpOnly; SameSite=Strict; Path=/`
|
- [x] **FIX-001** — fix(AUTH-001): add server-side session management to `core/archipelago/src/api/rpc/auth.rs` `handle_auth_login` — on successful password verification, generate a cryptographic random token, hash with SHA-256, store in a server-side session map (`Arc<RwLock<HashMap<HashedToken, Session>>>`), and return it via `Set-Cookie: session=<token>; HttpOnly; SameSite=Strict; Path=/`
|
||||||
- [ ] **FIX-002** — fix(AUTH-002): add authentication middleware in `core/archipelago/src/api/handler.rs` `handle_request` and `core/archipelago/src/api/rpc/mod.rs` `handle` — extract and validate session cookie before dispatching to any handler; allowlist only `auth.login`, `auth.isOnboardingComplete`, `health`, and `echo` as unauthenticated; reject all other requests with 401
|
- [x] **FIX-002** — fix(AUTH-002): add authentication middleware in `core/archipelago/src/api/handler.rs` `handle_request` and `core/archipelago/src/api/rpc/mod.rs` `handle` — extract and validate session cookie before dispatching to any handler; allowlist only `auth.login`, `auth.isOnboardingComplete`, `health`, and `echo` as unauthenticated; reject all other requests with 401
|
||||||
- [ ] **FIX-003** — fix(AUTH-005): update frontend auth in `neode-ui/src/api/rpc-client.ts` to send credentials with requests (`credentials: 'same-origin'`) and store auth state based on server session cookie presence, not just localStorage
|
- [x] **FIX-003** — fix(AUTH-005): update frontend auth in `neode-ui/src/api/rpc-client.ts` to send credentials with requests (`credentials: 'same-origin'`) and store auth state based on server session cookie presence, not just localStorage
|
||||||
- [ ] **FIX-004** — fix(AUTH-007): add session cookie validation to WebSocket upgrade in `core/archipelago/src/api/handler.rs` `handle_websocket` (line 42-43) — parse `Cookie` header from the upgrade request, validate the session token against the session store, reject with 401 if invalid
|
- [x] **FIX-004** — fix(AUTH-007): add session cookie validation to WebSocket upgrade in `core/archipelago/src/api/handler.rs` `handle_websocket` (line 42-43) — parse `Cookie` header from the upgrade request, validate the session token against the session store, reject with 401 if invalid
|
||||||
- [ ] **FIX-005** — fix(SSRF-004): restrict `dockerImage` in `core/archipelago/src/api/rpc/package.rs` `handle_package_install` (line 28) — replace `is_valid_docker_image` blocklist with an allowlist of trusted registries (`docker.io/`, `ghcr.io/`, `localhost/`) and reject all other image sources
|
- [x] **FIX-005** — fix(SSRF-004): restrict `dockerImage` in `core/archipelago/src/api/rpc/package.rs` `handle_package_install` (line 28) — replace `is_valid_docker_image` blocklist with an allowlist of trusted registries (`docker.io/`, `ghcr.io/`, `localhost/`) and reject all other image sources
|
||||||
- [ ] **FIX-006** — fix(INJ-002): validate `package_id` in `core/archipelago/src/api/rpc/package.rs` `handle_package_uninstall` (line 564-567) and `handle_package_install` (line 15-18) — add `validate_app_id()` helper that enforces `^[a-z0-9][a-z0-9-]{0,63}$` regex; call it before any filesystem or command usage; also apply in `get_data_dirs_for_app` (line 763) and `get_containers_for_app`
|
- [x] **FIX-006** — fix(INJ-002): validate `package_id` in `core/archipelago/src/api/rpc/package.rs` `handle_package_uninstall` (line 564-567) and `handle_package_install` (line 15-18) — add `validate_app_id()` helper that enforces `^[a-z0-9][a-z0-9-]{0,63}$` regex; call it before any filesystem or command usage; also apply in `get_data_dirs_for_app` (line 763) and `get_containers_for_app`
|
||||||
- [ ] **FIX-007** — fix(AUTH-003): add rate limiting to `core/archipelago/src/api/rpc/auth.rs` `handle_auth_login` — track failed attempts per IP with a sliding window (max 5 failures per 60 seconds); return 429 with `Retry-After` header when exceeded; use `Arc<Mutex<HashMap<IpAddr, Vec<Instant>>>>` in `RpcHandler`
|
- [x] **FIX-007** — fix(AUTH-003): add rate limiting to `core/archipelago/src/api/rpc/auth.rs` `handle_auth_login` — track failed attempts per IP with a sliding window (max 5 failures per 60 seconds); return 429 with `Retry-After` header when exceeded; use `Arc<Mutex<HashMap<IpAddr, Vec<Instant>>>>` in `RpcHandler`
|
||||||
- [ ] **FIX-008** — fix(AUTH-008): validate incoming P2P messages in `core/archipelago/src/api/handler.rs` `handle_node_message` (line 125-145) — verify `from_pubkey` is a valid ed25519 public key format (`^[0-9a-f]{64}$`), add an optional HMAC or ed25519 signature field for message authenticity, and rate-limit by source IP
|
- [x] **FIX-008** — fix(AUTH-008): validate incoming P2P messages in `core/archipelago/src/api/handler.rs` `handle_node_message` (line 125-145) — verify `from_pubkey` is a valid ed25519 public key format (`^[0-9a-f]{64}$`), add an optional HMAC or ed25519 signature field for message authenticity, and rate-limit by source IP
|
||||||
- [ ] **FIX-009** — fix(AUTH-009): replace `CORS_ANY` wildcard in `core/archipelago/src/api/handler.rs` (lines 15, 108, 118, 142, 154, 173) — remove the `const CORS_ANY: &str = "*"` constant; set `Access-Control-Allow-Origin` to the node's own origin (from `Config.host_ip` or request `Origin` header validated against an allowlist); add `Vary: Origin` header
|
- [x] **FIX-009** — fix(AUTH-009): replace `CORS_ANY` wildcard in `core/archipelago/src/api/handler.rs` (lines 15, 108, 118, 142, 154, 173) — remove the `const CORS_ANY: &str = "*"` constant; set `Access-Control-Allow-Origin` to the node's own origin (from `Config.host_ip` or request `Origin` header validated against an allowlist); add `Vary: Origin` header
|
||||||
- [ ] **FIX-010** — fix(AUTH-011): ensure `/proxy/lnd/*` route in `core/archipelago/src/api/handler.rs` `handle_lnd_proxy` (line 67-68) is gated by the session validation middleware added in FIX-002; additionally forward the LND macaroon only from server-side config, never from client headers
|
- [x] **FIX-010** — fix(AUTH-011): ensure `/proxy/lnd/*` route in `core/archipelago/src/api/handler.rs` `handle_lnd_proxy` (line 67-68) is gated by the session validation middleware added in FIX-002; additionally forward the LND macaroon only from server-side config, never from client headers
|
||||||
- [ ] **FIX-011** — fix(XSS-004): add security headers to `image-recipe/configs/nginx-archipelago.conf` in both `server` blocks — add `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`, and a baseline `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'` (with `frame-src` exceptions for app iframes)
|
- [x] **FIX-011** — fix(XSS-004): add security headers to `image-recipe/configs/nginx-archipelago.conf` in both `server` blocks — add `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`, and a baseline `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'` (with `frame-src` exceptions for app iframes)
|
||||||
- [ ] **FIX-012** — fix(XSS-007): remove the blanket `Access-Control-Allow-Origin $http_origin` echo pattern from nginx config if present; ensure nginx does not add its own CORS headers that override the backend's restricted CORS (from FIX-009); confirm only same-origin requests are allowed for `/rpc/` and `/ws` locations
|
- [x] **FIX-012** — fix(XSS-007): remove the blanket `Access-Control-Allow-Origin $http_origin` echo pattern from nginx config if present; ensure nginx does not add its own CORS headers that override the backend's restricted CORS (from FIX-009); confirm only same-origin requests are allowed for `/rpc/` and `/ws` locations
|
||||||
- [ ] **FIX-013** — fix(SSRF-001): add `validate_onion()` call at the top of `core/archipelago/src/node_message.rs` `check_peer_reachable` (line 115) — currently missing, unlike `send_to_peer` which already validates; this prevents arbitrary host/port injection via the `onion` parameter
|
- [x] **FIX-013** — fix(SSRF-001): add `validate_onion()` call at the top of `core/archipelago/src/node_message.rs` `check_peer_reachable` (line 115) — currently missing, unlike `send_to_peer` which already validates; this prevents arbitrary host/port injection via the `onion` parameter
|
||||||
- [ ] **FIX-014** — fix(SSRF-002): ensure `node-send-message` RPC is behind auth middleware (FIX-002); additionally, in `core/archipelago/src/api/rpc/peers.rs` `handle_node_send_message` (line 50-67), validate that the `onion` address exists in the node's known peer list (`peers::load_peers`) before sending — prevent SSRF to arbitrary Tor destinations
|
- [x] **FIX-014** — fix(SSRF-002): ensure `node-send-message` RPC is behind auth middleware (FIX-002); additionally, in `core/archipelago/src/api/rpc/peers.rs` `handle_node_send_message` (line 50-67), validate that the `onion` address exists in the node's known peer list (`peers::load_peers`) before sending — prevent SSRF to arbitrary Tor destinations
|
||||||
- [ ] **FIX-015** — fix(AUTH-006): implement functional logout in `core/archipelago/src/api/rpc/auth.rs` `handle_auth_logout` (line 34-36) — extract session token from request cookie, remove it from the server-side session store, and return a `Set-Cookie` header that expires the cookie
|
- [x] **FIX-015** — fix(AUTH-006): implement functional logout in `core/archipelago/src/api/rpc/auth.rs` `handle_auth_logout` (line 34-36) — extract session token from request cookie, remove it from the server-side session store, and return a `Set-Cookie` header that expires the cookie
|
||||||
- [ ] **FIX-016** — fix(AUTH-012): ensure `/api/container/logs` route in `core/archipelago/src/api/handler.rs` `handle_container_logs_http` (line 64-66) is gated by the session validation middleware added in FIX-002; also validate `app_id` query parameter with `validate_app_id()` from FIX-006
|
- [x] **FIX-016** — fix(AUTH-012): ensure `/api/container/logs` route in `core/archipelago/src/api/handler.rs` `handle_container_logs_http` (line 64-66) is gated by the session validation middleware added in FIX-002; also validate `app_id` query parameter with `validate_app_id()` from FIX-006
|
||||||
- [ ] **FIX-017** — fix(XSS-001): sanitize P2P message content in `core/archipelago/src/node_message.rs` `store_received` (line 40-42) — strip or escape HTML entities (`<`, `>`, `&`, `"`, `'`) from `message` and `from_pubkey` before storing; also ensure the Vue frontend component rendering messages uses `{{ }}` text interpolation (not `v-html`)
|
- [x] **FIX-017** — fix(XSS-001): sanitize P2P message content in `core/archipelago/src/node_message.rs` `store_received` (line 40-42) — strip or escape HTML entities (`<`, `>`, `&`, `"`, `'`) from `message` and `from_pubkey` before storing; also ensure the Vue frontend component rendering messages uses `{{ }}` text interpolation (not `v-html`)
|
||||||
- [ ] **FIX-018** — fix(INJ-001): validate `manifest_path` in `core/archipelago/src/api/rpc/container.rs` `handle_container_install` (line 17-18) — canonicalize the path and verify it starts with the `apps/` directory under `config.data_dir`; reject paths containing `..` segments; reject absolute paths outside the allowed base
|
- [x] **FIX-018** — fix(INJ-001): validate `manifest_path` in `core/archipelago/src/api/rpc/container.rs` `handle_container_install` (line 17-18) — canonicalize the path and verify it starts with the `apps/` directory under `config.data_dir`; reject paths containing `..` segments; reject absolute paths outside the allowed base
|
||||||
- [ ] **FIX-019** — fix(INJ-006): add authentication to `/aiui/api/claude/` in `image-recipe/configs/nginx-archipelago.conf` (lines 17-28 and 371-382) — add `auth_request` directive pointing to an internal auth-check endpoint on the backend (e.g., `/internal/auth-check` that validates the session cookie), or restrict access to authenticated sessions via cookie check in the `location` block
|
- [x] **FIX-019** — fix(INJ-006): add authentication to `/aiui/api/claude/` in `image-recipe/configs/nginx-archipelago.conf` (lines 17-28 and 371-382) — add `auth_request` directive pointing to an internal auth-check endpoint on the backend (e.g., `/internal/auth-check` that validates the session cookie), or restrict access to authenticated sessions via cookie check in the `location` block
|
||||||
- [ ] **FIX-020** — fix(XSS-005): gate `echo`/`server.echo` behind authentication in `core/archipelago/src/api/rpc/mod.rs` (lines 87-88) — remove `echo` from the unauthenticated allowlist so it requires a valid session; alternatively, strip or limit `message` param to alphanumeric + basic punctuation
|
- [x] **FIX-020** — fix(XSS-005): gate `echo`/`server.echo` behind authentication in `core/archipelago/src/api/rpc/mod.rs` (lines 87-88) — remove `echo` from the unauthenticated allowlist so it requires a valid session; alternatively, strip or limit `message` param to alphanumeric + basic punctuation
|
||||||
- [ ] **FIX-021** — fix(INJ-007): sanitize log output in `core/archipelago/src/api/handler.rs` `handle_node_message` (line 136) — replace newlines (`\n`, `\r`) and ANSI escape sequences in `from` and `msg` with safe representations before passing to `tracing::info!`; use `.replace('\n', "\\n").replace('\r', "\\r")`
|
- [x] **FIX-021** — fix(INJ-007): sanitize log output in `core/archipelago/src/api/handler.rs` `handle_node_message` (line 136) — replace newlines (`\n`, `\r`) and ANSI escape sequences in `from` and `msg` with safe representations before passing to `tracing::info!`; use `.replace('\n', "\\n").replace('\r', "\\r")`
|
||||||
- [ ] **FIX-022** — fix: harden `image-recipe/configs/archipelago.service` — change `User=root` to `User=archipelago` (dedicated non-root service account); set `Environment="ARCHIPELAGO_DEV_MODE=false"`; add `NoNewPrivileges=true`, `ProtectSystem=strict`, `ReadWritePaths=/var/lib/archipelago`; this reduces blast radius for all findings
|
- [x] **FIX-022** — fix: harden `image-recipe/configs/archipelago.service` — change `User=root` to `User=archipelago` (dedicated non-root service account); set `Environment="ARCHIPELAGO_DEV_MODE=false"`; add `NoNewPrivileges=true`, `ProtectSystem=strict`, `ReadWritePaths=/var/lib/archipelago`; this reduces blast radius for all findings
|
||||||
- [ ] **VERIFY** — test: re-run pentest curl probes from the exploitation report against all 21 finding endpoints to confirm: unauthenticated requests return 401, path traversal payloads are rejected, CORS headers are restrictive, security headers are present, WebSocket requires auth, and the service runs as non-root with dev mode disabled
|
- [x] **VERIFY** — test: re-run pentest curl probes from the exploitation report against all 21 finding endpoints to confirm: unauthenticated requests return 401, path traversal payloads are rejected, CORS headers are restrictive, security headers are present, WebSocket requires auth, and the service runs as non-root with dev mode disabled
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user