From 8e3e8e9a289225e9b4c6bc716b988dd7de1b8d52 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 6 Aug 2026 18:56:26 -0400 Subject: [PATCH] fix(appgate): stop 401ing credential-less subresource fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- core/archipelago/src/appgate/mod.rs | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) 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 {