Send the length of the bytes we are actually sending

A gzip response is decompressed before the blob proxy sees the body, but
its content-length still describes the compressed bytes. Copying that
header onto the longer body made the browser stop reading that many bytes
in and call the download complete, so files arrived truncated with nothing
reporting a failure.

It took a hop that compresses to show up, and one that only compresses
above a threshold to look like a race: a Sieve script stayed intact for two
rules and came back cut off mid-rule once the third pushed it past 1 KiB.

Ask upstream for identity, and forward no length at all rather than one
that describes different bytes.
This commit is contained in:
2026-08-30 13:46:05 -07:00
parent 22c8eb4af6
commit 0277b5b6a8
3 changed files with 86 additions and 2 deletions
+53
View File
@@ -35,3 +35,56 @@ test("image proxy refuses private targets", async () => {
const res = await app.request("/api/image?url=http://127.0.0.1/x");
assert.equal(res.status, 401);
});
test("a compressed upstream blob is not forwarded with the compressed length", async () => {
const { forwardedContentLength } = await import("./app.js");
// gzip: the body we forward has already been decompressed, so the length on
// the wire describes different bytes and must not be copied (issue #76).
const gz = new Headers({ "content-encoding": "gzip", "content-length": "384" });
assert.equal(forwardedContentLength(gz), null);
// identity, spelled out or absent: the length describes the body we send.
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "identity", "content-length": "1157" })), "1157");
assert.equal(forwardedContentLength(new Headers({ "content-length": "1157" })), "1157");
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "BR", "content-length": "384" })), null);
// Nothing to forward is not an error.
assert.equal(forwardedContentLength(new Headers()), null);
});
test("a Sieve script larger than a compressing hop's threshold survives the proxy", async () => {
const http = await import("node:http");
const zlib = await import("node:zlib");
const { forwardedContentLength } = await import("./app.js");
const script =
"# ihasmail filters v1 - edit with care; rules are stored in the `# rule:` comments\nrequire [\"fileinto\"];\n\n" +
["a", "b", "c"]
.map(
(k) =>
`# rule:{"id":"r${k}","name":"From ${k}@example.com","enabled":true,"join":"allof","tests":[{"type":"header","header":"from","op":"contains","value":"${k}@example.com"}],"actions":[{"type":"fileinto","mailbox":"INBOX/${k}"}]}\n` +
`if header :contains "from" "${k}@example.com"\n{\n fileinto "INBOX/${k}";\n}\n\n`,
)
.join("");
const gz = zlib.gzipSync(Buffer.from(script));
assert.ok(gz.length < Buffer.byteLength(script), "the script has to compress for this test to mean anything");
// A hop that compresses regardless of what we asked for.
const origin = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "application/sieve", "content-encoding": "gzip", "content-length": String(gz.length) });
res.end(gz);
});
await new Promise<void>((r) => origin.listen(0, () => r()));
const port = (origin.address() as { port: number }).port;
try {
const up = await fetch(`http://127.0.0.1:${port}/`);
// What the blob route forwards.
const headers = new Headers({ "content-type": "application/sieve; charset=utf-8" });
const cl = forwardedContentLength(up.headers);
if (cl) headers.set("Content-Length", cl);
const out = new Response(await up.arrayBuffer(), { status: 200, headers });
assert.equal(out.headers.get("content-length"), null);
assert.equal(await out.text(), script);
} finally {
origin.close();
}
});
+31 -2
View File
@@ -519,14 +519,17 @@ export function createApp(): Hono<Env> {
const upstream = await getUpstreamSession(session.id, session.authorization);
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }));
const res = await fetch(url, {
headers: { authorization: session.authorization },
// Ask for the bytes as they are. undici would otherwise negotiate gzip
// on our behalf and hand back a decompressed body whose content-length
// header still describes the compressed one -- see forwardedContentLength.
headers: { authorization: session.authorization, "accept-encoding": "identity" },
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
});
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
const headers = new Headers();
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
headers.set("Content-Type", type);
const cl = res.headers.get("content-length");
const cl = forwardedContentLength(res.headers);
if (cl) headers.set("Content-Length", cl);
const safeInline = inline && isInlineSafe(type);
headers.set(
@@ -651,6 +654,32 @@ function passthrough(res: Response): Response {
return new Response(res.body, { status: res.status, headers });
}
/**
* The upstream content-length, but only when it describes the bytes we are
* about to forward.
*
* A compressed response is decompressed for us before we ever see the body --
* undici does it transparently -- while the content-length header is left
* describing the *compressed* length. Copying it onto the longer body we then
* send makes the browser stop reading exactly that many bytes in and call the
* download complete, so the file arrives silently truncated.
*
* That is the second half of issue #76. A hop in front of Stalwart compressed
* responses over 1 KiB, so a Sieve script stayed intact until the third rule
* pushed it past the threshold and it came back cut off mid-rule. Nothing
* reported an error: the script parsed, just with rules missing, and saving
* wrote that shortened version back over the real one.
*
* We ask for `identity` above so the usual case still carries a length the
* browser can show progress against; this is the guard for a hop that
* compresses anyway.
*/
export function forwardedContentLength(headers: Headers): string | null {
const encoding = headers.get("content-encoding")?.trim().toLowerCase();
if (encoding && encoding !== "identity") return null;
return headers.get("content-length");
}
function sanitizeContentType(ct: string): string {
const lower = ct.split(";")[0]!.trim().toLowerCase();
// Never let the browser render HTML/SVG/XML/JS served from the blob endpoint.