commit 1d747dac316cb28b7a8511a4c8fd43a140954751
parent cbf4be824638ae354231131f2c7e1f170ce04350
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sat, 29 Aug 2026 10:39:13 +0200
Harden first-run password setup
Diffstat:
6 files changed, 607 insertions(+), 17 deletions(-)
diff --git a/docs/security-review.md b/docs/security-review.md
@@ -3,8 +3,6 @@
This document tracks the pre-mainnet security review. It is a review ledger, not
a claim that mainnet is safe. Keep the roadmap security-review checkbox open
until every launch-blocking item below is resolved or explicitly accepted.
-Actionable remediation and final go/no-go criteria are tracked in
-`docs/mainnet-security-checklist.md`.
## Review Scope
@@ -220,10 +218,12 @@ see which revision was tested.
`0600` temporary files on Unix. Management UI password hashes also use
PBKDF2-SHA256 with bounded iteration counts, login backoff, session expiry,
`HttpOnly`/`SameSite=Strict` cookies, CSRF same-origin checks, and trusted
- forwarded headers only from loopback proxies. Residual accepted risk for the
- candidate: PBKDF2 is CPU-hard rather than memory-hard, so operators must use
- strong unique passwords and keep the management UI local or otherwise
- protected.
+ forwarded headers only from loopback proxies. First-run password setup also
+ requires an exact loopback Host/Origin/port match and a cryptographically
+ random, one-time `HttpOnly` setup capability issued only to the local
+ management page. Residual accepted risk for the candidate: PBKDF2 is CPU-hard
+ rather than memory-hard, so operators must use strong unique passwords and
+ keep the management UI local or otherwise protected.
- Public exposure: verify bootnodes expose only the intended P2P and optional
Stratum ports, and that the management UI remains bound to a local or
otherwise protected address.
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -84,6 +84,8 @@ const EXPLORER_PAGE_LIMIT: usize = 20;
const DATASET_LIMIT: usize = 1_000;
const DATASET_PAGE_LIMIT: usize = 25;
const AUTH_COOKIE_NAME: &str = "iuna_session";
+const SETUP_COOKIE_NAME: &str = "iuna_setup";
+const SETUP_COOKIE_TTL_SECS: u64 = 10 * 60;
const AUTH_SESSION_TTL_MS: u64 = 12 * 60 * 60 * 1_000;
const AUTH_MAX_FAILED_ATTEMPTS: u32 = 5;
const AUTH_LOCKOUT_MS: u64 = 60 * 1_000;
@@ -107,6 +109,7 @@ pub async fn serve(
options: ServeOptions,
) -> Result<()> {
let addr = options.addr;
+ let setup_capability = auth::random_hex(32)?;
let state = HttpState {
node,
peers,
@@ -119,6 +122,8 @@ pub async fn serve(
stratum: options.stratum,
auth_sessions: Arc::new(Mutex::new(BTreeMap::new())),
auth_backoff: Arc::new(Mutex::new(BTreeMap::new())),
+ setup_capability: Arc::new(Mutex::new(Some(setup_capability))),
+ management_port: addr.port(),
};
let app = Router::new()
.route("/", get(index))
diff --git a/src/adapters/http/actions.rs b/src/adapters/http/actions.rs
@@ -587,6 +587,8 @@ mod tests {
},
auth_sessions: Arc::new(Mutex::new(BTreeMap::<String, AuthSession>::new())),
auth_backoff: Arc::new(Mutex::new(BTreeMap::<String, AuthBackoff>::new())),
+ setup_capability: Arc::new(Mutex::new(Some("test-setup-capability".to_string()))),
+ management_port: 9444,
};
reset_local_chain(&state, CHAIN_RESET_CONFIRMATION)
diff --git a/src/adapters/http/auth_routes.rs b/src/adapters/http/auth_routes.rs
@@ -11,11 +11,13 @@ use axum::{
use super::{
AUTH_COOKIE_NAME, ActionResponse, AuthClientKey, AuthForm, AuthStatusResponse,
- ChangePasswordForm, HttpState, action_json,
+ ChangePasswordForm, HttpState, SETUP_COOKIE_NAME, action_json,
auth::session_token_hash,
request_auth::{
- auth_client_key, auth_cookie, auth_exempt_path, change_auth_password, csrf_required,
+ auth_client_key, auth_cookie, auth_exempt_path, change_auth_password,
+ consume_setup_capability, csrf_required, local_setup_page_request, local_setup_request,
login_auth_password, request_is_authenticated, same_origin_request, setup_auth_password,
+ setup_capability_cookie, validate_setup_capability,
},
};
@@ -30,13 +32,30 @@ pub(super) async fn require_auth_middleware(
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|info| info.0);
+ if path == "/api/auth/setup"
+ && !local_setup_request(&headers, socket_addr, state.management_port)
+ {
+ return setup_origin_error().into_response();
+ }
if csrf_required(request.method()) && !same_origin_request(&headers, socket_addr) {
return csrf_error().into_response();
}
+ let issue_setup_cookie = path == "/"
+ && request.method() == axum::http::Method::GET
+ && local_setup_page_request(&headers, socket_addr, state.management_port)
+ && state.ui_config.lock().await.auth_password_hash.is_none();
let client_key = auth_client_key(&headers, socket_addr);
request.extensions_mut().insert(AuthClientKey(client_key));
if auth_exempt_path(&path) {
- return next.run(request).await;
+ let mut response = next.run(request).await;
+ if issue_setup_cookie {
+ if let Some(cookie) = setup_capability_cookie(&state).await {
+ if let Ok(value) = cookie.parse() {
+ response.headers_mut().append(header::SET_COOKIE, value);
+ }
+ }
+ }
+ return response;
}
let configured = state.ui_config.lock().await.auth_password_hash.is_some();
if !configured {
@@ -63,10 +82,28 @@ pub(super) async fn api_auth_status(
pub(super) async fn api_auth_setup_form(
State(state): State<HttpState>,
Extension(client_key): Extension<AuthClientKey>,
+ headers: HeaderMap,
Form(form): Form<AuthForm>,
) -> Response {
+ if let Err(error) = validate_setup_capability(&state, &headers).await {
+ return (StatusCode::FORBIDDEN, action_json(Err(error))).into_response();
+ }
match setup_auth_password(&state, &form.password, &client_key.0).await {
- Ok(cookie) => ([(header::SET_COOKIE, cookie)], action_json(Ok(()))).into_response(),
+ Ok(cookie) => {
+ consume_setup_capability(&state).await;
+ let mut response = action_json(Ok(())).into_response();
+ if let Ok(value) = cookie.parse() {
+ response.headers_mut().append(header::SET_COOKIE, value);
+ }
+ if let Ok(value) = format!(
+ "{SETUP_COOKIE_NAME}=; Path=/api/auth/setup; HttpOnly; SameSite=Strict; Max-Age=0"
+ )
+ .parse()
+ {
+ response.headers_mut().append(header::SET_COOKIE, value);
+ }
+ response
+ }
Err(error) => action_json(Err(error)).into_response(),
}
}
@@ -140,3 +177,319 @@ fn csrf_error() -> (StatusCode, Json<ActionResponse>) {
}),
)
}
+
+fn setup_origin_error() -> (StatusCode, Json<ActionResponse>) {
+ (
+ StatusCode::FORBIDDEN,
+ Json(ActionResponse {
+ ok: false,
+ error: Some(
+ "password setup is only available from the local management origin".to_string(),
+ ),
+ }),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{
+ collections::BTreeMap,
+ net::{IpAddr, Ipv4Addr, SocketAddr},
+ sync::Arc,
+ };
+
+ use axum::{
+ Router,
+ body::{Body, to_bytes},
+ extract::ConnectInfo,
+ http::{Method, Request, StatusCode, header},
+ middleware,
+ routing::{get, post},
+ };
+ use tokio::sync::Mutex;
+ use tower::ServiceExt;
+
+ use crate::{
+ adapters::{
+ chain_store::SqliteChainStore, config_store::UiConfig, p2p::GossipNetwork,
+ ui_data_store::SqliteUiDataStore, wallet_store,
+ },
+ app::{NodeCore, PeerBook, StratumStatus},
+ domain::{GenesisBurn, Ledger, MICRO_IUNA},
+ };
+
+ use super::super::state::{AuthBackoff, AuthSession, HttpState};
+ use super::{api_auth_setup_form, api_auth_status, require_auth_middleware};
+
+ const MANAGEMENT_PORT: u16 = 18_661;
+ const SETUP_CAPABILITY: &str = "test-setup-capability";
+ const TEST_SEED: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
+
+ fn loopback_peer() -> SocketAddr {
+ SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 51_234)
+ }
+
+ fn remote_peer() -> SocketAddr {
+ SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 10)), 51_234)
+ }
+
+ fn request(
+ method: Method,
+ uri: &str,
+ headers: &[(&str, &str)],
+ body: &str,
+ peer: SocketAddr,
+ ) -> Request<Body> {
+ let mut builder = Request::builder().method(method).uri(uri);
+ for (name, value) in headers {
+ builder = builder.header(*name, *value);
+ }
+ let mut request = builder.body(Body::from(body.to_string())).unwrap();
+ request.extensions_mut().insert(ConnectInfo(peer));
+ request
+ }
+
+ async fn test_state() -> HttpState {
+ let dir = tempfile::tempdir().unwrap().keep();
+ let wallet_path = dir.join("wallet.json");
+ let wallet =
+ wallet_store::replace_with_imported_seed_phrase(&wallet_path, TEST_SEED).unwrap();
+ let mut allocations = BTreeMap::new();
+ allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
+ let ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ vec![GenesisBurn::new(wallet.address(), 1)],
+ 1,
+ )
+ .unwrap();
+ let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0)));
+ let peers = Arc::new(Mutex::new(PeerBook::default()));
+ let gossip = GossipNetwork::start(
+ node.clone(),
+ peers.clone(),
+ SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
+ None,
+ false,
+ )
+ .await
+ .unwrap();
+
+ HttpState {
+ node,
+ peers,
+ gossip,
+ ui_config: Arc::new(Mutex::new(UiConfig::default())),
+ config_path: dir.join("config.json"),
+ chain_store: SqliteChainStore::open(dir.join("chain.sqlite")).unwrap(),
+ ui_data_store: SqliteUiDataStore::open(dir.join("ui.sqlite")).unwrap(),
+ wallet_path,
+ stratum: StratumStatus {
+ enabled: false,
+ listen_addr: None,
+ },
+ auth_sessions: Arc::new(Mutex::new(BTreeMap::<String, AuthSession>::new())),
+ auth_backoff: Arc::new(Mutex::new(BTreeMap::<String, AuthBackoff>::new())),
+ setup_capability: Arc::new(Mutex::new(Some(SETUP_CAPABILITY.to_string()))),
+ management_port: MANAGEMENT_PORT,
+ }
+ }
+
+ fn test_app(state: HttpState) -> Router {
+ Router::new()
+ .route("/", get(|| async { "index" }))
+ .route("/api/auth/setup", post(api_auth_setup_form))
+ .route("/api/auth/status", get(api_auth_status))
+ .layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_auth_middleware,
+ ))
+ .with_state(state)
+ }
+
+ #[tokio::test]
+ async fn setup_route_requires_local_origin_and_one_time_capability() {
+ let state = test_state().await;
+ let app = test_app(state.clone());
+ let local_origin = format!("http://127.0.0.1:{MANAGEMENT_PORT}");
+ let local_host = format!("127.0.0.1:{MANAGEMENT_PORT}");
+
+ let page = app
+ .clone()
+ .oneshot(request(
+ Method::GET,
+ "/",
+ &[(header::HOST.as_str(), &local_host)],
+ "",
+ loopback_peer(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(page.status(), StatusCode::OK);
+ let setup_cookie = page
+ .headers()
+ .get_all(header::SET_COOKIE)
+ .iter()
+ .filter_map(|value| value.to_str().ok())
+ .find(|value| value.starts_with("iuna_setup="))
+ .and_then(|value| value.split(';').next())
+ .unwrap()
+ .to_string();
+
+ let rebound = app
+ .clone()
+ .oneshot(request(
+ Method::POST,
+ "/api/auth/setup",
+ &[
+ (header::HOST.as_str(), "rebound.evil:18661"),
+ (header::ORIGIN.as_str(), "http://rebound.evil:18661"),
+ (
+ header::CONTENT_TYPE.as_str(),
+ "application/x-www-form-urlencoded",
+ ),
+ (header::COOKIE.as_str(), &setup_cookie),
+ ],
+ "password=correct-horse-battery-staple",
+ loopback_peer(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(rebound.status(), StatusCode::FORBIDDEN);
+ assert!(state.ui_config.lock().await.auth_password_hash.is_none());
+
+ let remote = app
+ .clone()
+ .oneshot(request(
+ Method::POST,
+ "/api/auth/setup",
+ &[
+ (header::HOST.as_str(), &local_host),
+ (header::ORIGIN.as_str(), &local_origin),
+ (
+ header::CONTENT_TYPE.as_str(),
+ "application/x-www-form-urlencoded",
+ ),
+ (header::COOKIE.as_str(), &setup_cookie),
+ ],
+ "password=correct-horse-battery-staple",
+ remote_peer(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(remote.status(), StatusCode::FORBIDDEN);
+
+ let missing_capability = app
+ .clone()
+ .oneshot(request(
+ Method::POST,
+ "/api/auth/setup",
+ &[
+ (header::HOST.as_str(), &local_host),
+ (header::ORIGIN.as_str(), &local_origin),
+ (
+ header::CONTENT_TYPE.as_str(),
+ "application/x-www-form-urlencoded",
+ ),
+ ],
+ "password=correct-horse-battery-staple",
+ loopback_peer(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(missing_capability.status(), StatusCode::FORBIDDEN);
+
+ let success = app
+ .clone()
+ .oneshot(request(
+ Method::POST,
+ "/api/auth/setup",
+ &[
+ (header::HOST.as_str(), &local_host),
+ (header::ORIGIN.as_str(), &local_origin),
+ (
+ header::CONTENT_TYPE.as_str(),
+ "application/x-www-form-urlencoded",
+ ),
+ (header::COOKIE.as_str(), &setup_cookie),
+ ],
+ "password=correct-horse-battery-staple",
+ loopback_peer(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(success.status(), StatusCode::OK);
+ let response_cookies = success
+ .headers()
+ .get_all(header::SET_COOKIE)
+ .iter()
+ .map(|value| value.to_str().unwrap().to_string())
+ .collect::<Vec<_>>();
+ assert!(
+ response_cookies
+ .iter()
+ .any(|cookie| cookie.starts_with("iuna_session="))
+ );
+ assert!(
+ response_cookies.iter().any(|cookie| {
+ cookie.starts_with("iuna_setup=") && cookie.contains("Max-Age=0")
+ })
+ );
+ let success_body = to_bytes(success.into_body(), 16 * 1024).await.unwrap();
+ let success_body = String::from_utf8(success_body.to_vec()).unwrap();
+ assert!(success_body.contains("\"ok\":true"));
+ assert!(!success_body.contains("abandon"));
+ assert!(state.ui_config.lock().await.auth_password_hash.is_some());
+ assert!(
+ wallet_store::metadata(&state.wallet_path)
+ .unwrap()
+ .unwrap()
+ .encrypted
+ );
+ assert!(state.setup_capability.lock().await.is_none());
+
+ let session_cookie = response_cookies
+ .iter()
+ .find(|cookie| cookie.starts_with("iuna_session="))
+ .and_then(|cookie| cookie.split(';').next())
+ .unwrap();
+ let status = app
+ .clone()
+ .oneshot(request(
+ Method::GET,
+ "/api/auth/status",
+ &[
+ (header::HOST.as_str(), &local_host),
+ (header::COOKIE.as_str(), session_cookie),
+ ],
+ "",
+ loopback_peer(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(status.status(), StatusCode::OK);
+ let status_body = to_bytes(status.into_body(), 16 * 1024).await.unwrap();
+ let status_body = String::from_utf8(status_body.to_vec()).unwrap();
+ assert!(status_body.contains("\"configured\":true"));
+ assert!(status_body.contains("\"authenticated\":true"));
+
+ let replay = app
+ .oneshot(request(
+ Method::POST,
+ "/api/auth/setup",
+ &[
+ (header::HOST.as_str(), &local_host),
+ (header::ORIGIN.as_str(), &local_origin),
+ (
+ header::CONTENT_TYPE.as_str(),
+ "application/x-www-form-urlencoded",
+ ),
+ (header::COOKIE.as_str(), &setup_cookie),
+ ],
+ "password=correct-horse-battery-staple",
+ loopback_peer(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(replay.status(), StatusCode::FORBIDDEN);
+ }
+}
diff --git a/src/adapters/http/request_auth.rs b/src/adapters/http/request_auth.rs
@@ -1,7 +1,10 @@
-use std::net::SocketAddr;
+use std::{
+ net::{IpAddr, SocketAddr},
+ str::FromStr,
+};
use anyhow::{Context, Result, bail};
-use axum::http::{HeaderMap, Method, header};
+use axum::http::{HeaderMap, Method, Uri, header, uri::Authority};
use crate::{
adapters::{config_store, wallet_store},
@@ -10,7 +13,7 @@ use crate::{
use super::{
AUTH_COOKIE_NAME, AUTH_LOCKOUT_MS, AUTH_MAX_FAILED_ATTEMPTS, AUTH_SESSION_TTL_MS, AuthSession,
- HttpState, UNKNOWN_CLIENT_KEY,
+ HttpState, SETUP_COOKIE_NAME, SETUP_COOKIE_TTL_SECS, UNKNOWN_CLIENT_KEY,
auth::{hash_password, random_hex, session_token_hash, validate_password, verify_password},
now_ms,
};
@@ -39,6 +42,64 @@ pub(super) fn same_origin_request(headers: &HeaderMap, socket_addr: Option<Socke
normalize_host(&origin_host) == normalize_host(&request_host)
}
+pub(super) fn local_setup_page_request(
+ headers: &HeaderMap,
+ socket_addr: Option<SocketAddr>,
+ management_port: u16,
+) -> bool {
+ socket_addr.is_some_and(|addr| addr.ip().is_loopback())
+ && raw_request_local_authority(headers, management_port).is_some()
+}
+
+pub(super) fn local_setup_request(
+ headers: &HeaderMap,
+ socket_addr: Option<SocketAddr>,
+ management_port: u16,
+) -> bool {
+ if !local_setup_page_request(headers, socket_addr, management_port) {
+ return false;
+ }
+ let Some(request_authority) = raw_request_local_authority(headers, management_port) else {
+ return false;
+ };
+ let Some(origin_authority) = local_origin_authority(headers, management_port) else {
+ return false;
+ };
+ request_authority == origin_authority
+}
+
+fn raw_request_local_authority(headers: &HeaderMap, management_port: u16) -> Option<(String, u16)> {
+ let host = header_string(headers, "host")?;
+ parse_local_authority(&host, management_port)
+}
+
+fn local_origin_authority(headers: &HeaderMap, management_port: u16) -> Option<(String, u16)> {
+ let value = header_string(headers, "origin").or_else(|| header_string(headers, "referer"))?;
+ let uri = Uri::from_str(&value).ok()?;
+ if uri.scheme_str() != Some("http") {
+ return None;
+ }
+ parse_local_authority(uri.authority()?.as_str(), management_port)
+}
+
+fn parse_local_authority(value: &str, management_port: u16) -> Option<(String, u16)> {
+ let authority = Authority::from_str(value.trim()).ok()?;
+ let port = authority.port_u16()?;
+ if port != management_port {
+ return None;
+ }
+ let host = authority
+ .host()
+ .trim_matches(['[', ']'])
+ .trim_end_matches('.')
+ .to_ascii_lowercase();
+ if host == "localhost" {
+ return Some((host, port));
+ }
+ let ip = IpAddr::from_str(&host).ok()?;
+ ip.is_loopback().then(|| (ip.to_string(), port))
+}
+
fn request_host(headers: &HeaderMap, socket_addr: Option<SocketAddr>) -> Option<String> {
let forwarded_host = socket_addr
.filter(|addr| trusted_forwarding_peer(addr.ip()))
@@ -93,6 +154,36 @@ pub(super) async fn request_is_authenticated(state: &HttpState, headers: &Header
.is_some_and(|session| session.expires_at > now)
}
+pub(super) async fn setup_capability_cookie(state: &HttpState) -> Option<String> {
+ state.setup_capability.lock().await.as_ref().map(|token| {
+ format!(
+ "{SETUP_COOKIE_NAME}={token}; Path=/api/auth/setup; HttpOnly; SameSite=Strict; Max-Age={SETUP_COOKIE_TTL_SECS}"
+ )
+ })
+}
+
+pub(super) async fn validate_setup_capability(
+ state: &HttpState,
+ headers: &HeaderMap,
+) -> Result<()> {
+ let supplied = named_cookie(headers, SETUP_COOKIE_NAME)
+ .context("local password setup capability is required")?;
+ let expected = state
+ .setup_capability
+ .lock()
+ .await
+ .clone()
+ .context("local password setup capability is no longer available")?;
+ if session_token_hash(supplied) != session_token_hash(&expected) {
+ bail!("local password setup capability is invalid");
+ }
+ Ok(())
+}
+
+pub(super) async fn consume_setup_capability(state: &HttpState) {
+ state.setup_capability.lock().await.take();
+}
+
pub(super) async fn wallet_password_for_request(
state: &HttpState,
headers: &HeaderMap,
@@ -296,10 +387,14 @@ async fn create_session_cookie(state: &HttpState, password: &str) -> Result<Stri
}
pub(super) fn auth_cookie(headers: &HeaderMap) -> Option<&str> {
+ named_cookie(headers, AUTH_COOKIE_NAME)
+}
+
+fn named_cookie<'a>(headers: &'a HeaderMap, expected_name: &str) -> Option<&'a str> {
let cookie = headers.get(header::COOKIE)?.to_str().ok()?;
cookie.split(';').find_map(|part| {
let (name, value) = part.trim().split_once('=')?;
- (name == AUTH_COOKIE_NAME).then_some(value)
+ (name == expected_name).then_some(value)
})
}
@@ -307,7 +402,7 @@ pub(super) fn auth_cookie(headers: &HeaderMap) -> Option<&str> {
mod tests {
use std::{
collections::BTreeMap,
- net::{IpAddr, Ipv4Addr, SocketAddr},
+ net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
sync::Arc,
};
@@ -326,8 +421,9 @@ mod tests {
use super::super::state::{AuthSession, HttpState};
use super::{AUTH_COOKIE_NAME, now_ms};
use super::{
- auth_client_key, check_auth_backoff, record_auth_failure, request_is_authenticated,
- same_origin_request, session_token_hash,
+ auth_client_key, check_auth_backoff, consume_setup_capability, local_setup_page_request,
+ local_setup_request, record_auth_failure, request_is_authenticated, same_origin_request,
+ session_token_hash, setup_capability_cookie, validate_setup_capability,
};
fn headers(values: &[(&'static str, &'static str)]) -> HeaderMap {
@@ -342,6 +438,10 @@ mod tests {
SocketAddr::new(IpAddr::V4(Ipv4Addr::from(ip)), 9444)
}
+ fn ipv6_loopback_socket() -> SocketAddr {
+ SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 9444)
+ }
+
async fn test_state() -> HttpState {
let dir = tempfile::tempdir().unwrap().keep();
let wallet = Wallet::from_seed("http-auth-abuse-tests");
@@ -380,6 +480,8 @@ mod tests {
},
auth_sessions: Arc::new(Mutex::new(BTreeMap::new())),
auth_backoff: Arc::new(Mutex::new(BTreeMap::new())),
+ setup_capability: Arc::new(Mutex::new(Some("test-setup-capability".to_string()))),
+ management_port: 9444,
}
}
@@ -413,6 +515,132 @@ mod tests {
}
#[test]
+ fn local_setup_accepts_only_exact_loopback_management_origins() {
+ assert!(local_setup_page_request(
+ &headers(&[("host", "127.0.0.1:9444")]),
+ Some(socket([127, 0, 0, 1])),
+ 9444
+ ));
+ assert!(local_setup_request(
+ &headers(&[
+ ("host", "127.0.0.1:9444"),
+ ("origin", "http://127.0.0.1:9444")
+ ]),
+ Some(socket([127, 0, 0, 1])),
+ 9444
+ ));
+ assert!(local_setup_request(
+ &headers(&[
+ ("host", "localhost:9444"),
+ ("referer", "http://localhost:9444/setup")
+ ]),
+ Some(socket([127, 0, 0, 1])),
+ 9444
+ ));
+ assert!(local_setup_request(
+ &headers(&[("host", "[::1]:9444"), ("origin", "http://[::1]:9444")]),
+ Some(ipv6_loopback_socket()),
+ 9444
+ ));
+ }
+
+ #[test]
+ fn local_setup_rejects_dns_rebinding_and_remote_requests() {
+ assert!(!local_setup_request(
+ &headers(&[
+ ("host", "rebound.evil:9444"),
+ ("origin", "http://rebound.evil:9444")
+ ]),
+ Some(socket([127, 0, 0, 1])),
+ 9444
+ ));
+ assert!(!local_setup_request(
+ &headers(&[
+ ("host", "127.0.0.1:9444"),
+ ("origin", "http://localhost:9444")
+ ]),
+ Some(socket([127, 0, 0, 1])),
+ 9444
+ ));
+ assert!(!local_setup_request(
+ &headers(&[
+ ("host", "127.0.0.1:9444"),
+ ("origin", "https://127.0.0.1:9444")
+ ]),
+ Some(socket([127, 0, 0, 1])),
+ 9444
+ ));
+ assert!(!local_setup_request(
+ &headers(&[
+ ("host", "127.0.0.1:9444"),
+ ("origin", "http://127.0.0.1:9444")
+ ]),
+ Some(socket([203, 0, 113, 10])),
+ 9444
+ ));
+ assert!(!local_setup_request(
+ &headers(&[
+ ("host", "127.0.0.1:9555"),
+ ("origin", "http://127.0.0.1:9555")
+ ]),
+ Some(socket([127, 0, 0, 1])),
+ 9444
+ ));
+ assert!(!local_setup_request(
+ &headers(&[
+ ("host", "rebound.evil:9444"),
+ ("x-forwarded-host", "127.0.0.1:9444"),
+ ("origin", "http://127.0.0.1:9444")
+ ]),
+ Some(socket([127, 0, 0, 1])),
+ 9444
+ ));
+ }
+
+ #[tokio::test]
+ async fn setup_capability_cookie_is_required_and_one_time() {
+ let state = test_state().await;
+ let cookie = setup_capability_cookie(&state).await.unwrap();
+ assert!(cookie.starts_with("iuna_setup=test-setup-capability;"));
+ assert!(cookie.contains("Path=/api/auth/setup"));
+ assert!(cookie.contains("HttpOnly"));
+ assert!(cookie.contains("SameSite=Strict"));
+
+ assert!(
+ validate_setup_capability(&state, &HeaderMap::new())
+ .await
+ .is_err()
+ );
+ assert!(
+ validate_setup_capability(
+ &state,
+ &headers(&[("cookie", "iuna_setup=wrong-capability")])
+ )
+ .await
+ .is_err()
+ );
+ assert!(
+ validate_setup_capability(
+ &state,
+ &headers(&[("cookie", "other=value; iuna_setup=test-setup-capability")])
+ )
+ .await
+ .is_ok()
+ );
+
+ consume_setup_capability(&state).await;
+ assert!(
+ validate_setup_capability(
+ &state,
+ &headers(&[("cookie", "iuna_setup=test-setup-capability")])
+ )
+ .await
+ .is_err()
+ );
+ assert!(setup_capability_cookie(&state).await.is_none());
+ }
+
+ #[test]
fn csrf_ignores_forwarded_host_from_untrusted_peer() {
let headers = headers(&[
("host", "127.0.0.1:9444"),
diff --git a/src/adapters/http/state.rs b/src/adapters/http/state.rs
@@ -23,6 +23,8 @@ pub(super) struct HttpState {
pub(super) stratum: StratumStatus,
pub(super) auth_sessions: Arc<Mutex<BTreeMap<String, AuthSession>>>,
pub(super) auth_backoff: Arc<Mutex<BTreeMap<String, AuthBackoff>>>,
+ pub(super) setup_capability: Arc<Mutex<Option<String>>>,
+ pub(super) management_port: u16,
}
#[derive(Clone)]