IndeeHub worked all year and broke when the gate rolled out. Cause, verified on the node: GET /manifest.json returns 401 + the gate's login HTML. A browser fetches <link rel="manifest"> in no-credentials mode unless the tag opts in with crossorigin="use-credentials", so the session cookie is NEVER offered and the gate challenges a fully authenticated user. The app's service worker then serves its cached shell, whose every network call fails — which reads as "the app is broken" rather than "the gate refused it". Any gated app with a PWA manifest has the same failure. Passed through unauthenticated on purpose, and deliberately as small as the problem: an EXACT-match allowlist of /manifest.json, /site.webmanifest and /favicon.ico. Static, non-user-specific, and no more revealing than the gate's own login page, which already shows the app's name and icon. Exact match, never a prefix — a prefix would let /manifest.json/../api/secrets ride through. A test pins that: 8 near-miss paths (traversal, query-string traversal, /api/manifest.json, /manifest.jsonx, case variants, /admin, /api/auth/nostr/session) must all still be challenged. 19/19 appgate tests pass. This does NOT address the app's own auth endpoints being intercepted — that needs a session-aware decision and is recorded separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1149 lines
46 KiB
Rust
1149 lines
46 KiB
Rust
//! 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:<app_port>` — 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<RwLock<PortMap>>,
|
|
/// 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<tls::GateTls>,
|
|
}
|
|
|
|
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<Body>,
|
|
app: &GatedPort,
|
|
client_ip: IpAddr,
|
|
) -> Response<Body> {
|
|
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 `<link rel="manifest">` 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`: `<link rel="manifest">` 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<Body>,
|
|
app: &GatedPort,
|
|
action: &str,
|
|
client_ip: IpAddr,
|
|
) -> Response<Body> {
|
|
// 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<Body> {
|
|
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<Body> {
|
|
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<String>,
|
|
client_ip: IpAddr,
|
|
) -> Response<Body> {
|
|
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<String> {
|
|
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<String, String>;
|
|
|
|
/// 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<String> {
|
|
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<Body>) -> Option<Form> {
|
|
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<Body>, app: &GatedPort) -> Response<Body> {
|
|
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::<hyper::Uri>() {
|
|
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<Body>, 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<Body> {
|
|
Response::builder()
|
|
.status(StatusCode::SEE_OTHER)
|
|
.header(header::LOCATION, "/")
|
|
.body(Body::empty())
|
|
.expect("static response builds")
|
|
}
|
|
|
|
fn bad_gateway() -> Response<Body> {
|
|
Response::builder()
|
|
.status(StatusCode::BAD_GATEWAY)
|
|
.body(Body::from("app is not responding"))
|
|
.expect("static response builds")
|
|
}
|
|
|
|
fn not_found() -> Response<Body> {
|
|
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 `<img>`, 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#"<div class="bg" style="background-image:url('{prefix}asset/{name}');animation-delay:{delay}s"></div>"#,
|
|
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#"<img class="icon" src="{}" alt="">"#, 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#"<div class="icon lettermark">{}</div>"#, esc(&letter))
|
|
});
|
|
format!(r#"<div class="tile">{inner}</div>"#)
|
|
}
|
|
|
|
/// 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<String> {
|
|
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));
|
|
// `<app>-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<u8>, &'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<String> {
|
|
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<Body> {
|
|
let html = format!(
|
|
r#"<!doctype html>
|
|
<html lang="en"><head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="robots" content="noindex">
|
|
<title>{title} — {app_name}</title>
|
|
<style>
|
|
/* The dashboard's own /login, rebuilt in static CSS: the same rotating
|
|
intro art, .glass-card panel, .glass-button action and transparent
|
|
white-bordered inputs from neode-ui/src/style.css. Written longhand
|
|
rather than shared with the SPA because the gate answers before any
|
|
bundle exists, and the CSP forbids external stylesheets and script. */
|
|
:root {{ color-scheme: dark; }}
|
|
* {{ box-sizing: border-box; }}
|
|
html {{ height:100%; }}
|
|
body {{ margin:0; color:#fff; background:#05070a; overflow:hidden;
|
|
font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;
|
|
/* Fixed to the viewport rather than a tall scrolling page: an on-screen
|
|
keyboard then overlays the card instead of scrolling it away, and the
|
|
card stays optically centred. min-height:100vh scrolled with the
|
|
keyboard on mobile and left the card off-centre (reported 2026-08-05). */
|
|
position:fixed; inset:0;
|
|
display:grid; place-items:center; padding:1rem;
|
|
height:100vh; height:100svh; }}
|
|
/* Very short viewports (landscape phone, or a keyboard eating most of it):
|
|
allow the card to scroll INSIDE the fixed frame rather than overflow. */
|
|
@media (max-height:640px) {{
|
|
body {{ align-items:start; overflow-y:auto; padding-top:3rem; }}
|
|
}}
|
|
/* Rotating backgrounds: each layer holds its image and cross-fades on a
|
|
shared cycle, so the art moves the way /login does with no script. */
|
|
.bg {{ position:fixed; inset:0; z-index:0; background-size:cover;
|
|
background-position:center; opacity:0; animation:bg-cycle {cycle}s infinite; }}
|
|
.bg::after {{ content:''; position:absolute; inset:0;
|
|
background:linear-gradient(180deg, rgba(0,0,0,.35), rgba(0,0,0,.72)); }}
|
|
@keyframes bg-cycle {{
|
|
0% {{ opacity:0; }} 4% {{ opacity:1; }}
|
|
{hold}% {{ opacity:1; }} {fade}% {{ opacity:0; }} 100% {{ opacity:0; }}
|
|
}}
|
|
main {{ position:relative; z-index:1; width:min(92vw,28rem); }}
|
|
.card {{ padding:2rem; padding-top:3.5rem; position:relative;
|
|
background:rgba(0,0,0,.65); backdrop-filter:blur(18px);
|
|
-webkit-backdrop-filter:blur(18px); border:1px solid rgba(255,255,255,.18);
|
|
border-radius:1rem; box-shadow:0 8px 24px rgba(0,0,0,.45); text-align:center; }}
|
|
/* The Archipelago mark, half in and half out of the panel — same placement
|
|
and gradient ring as Login.vue. */
|
|
.logo {{ position:absolute; top:-2.5rem; left:50%; transform:translateX(-50%);
|
|
width:5rem; height:5rem; border-radius:9999px; padding:3px;
|
|
background:linear-gradient(135deg, rgba(255,255,255,.6) 0%, rgba(0,0,0,.8) 100%);
|
|
box-shadow:0 8px 24px rgba(0,0,0,.5); }}
|
|
.logo img {{ width:100%; height:100%; border-radius:9999px; display:block;
|
|
background:#000; padding:.5rem; }}
|
|
/* The app's own tile, in the My Apps shape: 18px-rounded square on dark
|
|
glass with the same inner highlight and drop shadow. */
|
|
.tile {{ width:60px; height:60px; border-radius:18px; margin:0 auto .75rem;
|
|
background:rgba(0,0,0,.72); box-shadow:0 8px 18px rgba(0,0,0,.38); }}
|
|
.tile .icon {{ width:100%; height:100%; border-radius:18px; display:block;
|
|
object-fit:cover; border:1px solid rgba(255,255,255,.18);
|
|
background:radial-gradient(circle at 35% 28%, rgba(255,255,255,.1), rgba(255,255,255,0) 42%),
|
|
linear-gradient(145deg, rgba(22,22,24,.96), rgba(0,0,0,.96));
|
|
box-shadow:inset 0 1px 0 rgba(255,255,255,.12), inset 0 -10px 24px rgba(0,0,0,.34); }}
|
|
.lettermark {{ display:grid; place-items:center; font-size:1.6rem; font-weight:600;
|
|
color:rgba(255,255,255,.9); }}
|
|
h1 {{ font-size:1.5rem; font-weight:600; margin:0 0 .4rem;
|
|
color:rgba(255,255,255,.96); text-shadow:0 2px 6px rgba(0,0,0,.4); }}
|
|
p.sub {{ margin:0 0 1.75rem; color:rgba(255,255,255,.6); font-size:.875rem; }}
|
|
input {{ width:100%; padding:.75rem 1rem; margin-bottom:1rem; border-radius:.5rem;
|
|
border:1px solid rgba(255,255,255,.2); background:transparent; color:#fff;
|
|
font-size:1rem; transition:border-color .2s ease; }}
|
|
input::placeholder {{ color:rgba(255,255,255,.4); }}
|
|
input:focus {{ outline:none; border-color:rgba(255,255,255,.4);
|
|
box-shadow:0 0 0 1px rgba(255,255,255,.2); }}
|
|
button {{ width:100%; min-height:44px; padding:.75rem 1.25rem; border:none;
|
|
border-radius:.75rem; background:rgba(0,0,0,.6);
|
|
backdrop-filter:blur(24px); -webkit-backdrop-filter:blur(24px);
|
|
box-shadow:0 8px 24px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.22);
|
|
color:rgba(255,255,255,.9); font-size:1rem; font-weight:500; cursor:pointer;
|
|
transition:background-color .2s ease, transform .3s cubic-bezier(.4,0,.2,1); }}
|
|
button:hover {{ background:rgba(0,0,0,.7); }}
|
|
button:active {{ transform:translateY(1px); }}
|
|
.err {{ background:rgba(239,68,68,.2); border:1px solid rgba(239,68,68,.4);
|
|
color:#fecaca; padding:.75rem; border-radius:.5rem; margin-bottom:1rem;
|
|
font-size:.875rem; text-align:left; }}
|
|
</style></head>
|
|
<body>{backgrounds}<main><div class="card">{body}</div></main></body></html>"#,
|
|
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<Body> {
|
|
let body = format!(
|
|
r#"<div class="logo"><img src="{prefix}asset/favico-black-v2.svg" alt="Archipelago"></div>
|
|
{icon}
|
|
<h1>Sign in to open {name}</h1>
|
|
<p class="sub">This app is protected by your node password.</p>
|
|
{err}
|
|
<form method="post" action="{prefix}login">
|
|
<input type="password" name="password" placeholder="Node password" autocomplete="current-password" autofocus required>
|
|
<button type="submit">Sign in</button>
|
|
</form>"#,
|
|
icon = icon_markup(app),
|
|
name = esc(&app.app_name),
|
|
err = error
|
|
.map(|e| format!(r#"<div class="err">{}</div>"#, 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<Body> {
|
|
let body = format!(
|
|
r#"{icon}
|
|
<h1>Two-factor code</h1>
|
|
<p class="sub">Enter the 6-digit code to open {name}.</p>
|
|
{err}
|
|
<form method="post" action="{prefix}totp">
|
|
<input type="text" name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" placeholder="000000" autofocus required>
|
|
<button type="submit">Verify</button>
|
|
</form>"#,
|
|
icon = icon_markup(app),
|
|
name = esc(&app.app_name),
|
|
err = error
|
|
.map(|e| format!(r#"<div class="err">{}</div>"#, 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 <link rel="manifest"> 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#"<script>alert(1)</script>"#.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("<script>alert"));
|
|
assert!(html.contains("<script>"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn error_messages_are_escaped() {
|
|
let resp = login_page(
|
|
&app(),
|
|
Some("<img src=x onerror=1>"),
|
|
StatusCode::UNAUTHORIZED,
|
|
);
|
|
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
|
let html = String::from_utf8_lossy(&body);
|
|
assert!(!html.contains("<img src=x"));
|
|
}
|
|
|
|
/// The challenge must be framable by this node's own dashboard — My Apps
|
|
/// opens apps in an embedded frame, and a blanket `X-Frame-Options: DENY`
|
|
/// turned every gated app into "app is not responding" (100.82.34.38,
|
|
/// 2026-08-05). It must still be uncacheable, and still refuse to be
|
|
/// framed by a foreign origin, which `frame-ancestors` expresses and
|
|
/// `X-Frame-Options` cannot.
|
|
#[test]
|
|
fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() {
|
|
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
|
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
|
|
assert!(
|
|
!resp.headers().contains_key("X-Frame-Options"),
|
|
"X-Frame-Options cannot express 'my own node on another port' — it \
|
|
blocked the dashboard's own frame"
|
|
);
|
|
let csp = resp.headers()["Content-Security-Policy"].to_str().unwrap();
|
|
assert!(csp.contains("frame-ancestors 'self'"));
|
|
assert!(csp.contains("form-action 'self'"));
|
|
}
|
|
|
|
/// The login page must render entirely from the gate's own origin: the
|
|
/// CSP allows no external host, so a background or logo that 404s leaves
|
|
/// a black page rather than the dashboard's art.
|
|
#[tokio::test]
|
|
async fn login_page_sources_its_art_from_the_gate() {
|
|
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).to_string();
|
|
assert!(html.contains(&format!("{GATE_PREFIX}asset/favico-black-v2.svg")));
|
|
for name in LOGIN_BACKGROUNDS {
|
|
assert!(
|
|
html.contains(&format!("{GATE_PREFIX}asset/{name}")),
|
|
"background {name} is not referenced"
|
|
);
|
|
}
|
|
// Every referenced asset must be one the gate will actually serve.
|
|
// The logo is the sidebar A mark (favico-black-v2.svg) since the
|
|
// 2026-08-05 login-page rework — the old wordmark is off the
|
|
// allowlist on purpose.
|
|
assert!(read_ui_asset("favico-black-v2.svg").is_some() || cfg!(not(debug_assertions)));
|
|
}
|
|
|
|
/// The allowlist is the whole security boundary for asset serving: the
|
|
/// name arrives in a URL and is read before any authentication.
|
|
#[test]
|
|
fn asset_serving_refuses_anything_off_the_allowlist() {
|
|
for name in [
|
|
"../../../etc/passwd",
|
|
"/etc/passwd",
|
|
"db.sqlite3",
|
|
"manifest.yml",
|
|
"",
|
|
] {
|
|
assert!(
|
|
read_ui_asset(name).is_none(),
|
|
"{name} must not be servable by the gate"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn form_parsing_decodes_percent_and_plus() {
|
|
let req = Request::builder()
|
|
.body(Body::from("password=a%40b+c&code=123456"))
|
|
.unwrap();
|
|
let form = read_form(req).await.unwrap();
|
|
assert_eq!(field(&form, "password"), Some("a@b c".to_string()));
|
|
assert_eq!(field(&form, "code"), Some("123456".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oversized_form_bodies_are_refused() {
|
|
let req = Request::builder()
|
|
.body(Body::from("x=".to_string() + &"a".repeat(MAX_FORM_BYTES)))
|
|
.unwrap();
|
|
assert!(read_form(req).await.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn no_credential_is_challenged() {
|
|
let gate = test_gate().await;
|
|
assert_eq!(
|
|
gate.authorize(&HeaderMap::new(), "strfry").await,
|
|
Authorization::Challenge
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_valid_session_cookie_is_allowed() {
|
|
let gate = test_gate().await;
|
|
let token = gate.sessions.create().await;
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(header::COOKIE, format!("session={token}").parse().unwrap());
|
|
assert_eq!(
|
|
gate.authorize(&headers, "strfry").await,
|
|
Authorization::Allow
|
|
);
|
|
}
|
|
|
|
/// The gate must remove ONLY its own cookie pairs: an app's login cookie
|
|
/// riding the same header has to survive, or every gated app with its
|
|
/// own auth (vaultwarden, nextcloud, gitea) is logged out on each
|
|
/// request — the 2026-08-05 companion-UI/"app logged me out" regression.
|
|
#[test]
|
|
fn strip_gate_cookies_keeps_app_cookies() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::COOKIE,
|
|
"session=abc; vw_session=keepme; csrf_token=def; theme=dark"
|
|
.parse()
|
|
.unwrap(),
|
|
);
|
|
strip_gate_cookies(&mut headers);
|
|
assert_eq!(
|
|
headers.get(header::COOKIE).unwrap().to_str().unwrap(),
|
|
"vw_session=keepme; theme=dark"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn strip_gate_cookies_drops_header_when_only_gate_cookies() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::COOKIE,
|
|
"session=abc; csrf_token=def".parse().unwrap(),
|
|
);
|
|
strip_gate_cookies(&mut headers);
|
|
assert!(headers.get(header::COOKIE).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn strip_gate_cookies_no_header_is_a_noop() {
|
|
let mut headers = HeaderMap::new();
|
|
strip_gate_cookies(&mut headers);
|
|
assert!(headers.get(header::COOKIE).is_none());
|
|
}
|
|
|
|
/// The load-bearing 2FA property: a session still awaiting its TOTP code
|
|
/// fails `validate()`, so the gate rejects it without knowing anything
|
|
/// about second factors.
|
|
#[tokio::test]
|
|
async fn a_pending_2fa_session_is_challenged() {
|
|
let gate = test_gate().await;
|
|
let pending = gate.sessions.create_pending(vec![1, 2, 3]).await;
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::COOKIE,
|
|
format!("session={pending}").parse().unwrap(),
|
|
);
|
|
assert_eq!(
|
|
gate.authorize(&headers, "strfry").await,
|
|
Authorization::Challenge
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_garbage_cookie_is_challenged() {
|
|
let gate = test_gate().await;
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(header::COOKIE, "session=deadbeef".parse().unwrap());
|
|
assert_eq!(
|
|
gate.authorize(&headers, "strfry").await,
|
|
Authorization::Challenge
|
|
);
|
|
}
|
|
|
|
async fn test_gate() -> AppGate {
|
|
let dir = std::env::temp_dir().join(format!("appgate-test-{}", std::process::id()));
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
AppGate::new(
|
|
SessionStore::new().await,
|
|
AuthManager::new(dir.clone()),
|
|
LoginRateLimiter::new(),
|
|
dir,
|
|
)
|
|
}
|
|
}
|