From ed40b6798cfedb0f71add564b45c83c3d3723ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 5 Sep 2026 21:05:06 +0100 Subject: [PATCH] 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. --- api/router.ts | 6 +++--- sidecar/index.ts | 4 ++-- sidecar/upstream.ts | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/api/router.ts b/api/router.ts index 3c8ea08..6d25c11 100644 --- a/api/router.ts +++ b/api/router.ts @@ -1,7 +1,7 @@ import { createRouter } from '@@/create-router'; import { createSidecarProxy } from '@@/sidecar/create-proxy'; import { getLinkwardenConfig, setLinkwardenConfig, getAccount } from '../db/queries'; -import { stripHopByHop } from '../sidecar/upstream'; +import { stripHopByHop, stripResponseHeaders } from '../sidecar/upstream'; /** * Mounted at `/api/linkwarden`. @@ -73,7 +73,7 @@ router.all('/lw/*', async (ctx) => { try { 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 { return ctx.text('Linkwarden unreachable', 502); } @@ -174,7 +174,7 @@ router.all('/api/*', async (ctx) => { try { 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 { return ctx.json({ error: 'Linkwarden unreachable' }, 502); } diff --git a/sidecar/index.ts b/sidecar/index.ts index 358ff00..3811bad 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -1,6 +1,6 @@ import { createSidecarConnector } from '@@/sidecar/connect'; 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 // 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, { status: upstream.status, - headers: stripHopByHop(upstream.headers), + headers: stripResponseHeaders(upstream.headers), }); }, }); diff --git a/sidecar/upstream.ts b/sidecar/upstream.ts index 4133f9a..e26d597 100644 --- a/sidecar/upstream.ts +++ b/sidecar/upstream.ts @@ -79,6 +79,29 @@ export function stripHopByHop(src: Headers): Headers { 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.