fix(appgate): stop deleting an app's own Authorization header
The gate removed `Authorization` unconditionally before proxying, so every credential an app owns was destroyed one hop before the app saw it. IndeeHub's Nostr login is the reported case: it signs a NIP-98 event and sends `Authorization: Nostr <event>` to its own /api/auth/nostr/session. The header arrived stripped and its backend answered "Authorization header is missing" — a 401 that no signer could ever satisfy. That is why a NIP-07 browser extension in a tab, the parent frame's NIP-07 bridge (nostr-provider.js) and AIUI all broke at once while the signing itself was never at fault. Proven on the node: the same POST returns a real NIP-98 validation error on loopback and the gate's login page through the gate. The gate accepts exactly one header credential — `Authorization: Bearer <app-scoped device token>` — so only that one is ours to withhold. authorize() now reports which credential allowed the request, and the header is dropped only when it WAS the gate's token, mirroring the surgical cookie strip directly above it. Any other scheme (Nostr, Basic, an app's own bearer) rides through untouched. Credential-less allowlist paths still drop the header: nothing there needs auth, so an unverified token is not handed to the app. Tests: an app's Authorization is not classified as the gate's, and a real proxy hop against a local server shows the app's credential arriving intact while a gate device token does not.
This commit is contained in:
@@ -56,6 +56,10 @@ const GATE_PREFIX: &str = "/__archipelago-gate/";
|
||||
pub enum Authorization {
|
||||
/// Proxy it through.
|
||||
Allow,
|
||||
/// Proxy it through, but the credential that allowed it was the gate's own
|
||||
/// `Authorization: Bearer <device token>` — strip that header before the
|
||||
/// app sees it, exactly as the session cookie is stripped.
|
||||
AllowGateToken,
|
||||
/// Serve the login page.
|
||||
Challenge,
|
||||
}
|
||||
@@ -121,7 +125,7 @@ impl AppGate {
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
return Authorization::Allow;
|
||||
return Authorization::AllowGateToken;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,11 +163,18 @@ impl AppGate {
|
||||
// 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;
|
||||
// Nothing on this list needs a credential, so any Authorization
|
||||
// riding along is unverified as far as the gate is concerned —
|
||||
// drop it rather than hand an unexamined token to the app.
|
||||
return proxy_to_app(req, app, true).await;
|
||||
}
|
||||
|
||||
match self.authorize(req.headers(), &app.app_id).await {
|
||||
Authorization::Allow => proxy_to_app(req, app).await,
|
||||
// The credential was a cookie (or none was needed): the
|
||||
// Authorization header, if any, belongs to the app. Forward it.
|
||||
Authorization::Allow => proxy_to_app(req, app, false).await,
|
||||
// The credential WAS the Authorization header, and it was ours.
|
||||
Authorization::AllowGateToken => proxy_to_app(req, app, true).await,
|
||||
// 401 rather than a redirect: a redirect to a login page is
|
||||
// indistinguishable from the app itself redirecting, and machine
|
||||
// clients would follow it and parse HTML as if it were their API
|
||||
@@ -421,7 +432,11 @@ fn percent_decode(input: &str) -> String {
|
||||
}
|
||||
|
||||
/// Forward an authorised request to the app on loopback.
|
||||
async fn proxy_to_app(req: Request<Body>, app: &GatedPort) -> Response<Body> {
|
||||
async fn proxy_to_app(
|
||||
req: Request<Body>,
|
||||
app: &GatedPort,
|
||||
drop_authorization: bool,
|
||||
) -> Response<Body> {
|
||||
let port = app.port;
|
||||
let path_and_query = req
|
||||
.uri()
|
||||
@@ -446,7 +461,24 @@ async fn proxy_to_app(req: Request<Body>, app: &GatedPort) -> Response<Body> {
|
||||
if !app.session_passthrough {
|
||||
strip_gate_cookies(&mut parts.headers);
|
||||
}
|
||||
parts.headers.remove(header::AUTHORIZATION);
|
||||
// Same principle, applied to the other credential the gate accepts, and
|
||||
// with the same precision. The gate's own header credential is exactly
|
||||
// one thing: `Authorization: Bearer <app-scoped device token>`, and the
|
||||
// caller has already told us whether THIS request was authorised by it.
|
||||
//
|
||||
// Removing the header unconditionally — as this did — deletes credentials
|
||||
// the gate never issued and never inspects. IndeeHub sends its NIP-98
|
||||
// `Authorization: Nostr <signed event>` to its own `/api/auth/nostr/session`;
|
||||
// the header arrived stripped, so its backend answered "Authorization
|
||||
// header is missing" and a Nostr login could not complete by ANY route:
|
||||
// a NIP-07 extension in a tab, the parent frame's NIP-07 bridge
|
||||
// (`nostr-provider.js`), or AIUI. The signing was always fine — the proof
|
||||
// of it was thrown away one hop before the app. The same blanket removal
|
||||
// breaks every app doing Basic auth or presenting a bearer token it
|
||||
// issued itself.
|
||||
if drop_authorization {
|
||||
parts.headers.remove(header::AUTHORIZATION);
|
||||
}
|
||||
|
||||
let client = hyper::Client::new();
|
||||
match client.request(Request::from_parts(parts, body)).await {
|
||||
@@ -1099,6 +1131,95 @@ mod tests {
|
||||
assert!(headers.get(header::COOKIE).is_none());
|
||||
}
|
||||
|
||||
/// The regression that killed every Nostr login on 2026-08-06.
|
||||
///
|
||||
/// IndeeHub's NIP-98 credential rides in `Authorization: Nostr <event>`
|
||||
/// while the *gate's* credential is the session cookie. The gate must
|
||||
/// classify that as a plain `Allow` — not as its own token — because only
|
||||
/// `AllowGateToken` strips the header. Getting this wrong deleted the
|
||||
/// signed event one hop before the app, and the app answered
|
||||
/// "Authorization header is missing" no matter which signer produced it.
|
||||
#[tokio::test]
|
||||
async fn an_apps_own_authorization_is_not_mistaken_for_the_gates() {
|
||||
let gate = test_gate().await;
|
||||
let session = gate.sessions.create().await;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
format!("session={session}").parse().unwrap(),
|
||||
);
|
||||
for scheme in [
|
||||
"Nostr eyJraW5kIjogMjcyMzV9",
|
||||
"Basic dXNlcjpwYXNz",
|
||||
"Token app-issued-abc",
|
||||
] {
|
||||
headers.insert(header::AUTHORIZATION, scheme.parse().unwrap());
|
||||
assert_eq!(
|
||||
gate.authorize(&headers, "indeehub").await,
|
||||
Authorization::Allow,
|
||||
"{scheme} was treated as the gate's own credential"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of that path: an authorised request must reach the app with the
|
||||
/// app's own `Authorization` intact. Asserted against a real proxy hop —
|
||||
/// the header survived every unit test of the classifier while being
|
||||
/// dropped in `proxy_to_app`, which is precisely how this shipped.
|
||||
#[tokio::test]
|
||||
async fn proxy_forwards_an_apps_own_authorization_and_withholds_the_gates() {
|
||||
// A stand-in app that reports the Authorization header it received.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let _ = hyper::server::conn::Http::new()
|
||||
.serve_connection(
|
||||
stream,
|
||||
hyper::service::service_fn(|req: Request<Body>| async move {
|
||||
let seen = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("<none>")
|
||||
.to_string();
|
||||
Ok::<_, hyper::Error>(Response::new(Body::from(seen)))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
let mut app = app();
|
||||
app.port = port;
|
||||
|
||||
async fn seen_by_app(app: &GatedPort, header_value: &str, drop: bool) -> String {
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/auth/nostr/session")
|
||||
.header(header::AUTHORIZATION, header_value)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = proxy_to_app(req, app, drop).await;
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
String::from_utf8_lossy(&body).to_string()
|
||||
}
|
||||
|
||||
// The app's own credential reaches the app.
|
||||
assert_eq!(
|
||||
seen_by_app(&app, "Nostr eyJraW5kIjogMjcyMzV9", false).await,
|
||||
"Nostr eyJraW5kIjogMjcyMzV9"
|
||||
);
|
||||
// The gate's own device token never does.
|
||||
assert_eq!(
|
||||
seen_by_app(&app, "Bearer gate-device-token", true).await,
|
||||
"<none>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_gate_cookies_no_header_is_a_noop() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
Reference in New Issue
Block a user