iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

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:
Mdocs/security-review.md | 12++++++------
Msrc/adapters/http.rs | 5+++++
Msrc/adapters/http/actions.rs | 2++
Msrc/adapters/http/auth_routes.rs | 361++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/adapters/http/request_auth.rs | 242++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/adapters/http/state.rs | 2++
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)]