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:
co-authored by
Claude Opus 5
parent
71bf4f3eaf
commit
8e3e8e9a28
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user