a gzipped response was handed on still labelled gzipped

`fetch` decompresses a response body before anyone sees it, and the headers it
returns still say `content-encoding: gzip`. Copying those onto the decoded bytes
tells the client to gunzip plain JSON: `Decompression error: ZlibError`, which
surfaced as a 502 from the catch that wraps the forward.

It hides well, and that is the part worth writing down. A 401 body is a few bytes
and is not compressed, so every unauthenticated probe passes. The first request
carrying a real credential is the first one big enough to be gzipped, and the
first one to fail - which reads as "the credential broke it".

Measured on alpha in exactly that order: the extension door answered 401 without
a token and 502 with one.

`content-length` goes too. It describes the compressed length and the body being
handed on is not that length.

Response headers are now a different function from request headers, because they
are a different question and treating them as one is what caused this.
This commit is contained in:
2026-09-05 21:05:06 +01:00
parent b820317916
commit ed40b6798c
3 changed files with 28 additions and 5 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
import { createRouter } from '@@/create-router'; import { createRouter } from '@@/create-router';
import { createSidecarProxy } from '@@/sidecar/create-proxy'; import { createSidecarProxy } from '@@/sidecar/create-proxy';
import { getLinkwardenConfig, setLinkwardenConfig, getAccount } from '../db/queries'; import { getLinkwardenConfig, setLinkwardenConfig, getAccount } from '../db/queries';
import { stripHopByHop } from '../sidecar/upstream'; import { stripHopByHop, stripResponseHeaders } from '../sidecar/upstream';
/** /**
* Mounted at `/api/linkwarden`. * Mounted at `/api/linkwarden`.
@@ -73,7 +73,7 @@ router.all('/lw/*', async (ctx) => {
try { try {
const upstream = await fetch(`${base}${subpath}${url.search}`, init); const upstream = await fetch(`${base}${subpath}${url.search}`, init);
return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); return new Response(upstream.body, { status: upstream.status, headers: stripResponseHeaders(upstream.headers) });
} catch { } catch {
return ctx.text('Linkwarden unreachable', 502); return ctx.text('Linkwarden unreachable', 502);
} }
@@ -174,7 +174,7 @@ router.all('/api/*', async (ctx) => {
try { try {
const upstream = await fetch(`${base}${subpath}${url.search}`, init); const upstream = await fetch(`${base}${subpath}${url.search}`, init);
return new Response(upstream.body, { status: upstream.status, headers: upstream.headers }); return new Response(upstream.body, { status: upstream.status, headers: stripResponseHeaders(upstream.headers) });
} catch { } catch {
return ctx.json({ error: 'Linkwarden unreachable' }, 502); return ctx.json({ error: 'Linkwarden unreachable' }, 502);
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import { createSidecarConnector } from '@@/sidecar/connect'; import { createSidecarConnector } from '@@/sidecar/connect';
import { API_URL } from '@@/officer-url.mjs'; import { API_URL } from '@@/officer-url.mjs';
import { getLinkwardenBase, stripHopByHop, redactPath } from './upstream'; import { getLinkwardenBase, stripHopByHop, stripResponseHeaders, redactPath } from './upstream';
// The officer-linkwarden sidecar: a singleton process that registers with the API server and owns a // The officer-linkwarden sidecar: a singleton process that registers with the API server and owns a
// transparent reverse-proxy to a self-hosted Linkwarden. The platform API is a thin forwarder to this; // transparent reverse-proxy to a self-hosted Linkwarden. The platform API is a thin forwarder to this;
@@ -86,7 +86,7 @@ Bun.serve({
return new Response(upstream.body, { return new Response(upstream.body, {
status: upstream.status, status: upstream.status,
headers: stripHopByHop(upstream.headers), headers: stripResponseHeaders(upstream.headers),
}); });
}, },
}); });
+23
View File
@@ -79,6 +79,29 @@ export function stripHopByHop(src: Headers): Headers {
return out; return out;
} }
/**
* Headers for a response we are handing back, which is NOT the same as headers for a request.
*
* ── `content-encoding` has to go, and finding out why cost a 502 ──
*
* `fetch` decompresses a gzipped response body before anyone sees it. The headers it returns still say
* `content-encoding: gzip`, so copying them onto the decoded bytes tells the client to gunzip plain
* JSON — `Decompression error: ZlibError`, surfacing as a 502 from the catch below.
*
* It hides well. A 401 body is a few bytes and is not compressed, so every unauthenticated probe passes
* and the first REAL request is the one that fails. Measured exactly that way on alpha: the door
* answered 401 correctly without a token and 502 with one.
*
* `content-length` goes with it for the same reason — it describes the compressed length, and the body
* being handed on is not that length.
*/
export function stripResponseHeaders(src: Headers): Headers {
const out = stripHopByHop(src);
out.delete('content-encoding');
out.delete('content-length');
return out;
}
/* /*
Paths are logged; tokens are not. Paths are logged; tokens are not.