//! The app gate — authentication in front of every app port. //! //! # Why this exists //! //! Reproduced on a live node 2026-08-03: with no session cookie at all, over //! the Tailscale address, six app ports answered `HTTP 200` with their real //! UIs. `ss -tlnp` showed them bound `0.0.0.0`, so the same pages were served //! on the LAN address, the FIPS mesh address, and through each app's onion. //! This is the same bug class as the `/lnd-connect-info` and `/bitcoin-rpc/` //! leaks closed in v1.7.120, but across every app rather than two endpoints. //! //! # Why one gate covers four transports //! //! LAN, Tailscale, Tor and the FIPS mesh all converge on //! `127.0.0.1:` — the container publishes there, the mesh relay //! forwards there, and `HiddenServicePort` points there. Authorising at that //! convergence point is one gate rather than four, which is the only reason //! this is tractable at all. //! //! # Why not umbrel's sidecar proxy //! //! umbrelOS gives every app an `app_proxy` container that owns the published //! port. That works, but it costs a container per app and a second service to //! hold the shared secret. Here the daemon already terminates HTTP, already //! owns the session store, and already runs a relay loop for the mesh, so the //! gate is assembly rather than new infrastructure. //! //! # What it does NOT do //! //! It does not invent authentication policy. Password verification, TOTP //! decryption and step replay protection, session lifetime, and rate limiting //! are the same primitives the JSON-RPC login path uses. Only the transport //! differs — an HTML form instead of JSON-RPC — because a browser being //! redirected to an app cannot speak JSON-RPC. pub mod identity; pub mod listener; pub mod tls; use crate::auth::AuthManager; use crate::rate_limit::LoginRateLimiter; use crate::session::SessionStore; use hyper::{header, Body, HeaderMap, Method, Request, Response, StatusCode}; use identity::{GatedPort, PortMap}; use std::net::IpAddr; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; /// Paths the gate serves itself rather than proxying. Namespaced so an app /// that happens to have its own `/login` is unaffected. const GATE_PREFIX: &str = "/__archipelago-gate/"; /// Result of examining a request's credentials. #[derive(Debug, PartialEq, Eq)] pub enum Authorization { /// Proxy it through. Allow, /// Serve the login page. Challenge, } pub struct AppGate { sessions: SessionStore, auth: AuthManager, limiter: LoginRateLimiter, data_dir: PathBuf, port_map: Arc>, /// TLS for gated ports. Shared by every accept loop so one reissue is /// picked up by all of them, and so the parse happens once rather than /// per port. pub(crate) tls: Arc, } impl AppGate { pub fn new( sessions: SessionStore, auth: AuthManager, limiter: LoginRateLimiter, data_dir: PathBuf, ) -> Self { Self { sessions, auth, limiter, data_dir, port_map: Arc::new(RwLock::new(identity::build_port_map())), tls: Arc::new(tls::GateTls::new()), } } /// Re-read the manifests. Called on catalog refresh so a newly installed /// app is gated without a daemon restart. pub async fn refresh(&self) { *self.port_map.write().await = identity::build_port_map(); } pub async fn port_map(&self) -> PortMap { self.port_map.read().await.clone() } /// Does this request carry a credential good for `app_id`? /// /// Two accepted forms, deliberately no others: /// /// * the node session cookie — and because a session still pending its /// TOTP step fails `validate()`, **2FA is honoured here for free**. The /// gate never sees a TOTP code on a proxied request and never needs to. /// * an app-scoped bearer token, for machine clients that speak HTTP but /// cannot hold a cookie or complete an interactive login (Home /// Assistant reaching an app's API is the motivating case). pub async fn authorize(&self, headers: &HeaderMap, app_id: &str) -> Authorization { if let Some(token) = crate::session::extract_session_cookie(headers) { if self.sessions.validate(&token).await { return Authorization::Allow; } } if let Some(token) = bearer_token(headers) { if crate::device_tokens::verify_for_app(&self.data_dir, &token, app_id) .await .is_some() { return Authorization::Allow; } } Authorization::Challenge } /// Handle one inbound request on a gated port. pub async fn handle( &self, req: Request, app: &GatedPort, client_ip: IpAddr, ) -> Response { let path = req.uri().path().to_string(); if let Some(action) = path.strip_prefix(GATE_PREFIX) { return self.handle_gate_action(req, app, action, client_ip).await; } // A browser fetches a few subresources WITHOUT credentials by // specification, no matter how the user is authenticated: a PWA // manifest referenced by a plain `` is the // canonical case. The cookie is never offered, so challenging these // returns 401 + a login page EVEN TO A FULLY AUTHENTICATED USER — and // the app's own service worker then serves a cached shell whose every // network call fails, which reads as "the app is broken" rather than // "the gate refused". IndeeHub worked all year and broke on the gate's // rollout for exactly this reason. // // These are passed through unauthenticated on purpose. It is a real // hole in the gate, so it is deliberately as small as the problem: an // exact-match allowlist of non-sensitive, static, well-known paths that // reveal nothing the gate's own login page does not already show (the // app's name and icon). No prefixes, no wildcards — a prefix here would // let `/manifest.json/../api/secrets` style paths ride through, and // anything user-specific must keep being challenged. if Self::is_credentialless_public_path(&path) { return proxy_to_app(req, app).await; } match self.authorize(req.headers(), &app.app_id).await { Authorization::Allow => proxy_to_app(req, app).await, // 401 rather than a redirect: a redirect to a login page is // indistinguishable from the app itself redirecting, and machine // clients would follow it and parse HTML as if it were their API // response. The status says "you are not authenticated" in a way // every client understands, and browsers still render the body. Authorization::Challenge => login_page(app, None, StatusCode::UNAUTHORIZED), } } /// Paths a browser fetches without credentials by specification. /// /// Exact matches only, and every entry must be static, non-user-specific, /// and no more revealing than the gate's own login page. Adding to this /// list widens an authentication bypass — justify each one here. /// /// - `/manifest.json`, `/site.webmanifest`: `` is /// fetched in no-credentials mode unless the tag opts in with /// `crossorigin="use-credentials"`, which app authors cannot be required /// to do. Contains the app's name, colours and icon paths. /// - `/favicon.ico`: same no-credentials treatment, and the gate's login /// page already displays the app's icon. fn is_credentialless_public_path(path: &str) -> bool { matches!( path, "/manifest.json" | "/site.webmanifest" | "/favicon.ico" ) } /// The gate's own endpoints: the login form target and the TOTP step. async fn handle_gate_action( &self, req: Request, app: &GatedPort, action: &str, client_ip: IpAddr, ) -> Response { // Assets are GET and pre-auth by nature: the login page cannot // render its own background or logo without them. if let Some(name) = action.strip_prefix("asset/") { return self.serve_asset(name); } if req.method() != Method::POST { return login_page(app, None, StatusCode::OK); } // Captured before the body is consumed. The pending-2FA session // rides the cookie rather than a hidden form field so the token // never appears in the HTML, in a `view-source`, or in a screenshot // of the second-factor page. let pending = crate::session::extract_session_cookie(req.headers()); // Same limiter instance as the JSON-RPC login path, so an attacker // cannot get a fresh budget of guesses simply by moving to an app // port. if !self.limiter.check(client_ip).await { return login_page( app, Some("Too many attempts. Wait a minute and try again."), StatusCode::TOO_MANY_REQUESTS, ); } let form = match read_form(req).await { Some(form) => form, None => return login_page(app, Some("Malformed request."), StatusCode::BAD_REQUEST), }; match action { "login" => self.do_login(app, &form, client_ip).await, "totp" => self.do_totp(app, &form, pending, client_ip).await, _ => not_found(), } } /// Static assets the login page needs, served from the gate's own origin. /// /// The backgrounds are ~1 MB each, so inlining them as data URIs would /// bloat every challenge response. Serving them here keeps the page /// byte-identical to the dashboard's login while the CSP stays tight: /// `img-src 'self' data:` and nothing else. fn serve_asset(&self, name: &str) -> Response { let Some((bytes, mime)) = read_ui_asset(name) else { return not_found(); }; Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, mime) // Immutable art; caching it costs nothing and keeps the login // instant on a repeat challenge. .header(header::CACHE_CONTROL, "public, max-age=86400") .body(Body::from(bytes)) .expect("asset response builds") } async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response { let password = field(form, "password").unwrap_or_default(); match self.auth.verify_password(&password).await { Ok(true) => {} _ => { self.limiter.record_failure(client_ip).await; return login_page(app, Some("Incorrect password."), StatusCode::UNAUTHORIZED); } } // 2FA, if configured. The secret is encrypted with the password, so // this is the only moment it can be decrypted — exactly as in the // JSON-RPC path. A pending session cannot pass `authorize`, so a // half-finished login grants nothing. if self.auth.is_totp_enabled().await.unwrap_or(false) { if let Ok(Some(totp_data)) = self.auth.get_totp_data().await { if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, &password) { let pending = self.sessions.create_pending(secret).await; let mut resp = totp_page(app, None, StatusCode::OK); set_session_cookie(&mut resp, &pending); return resp; } } // TOTP is on but its data is unreadable. Refuse: falling through // to a full session would silently downgrade the node's second // factor to nothing. return login_page( app, Some("Two-factor data could not be read. Sign in from the dashboard."), StatusCode::INTERNAL_SERVER_ERROR, ); } let token = self.sessions.create().await; let mut resp = redirect_to_app(); set_session_cookie(&mut resp, &token); resp } async fn do_totp( &self, app: &GatedPort, form: &Form, pending: Option, client_ip: IpAddr, ) -> Response { let code = field(form, "code").unwrap_or_default(); let Some(pending) = pending.filter(|s| !s.is_empty()) else { return login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED); }; let Some(secret) = self.sessions.get_pending_secret(&pending).await else { return login_page( app, Some("Session expired. Start again."), StatusCode::UNAUTHORIZED, ); }; let totp_data = self.auth.get_totp_data().await.ok().flatten(); let used_steps = totp_data .as_ref() .map(|d| d.used_steps.clone()) .unwrap_or_default(); match crate::totp::verify_code(&secret, &code, &used_steps) { Ok(Some(step)) => { // Record the step so the same code cannot be replayed — the // JSON-RPC path does this and skipping it here would make the // gate the weaker of the two doors. if let Some(mut data) = totp_data { data.used_steps.push(step); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); let cutoff = (now / 30) - 10; data.used_steps.retain(|s| *s > cutoff); let _ = self.auth.update_totp(data).await; } match self.sessions.upgrade_to_full(&pending).await { Some(full) => { let mut resp = redirect_to_app(); set_session_cookie(&mut resp, &full); resp } None => login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED), } } _ => { self.limiter.record_failure(client_ip).await; let mut resp = totp_page(app, Some("Incorrect code."), StatusCode::UNAUTHORIZED); set_session_cookie(&mut resp, &pending); resp } } } } // --------------------------------------------------------------------------- // Request helpers // --------------------------------------------------------------------------- fn bearer_token(headers: &HeaderMap) -> Option { let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?; let token = value .strip_prefix("Bearer ") .or_else(|| value.strip_prefix("bearer "))?; let token = token.trim(); (!token.is_empty()).then(|| token.to_string()) } type Form = std::collections::HashMap; /// Free function rather than a trait method: `HashMap` has an inherent `get` /// that would win method resolution and silently return `Option<&String>`. fn field(form: &Form, key: &str) -> Option { form.get(key).cloned() } /// Read an `application/x-www-form-urlencoded` body. /// /// Capped: an unauthenticated caller must not be able to make the daemon /// buffer arbitrary bytes, and no legitimate login form approaches this. const MAX_FORM_BYTES: usize = 8 * 1024; async fn read_form(req: Request) -> Option
{ let bytes = hyper::body::to_bytes(req.into_body()).await.ok()?; if bytes.len() > MAX_FORM_BYTES { return None; } let text = std::str::from_utf8(&bytes).ok()?; let mut form = Form::new(); for pair in text.split('&') { let Some((k, v)) = pair.split_once('=') else { continue; }; form.insert(percent_decode(k), percent_decode(v)); } Some(form) } fn percent_decode(input: &str) -> String { let bytes = input.replace('+', " ").into_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' && i + 2 < bytes.len() { let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok(); if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { out.push(byte); i += 3; continue; } } out.push(bytes[i]); i += 1; } String::from_utf8_lossy(&out).into_owned() } /// Forward an authorised request to the app on loopback. async fn proxy_to_app(req: Request, app: &GatedPort) -> Response { let port = app.port; let path_and_query = req .uri() .path_and_query() .map(|p| p.as_str()) .unwrap_or("/") .to_string(); let uri = match format!("http://127.0.0.1:{port}{path_and_query}").parse::() { Ok(uri) => uri, Err(_) => return bad_gateway(), }; let (mut parts, body) = req.into_parts(); parts.uri = uri; // Strip the gate's own credential before it reaches the app — the app // should never be in a position to log, echo, or forward the node // session. But ONLY the gate's cookies: apps run their own cookie logins // (vaultwarden, nextcloud, gitea…), and removing the whole header logged // every one of them out on each request. Companion UIs that proxy the // daemon's authenticated endpoints opt in to keeping the session via // `session_passthrough: true` on their gated port. if !app.session_passthrough { strip_gate_cookies(&mut parts.headers); } parts.headers.remove(header::AUTHORIZATION); let client = hyper::Client::new(); match client.request(Request::from_parts(parts, body)).await { Ok(resp) => resp, Err(_) => bad_gateway(), } } /// Cookie names owned by the gate/daemon, never the app's to see. const GATE_COOKIE_NAMES: &[&str] = &["session", "csrf_token"]; /// Remove the gate's own cookie pairs from the Cookie header, preserving the /// app's cookies (its login/session/prefs) untouched. Drops the header /// entirely when nothing remains. fn strip_gate_cookies(headers: &mut hyper::HeaderMap) { let Some(cookie) = headers.get(header::COOKIE) else { return; }; let Ok(raw) = cookie.to_str() else { // Not valid UTF-8 — can't safely filter pairs, so fail closed. headers.remove(header::COOKIE); return; }; let kept: Vec<&str> = raw .split(';') .map(str::trim) .filter(|pair| { let name = pair.split('=').next().unwrap_or("").trim(); !GATE_COOKIE_NAMES.contains(&name) }) .filter(|pair| !pair.is_empty()) .collect(); if kept.is_empty() { headers.remove(header::COOKIE); return; } match header::HeaderValue::from_str(&kept.join("; ")) { Ok(v) => { headers.insert(header::COOKIE, v); } Err(_) => { headers.remove(header::COOKIE); } } } fn set_session_cookie(resp: &mut Response, token: &str) { // No Domain attribute, so the cookie is host-only. Cookies ignore port, // which is what makes one sign-in cover the dashboard and every app port // on the same host — and equally why an app on a *different* host (its // own onion) is a separate sign-in. if let Ok(value) = header::HeaderValue::from_str(&format!("session={token}; HttpOnly; SameSite=Lax; Path=/")) { resp.headers_mut().append(header::SET_COOKIE, value); } } fn redirect_to_app() -> Response { Response::builder() .status(StatusCode::SEE_OTHER) .header(header::LOCATION, "/") .body(Body::empty()) .expect("static response builds") } fn bad_gateway() -> Response { Response::builder() .status(StatusCode::BAD_GATEWAY) .body(Body::from("app is not responding")) .expect("static response builds") } fn not_found() -> Response { Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::empty()) .expect("static response builds") } // --------------------------------------------------------------------------- // Pages // --------------------------------------------------------------------------- /// Minimal HTML escape for values interpolated into the pages below. fn esc(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } /// The app's icon as an ``, or a lettermark when the manifest declares /// none. Inlined as a data URI rather than linked: the gate is answering on /// the app's own port, so any asset URL would either hit the unauthenticated /// app behind it or a different origin the browser may not reach. /// One stacked layer per background, each delayed so they cross-fade in turn. fn background_layers() -> String { let step = LOGIN_BACKGROUNDS.len() as u32 * 9 / LOGIN_BACKGROUNDS.len() as u32; LOGIN_BACKGROUNDS .iter() .enumerate() .map(|(i, name)| { format!( r#"
"#, prefix = GATE_PREFIX, delay = i as u32 * step, ) }) .collect() } fn icon_markup(app: &GatedPort) -> String { let inner = app .icon .as_deref() .and_then(read_icon_data_uri) // A manifest that names no icon still gets one: the dashboard already // ships icons named after the app, so fall back to those before // giving up. Without this EVERY gated app showed a lettermark, // because no manifest declares metadata.icon (archi-dev-box, // 2026-08-05). .or_else(|| { icon_candidates(&app.app_id) .iter() .find_map(|c| read_icon_data_uri(c)) }) .map(|data_uri| format!(r#""#, esc(&data_uri))) .unwrap_or_else(|| { let letter = app .app_name .chars() .find(|c| c.is_alphanumeric()) .map(|c| c.to_uppercase().to_string()) .unwrap_or_else(|| "?".to_string()); format!(r#"
{}
"#, esc(&letter)) }); format!(r#"
{inner}
"#) } /// Icons live with the web UI. Only files under the icon directory are read, /// and only known image extensions — the path comes from a manifest, which is /// signed, but treating it as untrusted costs nothing. /// Icon basenames to try for an app id, best first. /// /// The shipped icon set is named for the *product*, while app ids carry /// packaging detail — `filebrowser` vs `file-browser`, `morphos-server` vs /// `morphos` — and the per-app screens (`lnd-ui`, `bitcoin-ui`, `electrs-ui`) /// have no icon of their own but obviously belong to the app they front. /// Resolving those here keeps the mapping in one readable place instead of /// adding a `metadata.icon` line to every manifest, which would have to be /// re-signed into the catalog to take effect. fn icon_candidates(app_id: &str) -> Vec { let mut out = vec![app_id.to_string()]; let alias = match app_id { "filebrowser" => Some("file-browser"), "home-assistant" => Some("homeassistant"), "morphos-server" => Some("morphos"), "barkd" => Some("bark"), "archy-mempool-web" | "mempool-api" => Some("mempool"), "lnd-ui" | "lightning-stack" => Some("lnd"), "bitcoin-ui" => Some("bitcoin-core"), "electrs-ui" => Some("electrumx"), "fips-ui" | "aiui" | "did-wallet" => Some("archipelago-a"), "fedimint-gateway" | "fedimint-clientd" => Some("fedimint"), _ => None, }; out.extend(alias.map(str::to_string)); // `-ui` / `-server` / `-web` front an app whose icon is the bare name. for suffix in ["-ui", "-server", "-web"] { if let Some(base) = app_id.strip_suffix(suffix) { out.push(base.to_string()); } } out } /// Backgrounds the login cycles through, matching the dashboard's own /// `/login` art. Cross-faded by CSS alone — the CSP forbids script, and a /// rotation that needs JavaScript would not survive it. const LOGIN_BACKGROUNDS: [&str; 4] = [ "bg-intro.jpg", "bg-intro-4.webp", "bg-intro-6.webp", "bg-intro-3.jpg", ]; /// Assets the gate will serve, by exact name. An allowlist rather than a path /// join: the name arrives in a URL, and the gate answers before any /// authentication, so nothing here may be caller-controlled beyond this set. fn read_ui_asset(name: &str) -> Option<(Vec, &'static str)> { let allowed = LOGIN_BACKGROUNDS.contains(&name) || name == "favico-black-v2.svg"; if !allowed { return None; } let mime = icon_mime(name.rsplit_once('.')?.1)?; for root in [ "/opt/archipelago/web-ui/assets/img", "web/dist/neode-ui/assets/img", "neode-ui/public/assets/img", "/opt/archipelago/web-ui/assets/icon", "web/dist/neode-ui/assets/icon", "neode-ui/public/assets/icon", ] { if let Ok(bytes) = std::fs::read(std::path::Path::new(root).join(name)) { return Some((bytes, mime)); } } None } const ICON_ROOTS: [&str; 2] = [ "/opt/archipelago/web-ui/assets/img/app-icons", "web/dist/neode-ui/assets/img/app-icons", ]; fn icon_mime(ext: &str) -> Option<&'static str> { match ext.to_ascii_lowercase().as_str() { "svg" => Some("image/svg+xml"), "png" => Some("image/png"), "webp" => Some("image/webp"), "jpg" | "jpeg" => Some("image/jpeg"), _ => None, } } /// Read an app icon as a `data:` URI. /// /// `icon_ref` may be a filename or path with an extension (a manifest's /// `metadata.icon`), or a bare name such as an app id — in which case the /// known extensions are tried in turn. Only the file name is used; the /// directories searched are fixed, so a manifest cannot point the gate at an /// arbitrary path. fn read_icon_data_uri(icon_ref: &str) -> Option { let name = std::path::Path::new(icon_ref).file_name()?.to_str()?; let candidates: Vec<(String, &str)> = match name.rsplit_once('.') { Some((_, ext)) => vec![(name.to_string(), icon_mime(ext)?)], None => ["svg", "png", "webp", "jpg"] .iter() .filter_map(|ext| Some((format!("{name}.{ext}"), icon_mime(ext)?))) .collect(), }; for (file, mime) in candidates { for root in ICON_ROOTS { let candidate = std::path::Path::new(root).join(&file); if let Ok(bytes) = std::fs::read(&candidate) { if bytes.len() > 512 * 1024 { continue; } return Some(format!("data:{mime};base64,{}", base64_encode(&bytes))); } } } None } fn base64_encode(bytes: &[u8]) -> String { use base64::Engine; base64::engine::general_purpose::STANDARD.encode(bytes) } fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Response { let html = format!( r#" {title} — {app_name} {backgrounds}
{body}
"#, title = esc(title), app_name = esc(&app.app_name), body = body, backgrounds = background_layers(), cycle = LOGIN_BACKGROUNDS.len() as u32 * 9, hold = 100 / LOGIN_BACKGROUNDS.len() as u32, fade = 100 / LOGIN_BACKGROUNDS.len() as u32 + 4, ); Response::builder() .status(status) .header(header::CONTENT_TYPE, "text/html; charset=utf-8") // The gate answers on the app's own port for an unauthenticated // caller; nothing here should be cached or framed. .header(header::CACHE_CONTROL, "no-store") // NOT X-Frame-Options: DENY. My Apps opens an app in an embedded // frame, so a blanket DENY made every gated app render as "app is // not responding" the moment the gate challenged it (reported on // 100.82.34.38, 2026-08-05). frame-ancestors is the modern control // and can be precise: only pages from this same node may frame the // login, on any port or scheme, which is exactly the dashboard. // Anything else — another site embedding it to harvest the node // password — is still refused. .header( "Content-Security-Policy", "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \ form-action 'self'; frame-ancestors 'self' http://*:* https://*:*", ) .body(Body::from(html)) .expect("static response builds") } /// The challenge. Names and pictures the app being opened, so the visitor can /// confirm what they are authenticating to rather than being asked for a /// password by an unexplained page. fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response { let body = format!( r#" {icon}

Sign in to open {name}

This app is protected by your node password.

{err} "#, icon = icon_markup(app), name = esc(&app.app_name), err = error .map(|e| format!(r#"
{}
"#, esc(e))) .unwrap_or_default(), prefix = GATE_PREFIX, ); page("Sign in", app, &body, status) } /// Second factor. Reached only after the password verified, and the session /// backing it cannot authorise anything until this completes. fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response { let body = format!( r#"{icon}

Two-factor code

Enter the 6-digit code to open {name}.

{err}
"#, icon = icon_markup(app), name = esc(&app.app_name), err = error .map(|e| format!(r#"
{}
"#, esc(e))) .unwrap_or_default(), prefix = GATE_PREFIX, ); page("Two-factor", app, &body, status) } #[cfg(test)] mod tests { #[test] fn credentialless_allowlist_covers_the_manifest_that_broke_apps() { // A fetch never carries the cookie, so these must // pass or a logged-in user still gets 401 + a login page. for p in ["/manifest.json", "/site.webmanifest", "/favicon.ico"] { assert!(AppGate::is_credentialless_public_path(p), "{p} still challenged"); } } #[test] fn credentialless_allowlist_is_exact_match_only() { // A prefix match here would be an authentication bypass. Anything that // merely CONTAINS or extends an allowed path must still be challenged. for p in [ "/manifest.json/../api/secrets", "/manifest.json?x=1/../secrets", "/api/manifest.json", "/manifest.jsonx", "/MANIFEST.JSON", "/", "/api/auth/nostr/session", "/admin", ] { assert!(!AppGate::is_credentialless_public_path(p), "{p} wrongly bypassed the gate"); } } use super::*; fn app() -> GatedPort { GatedPort { port: 8090, app_id: "strfry".to_string(), app_name: "Strfry Relay".to_string(), icon: None, declared: true, session_passthrough: false, } } #[test] fn bearer_token_is_parsed_case_insensitively() { let mut headers = HeaderMap::new(); headers.insert(header::AUTHORIZATION, "Bearer abc123".parse().unwrap()); assert_eq!(bearer_token(&headers), Some("abc123".to_string())); headers.insert(header::AUTHORIZATION, "bearer abc123".parse().unwrap()); assert_eq!(bearer_token(&headers), Some("abc123".to_string())); } #[test] fn non_bearer_authorization_is_ignored() { let mut headers = HeaderMap::new(); // An app's own Basic credential must never be mistaken for ours. headers.insert(header::AUTHORIZATION, "Basic dXNlcjpwYXNz".parse().unwrap()); assert_eq!(bearer_token(&headers), None); headers.insert(header::AUTHORIZATION, "Bearer ".parse().unwrap()); assert_eq!(bearer_token(&headers), None); } #[tokio::test] async fn login_page_names_the_app() { let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); let body = hyper::body::to_bytes(resp.into_body()).await.unwrap(); let html = String::from_utf8_lossy(&body); assert!(html.contains("Sign in to open Strfry Relay")); // A lettermark stands in when the manifest declares no icon. assert!(html.contains("lettermark")); } #[tokio::test] async fn page_escapes_app_names() { let mut app = app(); app.app_name = r#""#.to_string(); let resp = login_page(&app, None, StatusCode::UNAUTHORIZED); let body = hyper::body::to_bytes(resp.into_body()).await.unwrap(); let html = String::from_utf8_lossy(&body); assert!(!html.contains("