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 {