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;
|
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 {
|
match self.authorize(req.headers(), &app.app_id).await {
|
||||||
Authorization::Allow => proxy_to_app(req, app).await,
|
Authorization::Allow => proxy_to_app(req, app).await,
|
||||||
// 401 rather than a redirect: a redirect to a login page is
|
// 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.
|
/// The gate's own endpoints: the login form target and the TOTP step.
|
||||||
async fn handle_gate_action(
|
async fn handle_gate_action(
|
||||||
&self,
|
&self,
|
||||||
@@ -835,6 +875,33 @@ fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respon
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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::*;
|
use super::*;
|
||||||
|
|
||||||
fn app() -> GatedPort {
|
fn app() -> GatedPort {
|
||||||
|
|||||||
Reference in New Issue
Block a user