fix(appgate): stop 401ing credential-less subresource fetches

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>
This commit is contained in:
archipelago
2026-08-06 18:56:26 -04:00
co-authored by Claude Opus 5
parent 71bf4f3eaf
commit 8e3e8e9a28
+67
View File
@@ -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 `<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
@@ -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`: `<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,
@@ -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 <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 {