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:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user