diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index 0df769d0..6459fbee 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -141,6 +141,27 @@ impl AppGate { 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 @@ -152,6 +173,25 @@ impl AppGate { } } + /// 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, @@ -835,6 +875,33 @@ fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respon #[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 {