iuna

iuna

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

request_auth.rs (25520B)


      1 use std::{
      2     net::{IpAddr, SocketAddr},
      3     str::FromStr,
      4     sync::Arc,
      5 };
      6 
      7 use anyhow::{Context, Result, bail};
      8 use axum::http::{HeaderMap, Method, Uri, header, uri::Authority};
      9 use secrecy::{ExposeSecret, SecretString};
     10 
     11 use crate::{
     12     adapters::{config_store, wallet_store},
     13     domain::Wallet,
     14 };
     15 
     16 use super::{
     17     AUTH_COOKIE_NAME, AUTH_LOCKOUT_MS, AUTH_MAX_FAILED_ATTEMPTS, AUTH_SESSION_TTL_MS, AuthSession,
     18     HttpState, SETUP_COOKIE_NAME, SETUP_COOKIE_TTL_SECS, UNKNOWN_CLIENT_KEY,
     19     auth::{hash_password, random_hex, session_token_hash, validate_password, verify_password},
     20     now_ms,
     21 };
     22 
     23 pub(super) fn auth_exempt_path(path: &str) -> bool {
     24     path == "/"
     25         || path == "/favicon.ico"
     26         || path == "/assets/alpine.min.js"
     27         || path == "/assets/iuna-ui.js"
     28         || path == "/api/auth/status"
     29         || path == "/api/auth/setup"
     30         || path == "/api/auth/login"
     31 }
     32 
     33 pub(super) fn csrf_required(method: &Method) -> bool {
     34     !matches!(method, &Method::GET | &Method::HEAD | &Method::OPTIONS)
     35 }
     36 
     37 pub(super) fn same_origin_request(headers: &HeaderMap, socket_addr: Option<SocketAddr>) -> bool {
     38     let Some(request_host) = request_host(headers, socket_addr) else {
     39         return false;
     40     };
     41     let Some(origin_host) = origin_or_referer_host(headers) else {
     42         return false;
     43     };
     44     normalize_host(&origin_host) == normalize_host(&request_host)
     45 }
     46 
     47 pub(super) fn local_setup_page_request(
     48     headers: &HeaderMap,
     49     socket_addr: Option<SocketAddr>,
     50     management_port: u16,
     51 ) -> bool {
     52     socket_addr.is_some_and(|addr| addr.ip().is_loopback())
     53         && raw_request_local_authority(headers, management_port).is_some()
     54 }
     55 
     56 pub(super) fn local_setup_request(
     57     headers: &HeaderMap,
     58     socket_addr: Option<SocketAddr>,
     59     management_port: u16,
     60 ) -> bool {
     61     if !local_setup_page_request(headers, socket_addr, management_port) {
     62         return false;
     63     }
     64     let Some(request_authority) = raw_request_local_authority(headers, management_port) else {
     65         return false;
     66     };
     67     let Some(origin_authority) = local_origin_authority(headers, management_port) else {
     68         return false;
     69     };
     70     request_authority == origin_authority
     71 }
     72 
     73 fn raw_request_local_authority(headers: &HeaderMap, management_port: u16) -> Option<(String, u16)> {
     74     let host = header_string(headers, "host")?;
     75     parse_local_authority(&host, management_port)
     76 }
     77 
     78 fn local_origin_authority(headers: &HeaderMap, management_port: u16) -> Option<(String, u16)> {
     79     let value = header_string(headers, "origin").or_else(|| header_string(headers, "referer"))?;
     80     let uri = Uri::from_str(&value).ok()?;
     81     if uri.scheme_str() != Some("http") {
     82         return None;
     83     }
     84     parse_local_authority(uri.authority()?.as_str(), management_port)
     85 }
     86 
     87 fn parse_local_authority(value: &str, management_port: u16) -> Option<(String, u16)> {
     88     let authority = Authority::from_str(value.trim()).ok()?;
     89     let port = authority.port_u16()?;
     90     if port != management_port {
     91         return None;
     92     }
     93     let host = authority
     94         .host()
     95         .trim_matches(['[', ']'])
     96         .trim_end_matches('.')
     97         .to_ascii_lowercase();
     98     if host == "localhost" {
     99         return Some((host, port));
    100     }
    101     let ip = IpAddr::from_str(&host).ok()?;
    102     ip.is_loopback().then(|| (ip.to_string(), port))
    103 }
    104 
    105 fn request_host(headers: &HeaderMap, socket_addr: Option<SocketAddr>) -> Option<String> {
    106     let forwarded_host = socket_addr
    107         .filter(|addr| trusted_forwarding_peer(addr.ip()))
    108         .and_then(|_| header_string(headers, "x-forwarded-host"));
    109     forwarded_host.or_else(|| header_string(headers, "host"))
    110 }
    111 
    112 fn origin_or_referer_host(headers: &HeaderMap) -> Option<String> {
    113     header_string(headers, "origin")
    114         .and_then(|origin| url_host(&origin))
    115         .or_else(|| header_string(headers, "referer").and_then(|referer| url_host(&referer)))
    116 }
    117 
    118 fn header_string(headers: &HeaderMap, name: &'static str) -> Option<String> {
    119     headers
    120         .get(name)
    121         .and_then(|value| value.to_str().ok())
    122         .map(str::trim)
    123         .filter(|value| !value.is_empty())
    124         .map(ToOwned::to_owned)
    125 }
    126 
    127 fn url_host(value: &str) -> Option<String> {
    128     let (_, rest) = value.split_once("://")?;
    129     rest.split(['/', '?', '#'])
    130         .next()
    131         .map(str::trim)
    132         .filter(|authority| !authority.is_empty() && *authority != "null")
    133         .map(|authority| {
    134             authority
    135                 .rsplit('@')
    136                 .next()
    137                 .unwrap_or(authority)
    138                 .to_string()
    139         })
    140 }
    141 
    142 fn normalize_host(host: &str) -> String {
    143     host.trim().trim_end_matches('.').to_ascii_lowercase()
    144 }
    145 
    146 pub(super) async fn request_is_authenticated(state: &HttpState, headers: &HeaderMap) -> bool {
    147     let Some(token) = auth_cookie(headers) else {
    148         return false;
    149     };
    150     let token_hash = session_token_hash(token);
    151     let now = now_ms();
    152     let mut sessions = state.auth_sessions.lock().await;
    153     sessions.retain(|_, session| session.expires_at > now);
    154     sessions
    155         .get(&token_hash)
    156         .is_some_and(|session| session.expires_at > now)
    157 }
    158 
    159 pub(super) async fn setup_capability_cookie(state: &HttpState) -> Option<String> {
    160     state.setup_capability.lock().await.as_ref().map(|token| {
    161         format!(
    162             "{SETUP_COOKIE_NAME}={}; Path=/api/auth/setup; HttpOnly; SameSite=Strict; Max-Age={SETUP_COOKIE_TTL_SECS}",
    163             token.expose_secret()
    164         )
    165     })
    166 }
    167 
    168 pub(super) async fn validate_setup_capability(
    169     state: &HttpState,
    170     headers: &HeaderMap,
    171 ) -> Result<()> {
    172     let supplied = named_cookie(headers, SETUP_COOKIE_NAME)
    173         .context("local password setup capability is required")?;
    174     let capability = state.setup_capability.lock().await;
    175     let expected = capability
    176         .as_ref()
    177         .context("local password setup capability is no longer available")?;
    178     if session_token_hash(supplied) != session_token_hash(expected.expose_secret()) {
    179         bail!("local password setup capability is invalid");
    180     }
    181     Ok(())
    182 }
    183 
    184 pub(super) async fn consume_setup_capability(state: &HttpState) {
    185     state.setup_capability.lock().await.take();
    186 }
    187 
    188 pub(super) async fn wallet_password_for_request(
    189     state: &HttpState,
    190     headers: &HeaderMap,
    191 ) -> Option<Arc<SecretString>> {
    192     let token = auth_cookie(headers)?;
    193     let token_hash = session_token_hash(token);
    194     let now = now_ms();
    195     let mut sessions = state.auth_sessions.lock().await;
    196     sessions.retain(|_, session| session.expires_at > now);
    197     sessions
    198         .get(&token_hash)
    199         .filter(|session| session.expires_at > now)
    200         .map(|session| session.wallet_password.clone())
    201 }
    202 
    203 pub(super) fn auth_client_key(headers: &HeaderMap, socket_addr: Option<SocketAddr>) -> String {
    204     if let Some(addr) = socket_addr {
    205         if !trusted_forwarding_peer(addr.ip()) {
    206             return addr.ip().to_string();
    207         }
    208     }
    209     forwarded_for_client(headers)
    210         .or_else(|| header_string(headers, "x-real-ip"))
    211         .or_else(|| forwarded_header_client(headers))
    212         .or_else(|| socket_addr.map(|addr| addr.ip().to_string()))
    213         .unwrap_or_else(|| UNKNOWN_CLIENT_KEY.to_string())
    214 }
    215 
    216 fn trusted_forwarding_peer(ip: std::net::IpAddr) -> bool {
    217     ip.is_loopback()
    218 }
    219 
    220 fn forwarded_for_client(headers: &HeaderMap) -> Option<String> {
    221     header_string(headers, "x-forwarded-for").and_then(|value| {
    222         value
    223             .split(',')
    224             .next()
    225             .map(str::trim)
    226             .filter(|client| !client.is_empty())
    227             .map(ToOwned::to_owned)
    228     })
    229 }
    230 
    231 fn forwarded_header_client(headers: &HeaderMap) -> Option<String> {
    232     let value = header_string(headers, "forwarded")?;
    233     for item in value.split(';') {
    234         let Some((name, value)) = item.split_once('=') else {
    235             continue;
    236         };
    237         if name.trim().eq_ignore_ascii_case("for") {
    238             return Some(
    239                 value
    240                     .trim()
    241                     .trim_matches('"')
    242                     .trim_matches('[')
    243                     .trim_matches(']')
    244                     .to_string(),
    245             )
    246             .filter(|client| !client.is_empty());
    247         }
    248     }
    249     None
    250 }
    251 
    252 pub(super) async fn setup_auth_password(
    253     state: &HttpState,
    254     password: SecretString,
    255     client_key: &str,
    256 ) -> Result<String> {
    257     let exposed_password = password.expose_secret();
    258     check_auth_backoff(state, client_key).await?;
    259     if let Err(error) = validate_password(exposed_password) {
    260         record_auth_failure(state, client_key).await;
    261         return Err(error);
    262     }
    263     let mut config = state.ui_config.lock().await;
    264     if config.auth_password_hash.is_some() {
    265         record_auth_failure(state, client_key).await;
    266         bail!("authentication is already configured");
    267     }
    268     config.auth_password_hash = Some(hash_password(exposed_password)?);
    269     config_store::save(&state.config_path, &config)?;
    270     drop(config);
    271     wallet_store::encrypt_existing_with_password(&state.wallet_path, exposed_password)?;
    272     let wallet = wallet_store::load_with_password(&state.wallet_path, exposed_password)?;
    273     restore_node_wallet_from_store(state, wallet, Some(exposed_password)).await?;
    274     clear_auth_backoff(state, client_key).await;
    275     create_session_cookie(state, password).await
    276 }
    277 
    278 pub(super) async fn login_auth_password(
    279     state: &HttpState,
    280     password: SecretString,
    281     client_key: &str,
    282 ) -> Result<String> {
    283     let exposed_password = password.expose_secret();
    284     check_auth_backoff(state, client_key).await?;
    285     let hash = state
    286         .ui_config
    287         .lock()
    288         .await
    289         .auth_password_hash
    290         .clone()
    291         .context("authentication setup is required")?;
    292     if !verify_password(exposed_password, &hash)? {
    293         record_auth_failure(state, client_key).await;
    294         bail!("invalid password");
    295     }
    296     wallet_store::encrypt_existing_with_password(&state.wallet_path, exposed_password)?;
    297     let wallet = wallet_store::load_with_password(&state.wallet_path, exposed_password)?;
    298     restore_node_wallet_from_store(state, wallet, Some(exposed_password)).await?;
    299     clear_auth_backoff(state, client_key).await;
    300     create_session_cookie(state, password).await
    301 }
    302 
    303 pub(super) async fn change_auth_password(
    304     state: &HttpState,
    305     old_password: SecretString,
    306     new_password: SecretString,
    307     client_key: &str,
    308 ) -> Result<String> {
    309     let exposed_old_password = old_password.expose_secret();
    310     let exposed_new_password = new_password.expose_secret();
    311     check_auth_backoff(state, client_key).await?;
    312     validate_password(exposed_new_password)?;
    313     let current_hash = state
    314         .ui_config
    315         .lock()
    316         .await
    317         .auth_password_hash
    318         .clone()
    319         .context("authentication setup is required")?;
    320     if !verify_password(exposed_old_password, &current_hash)? {
    321         record_auth_failure(state, client_key).await;
    322         bail!("invalid current password");
    323     }
    324     let wallet = wallet_store::reencrypt_with_password(
    325         &state.wallet_path,
    326         exposed_old_password,
    327         exposed_new_password,
    328     )?;
    329     {
    330         let mut config = state.ui_config.lock().await;
    331         config.auth_password_hash = Some(hash_password(exposed_new_password)?);
    332         config_store::save(&state.config_path, &config)?;
    333     }
    334     restore_node_wallet_from_store(state, wallet, Some(exposed_new_password)).await?;
    335     state.auth_sessions.lock().await.clear();
    336     clear_auth_backoff(state, client_key).await;
    337     create_session_cookie(state, new_password).await
    338 }
    339 
    340 pub(super) async fn restore_node_wallet_from_store(
    341     state: &HttpState,
    342     wallet: Wallet,
    343     _password: Option<&str>,
    344 ) -> Result<()> {
    345     let mut node = state.node.lock().await;
    346     node.replace_wallet(wallet);
    347     Ok(())
    348 }
    349 
    350 async fn check_auth_backoff(state: &HttpState, client_key: &str) -> Result<()> {
    351     let now = now_ms();
    352     let mut backoffs = state.auth_backoff.lock().await;
    353     let backoff = backoffs.entry(client_key.to_string()).or_default();
    354     if backoff
    355         .locked_until_ms
    356         .is_some_and(|locked_until| locked_until > now)
    357     {
    358         bail!("too many failed login attempts; try again later");
    359     }
    360     if backoff.locked_until_ms.is_some() {
    361         backoff.locked_until_ms = None;
    362         backoff.failed_attempts = 0;
    363     }
    364     Ok(())
    365 }
    366 
    367 async fn record_auth_failure(state: &HttpState, client_key: &str) {
    368     let mut backoffs = state.auth_backoff.lock().await;
    369     let backoff = backoffs.entry(client_key.to_string()).or_default();
    370     backoff.failed_attempts = backoff.failed_attempts.saturating_add(1);
    371     if backoff.failed_attempts >= AUTH_MAX_FAILED_ATTEMPTS {
    372         backoff.locked_until_ms = Some(now_ms().saturating_add(AUTH_LOCKOUT_MS));
    373     }
    374 }
    375 
    376 async fn clear_auth_backoff(state: &HttpState, client_key: &str) {
    377     state.auth_backoff.lock().await.remove(client_key);
    378 }
    379 
    380 async fn create_session_cookie(state: &HttpState, password: SecretString) -> Result<String> {
    381     let token = random_hex(32)?;
    382     let token_hash = session_token_hash(token.expose_secret());
    383     let expires_at = now_ms().saturating_add(AUTH_SESSION_TTL_MS);
    384     state.auth_sessions.lock().await.insert(
    385         token_hash,
    386         AuthSession {
    387             expires_at,
    388             wallet_password: Arc::new(password),
    389         },
    390     );
    391     Ok(format!(
    392         "{AUTH_COOKIE_NAME}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}",
    393         token.expose_secret(),
    394         AUTH_SESSION_TTL_MS / 1000
    395     ))
    396 }
    397 
    398 pub(super) fn auth_cookie(headers: &HeaderMap) -> Option<&str> {
    399     named_cookie(headers, AUTH_COOKIE_NAME)
    400 }
    401 
    402 fn named_cookie<'a>(headers: &'a HeaderMap, expected_name: &str) -> Option<&'a str> {
    403     let cookie = headers.get(header::COOKIE)?.to_str().ok()?;
    404     cookie.split(';').find_map(|part| {
    405         let (name, value) = part.trim().split_once('=')?;
    406         (name == expected_name).then_some(value)
    407     })
    408 }
    409 
    410 #[cfg(test)]
    411 mod tests {
    412     use std::{
    413         collections::BTreeMap,
    414         net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
    415         sync::Arc,
    416     };
    417 
    418     use axum::http::{HeaderMap, HeaderValue, header};
    419     use tokio::sync::Mutex;
    420 
    421     use crate::{
    422         adapters::{
    423             chain_store::SqliteChainStore, config_store::UiConfig, p2p::GossipNetwork,
    424             ui_data_store::SqliteUiDataStore,
    425         },
    426         app::{NodeCore, PeerBook, StratumStatus},
    427         domain::{GenesisBurn, Ledger, MICRO_IUNA, Wallet},
    428     };
    429 
    430     use super::super::state::{AuthSession, HttpState};
    431     use super::{AUTH_COOKIE_NAME, now_ms};
    432     use super::{
    433         auth_client_key, check_auth_backoff, consume_setup_capability, local_setup_page_request,
    434         local_setup_request, record_auth_failure, request_is_authenticated, same_origin_request,
    435         session_token_hash, setup_capability_cookie, validate_setup_capability,
    436     };
    437 
    438     fn headers(values: &[(&'static str, &'static str)]) -> HeaderMap {
    439         let mut headers = HeaderMap::new();
    440         for (name, value) in values {
    441             headers.insert(*name, HeaderValue::from_static(value));
    442         }
    443         headers
    444     }
    445 
    446     fn socket(ip: [u8; 4]) -> SocketAddr {
    447         SocketAddr::new(IpAddr::V4(Ipv4Addr::from(ip)), 9444)
    448     }
    449 
    450     fn ipv6_loopback_socket() -> SocketAddr {
    451         SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 9444)
    452     }
    453 
    454     async fn test_state() -> HttpState {
    455         let dir = tempfile::tempdir().unwrap().keep();
    456         let wallet = Wallet::from_seed("http-auth-abuse-tests");
    457         let mut allocations = BTreeMap::new();
    458         allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
    459         let ledger = Ledger::new_with_genesis_burns(
    460             allocations,
    461             vec![GenesisBurn::new(wallet.address(), 1)],
    462             1,
    463         )
    464         .unwrap();
    465         let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0)));
    466         let peers = Arc::new(Mutex::new(PeerBook::default()));
    467         let gossip = GossipNetwork::start(
    468             node.clone(),
    469             peers.clone(),
    470             socket([127, 0, 0, 1]),
    471             None,
    472             false,
    473         )
    474         .await
    475         .unwrap();
    476 
    477         HttpState {
    478             node,
    479             peers,
    480             gossip,
    481             ui_config: Arc::new(Mutex::new(UiConfig::default())),
    482             config_path: dir.join("config.json"),
    483             chain_store: SqliteChainStore::open(dir.join("chain.sqlite")).unwrap(),
    484             ui_data_store: SqliteUiDataStore::open(dir.join("ui.sqlite")).unwrap(),
    485             wallet_path: dir.join("wallet.json"),
    486             stratum: StratumStatus {
    487                 enabled: false,
    488                 listen_addr: None,
    489             },
    490             auth_sessions: Arc::new(Mutex::new(BTreeMap::new())),
    491             auth_backoff: Arc::new(Mutex::new(BTreeMap::new())),
    492             setup_capability: Arc::new(Mutex::new(Some("test-setup-capability".into()))),
    493             management_port: 9444,
    494             wallet_endpoint_addr: None,
    495         }
    496     }
    497 
    498     #[test]
    499     fn csrf_same_origin_requires_matching_origin_or_referer_host() {
    500         assert!(same_origin_request(
    501             &headers(&[
    502                 ("host", "127.0.0.1:9444"),
    503                 ("origin", "http://127.0.0.1:9444")
    504             ]),
    505             None
    506         ));
    507         assert!(same_origin_request(
    508             &headers(&[
    509                 ("host", "iuna.local:9444"),
    510                 ("referer", "http://iuna.local:9444/settings")
    511             ]),
    512             None
    513         ));
    514         assert!(!same_origin_request(
    515             &headers(&[
    516                 ("host", "127.0.0.1:9444"),
    517                 ("origin", "https://evil.example")
    518             ]),
    519             None
    520         ));
    521         assert!(!same_origin_request(
    522             &headers(&[("host", "127.0.0.1:9444")]),
    523             None
    524         ));
    525     }
    526 
    527     #[test]
    528     fn local_setup_accepts_only_exact_loopback_management_origins() {
    529         assert!(local_setup_page_request(
    530             &headers(&[("host", "127.0.0.1:9444")]),
    531             Some(socket([127, 0, 0, 1])),
    532             9444
    533         ));
    534         assert!(local_setup_request(
    535             &headers(&[
    536                 ("host", "127.0.0.1:9444"),
    537                 ("origin", "http://127.0.0.1:9444")
    538             ]),
    539             Some(socket([127, 0, 0, 1])),
    540             9444
    541         ));
    542         assert!(local_setup_request(
    543             &headers(&[
    544                 ("host", "localhost:9444"),
    545                 ("referer", "http://localhost:9444/setup")
    546             ]),
    547             Some(socket([127, 0, 0, 1])),
    548             9444
    549         ));
    550         assert!(local_setup_request(
    551             &headers(&[("host", "[::1]:9444"), ("origin", "http://[::1]:9444")]),
    552             Some(ipv6_loopback_socket()),
    553             9444
    554         ));
    555     }
    556 
    557     #[test]
    558     fn local_setup_rejects_dns_rebinding_and_remote_requests() {
    559         assert!(!local_setup_request(
    560             &headers(&[
    561                 ("host", "rebound.evil:9444"),
    562                 ("origin", "http://rebound.evil:9444")
    563             ]),
    564             Some(socket([127, 0, 0, 1])),
    565             9444
    566         ));
    567         assert!(!local_setup_request(
    568             &headers(&[
    569                 ("host", "127.0.0.1:9444"),
    570                 ("origin", "http://localhost:9444")
    571             ]),
    572             Some(socket([127, 0, 0, 1])),
    573             9444
    574         ));
    575         assert!(!local_setup_request(
    576             &headers(&[
    577                 ("host", "127.0.0.1:9444"),
    578                 ("origin", "https://127.0.0.1:9444")
    579             ]),
    580             Some(socket([127, 0, 0, 1])),
    581             9444
    582         ));
    583         assert!(!local_setup_request(
    584             &headers(&[
    585                 ("host", "127.0.0.1:9444"),
    586                 ("origin", "http://127.0.0.1:9444")
    587             ]),
    588             Some(socket([203, 0, 113, 10])),
    589             9444
    590         ));
    591         assert!(!local_setup_request(
    592             &headers(&[
    593                 ("host", "127.0.0.1:9555"),
    594                 ("origin", "http://127.0.0.1:9555")
    595             ]),
    596             Some(socket([127, 0, 0, 1])),
    597             9444
    598         ));
    599         assert!(!local_setup_request(
    600             &headers(&[
    601                 ("host", "rebound.evil:9444"),
    602                 ("x-forwarded-host", "127.0.0.1:9444"),
    603                 ("origin", "http://127.0.0.1:9444")
    604             ]),
    605             Some(socket([127, 0, 0, 1])),
    606             9444
    607         ));
    608     }
    609 
    610     #[tokio::test]
    611     async fn setup_capability_cookie_is_required_and_one_time() {
    612         let state = test_state().await;
    613         let cookie = setup_capability_cookie(&state).await.unwrap();
    614         assert!(cookie.starts_with("iuna_setup=test-setup-capability;"));
    615         assert!(cookie.contains("Path=/api/auth/setup"));
    616         assert!(cookie.contains("HttpOnly"));
    617         assert!(cookie.contains("SameSite=Strict"));
    618 
    619         assert!(
    620             validate_setup_capability(&state, &HeaderMap::new())
    621                 .await
    622                 .is_err()
    623         );
    624         assert!(
    625             validate_setup_capability(
    626                 &state,
    627                 &headers(&[("cookie", "iuna_setup=wrong-capability")])
    628             )
    629             .await
    630             .is_err()
    631         );
    632         assert!(
    633             validate_setup_capability(
    634                 &state,
    635                 &headers(&[("cookie", "other=value; iuna_setup=test-setup-capability")])
    636             )
    637             .await
    638             .is_ok()
    639         );
    640 
    641         consume_setup_capability(&state).await;
    642         assert!(
    643             validate_setup_capability(
    644                 &state,
    645                 &headers(&[("cookie", "iuna_setup=test-setup-capability")])
    646             )
    647             .await
    648             .is_err()
    649         );
    650         assert!(setup_capability_cookie(&state).await.is_none());
    651     }
    652 
    653     #[test]
    654     fn csrf_ignores_forwarded_host_from_untrusted_peer() {
    655         let headers = headers(&[
    656             ("host", "127.0.0.1:9444"),
    657             ("x-forwarded-host", "evil.example"),
    658             ("origin", "https://evil.example"),
    659         ]);
    660 
    661         assert!(!same_origin_request(
    662             &headers,
    663             Some(socket([203, 0, 113, 10]))
    664         ));
    665         assert!(!same_origin_request(
    666             &headers,
    667             Some(socket([192, 168, 1, 10]))
    668         ));
    669     }
    670 
    671     #[test]
    672     fn csrf_accepts_forwarded_host_from_trusted_proxy() {
    673         let headers = headers(&[
    674             ("host", "127.0.0.1:9444"),
    675             ("x-forwarded-host", "iuna.example"),
    676             ("origin", "https://iuna.example"),
    677         ]);
    678 
    679         assert!(same_origin_request(&headers, Some(socket([127, 0, 0, 1]))));
    680     }
    681 
    682     #[test]
    683     fn auth_client_key_ignores_forwarded_client_from_untrusted_peer() {
    684         let headers = headers(&[
    685             ("x-forwarded-for", "198.51.100.50"),
    686             ("x-real-ip", "198.51.100.51"),
    687             ("forwarded", "for=198.51.100.52"),
    688         ]);
    689 
    690         assert_eq!(
    691             auth_client_key(&headers, Some(socket([203, 0, 113, 10]))),
    692             "203.0.113.10"
    693         );
    694         assert_eq!(
    695             auth_client_key(&headers, Some(socket([192, 168, 1, 10]))),
    696             "192.168.1.10"
    697         );
    698         assert_eq!(
    699             auth_client_key(&headers, Some(socket([127, 0, 0, 1]))),
    700             "198.51.100.50"
    701         );
    702     }
    703 
    704     #[tokio::test]
    705     async fn auth_backoff_locks_out_and_resets_after_expiry() {
    706         let state = test_state().await;
    707         let client_key = "client-a";
    708         for _ in 0..super::AUTH_MAX_FAILED_ATTEMPTS {
    709             record_auth_failure(&state, client_key).await;
    710         }
    711 
    712         assert!(check_auth_backoff(&state, client_key).await.is_err());
    713 
    714         {
    715             let mut backoffs = state.auth_backoff.lock().await;
    716             let backoff = backoffs.get_mut(client_key).unwrap();
    717             backoff.locked_until_ms = Some(now_ms().saturating_sub(1));
    718         }
    719 
    720         assert!(check_auth_backoff(&state, client_key).await.is_ok());
    721         let backoffs = state.auth_backoff.lock().await;
    722         let backoff = backoffs.get(client_key).unwrap();
    723         assert_eq!(backoff.failed_attempts, 0);
    724         assert_eq!(backoff.locked_until_ms, None);
    725     }
    726 
    727     #[tokio::test]
    728     async fn expired_sessions_are_rejected_and_pruned() {
    729         let state = test_state().await;
    730         let expired_token = "expired";
    731         let live_token = "live";
    732         state.auth_sessions.lock().await.insert(
    733             session_token_hash(expired_token),
    734             AuthSession {
    735                 expires_at: now_ms().saturating_sub(1),
    736                 wallet_password: Arc::new("expired-password".into()),
    737             },
    738         );
    739         state.auth_sessions.lock().await.insert(
    740             session_token_hash(live_token),
    741             AuthSession {
    742                 expires_at: now_ms().saturating_add(60_000),
    743                 wallet_password: Arc::new("live-password".into()),
    744             },
    745         );
    746 
    747         let expired_headers = headers(&[(header::COOKIE.as_str(), "iuna_session=expired")]);
    748         assert!(!request_is_authenticated(&state, &expired_headers).await);
    749         assert!(
    750             !state
    751                 .auth_sessions
    752                 .lock()
    753                 .await
    754                 .contains_key(&session_token_hash(expired_token))
    755         );
    756 
    757         let live_cookie = format!("{AUTH_COOKIE_NAME}={live_token}");
    758         let mut live_headers = HeaderMap::new();
    759         live_headers.insert(header::COOKIE, HeaderValue::from_str(&live_cookie).unwrap());
    760         assert!(request_is_authenticated(&state, &live_headers).await);
    761     }
    762 }