Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions packages/safe-chain/src/registryProxy/decodeBody.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { gunzip } from "zlib";
import { promisify } from "util";

// Guards against decompression bombs; the largest npm packuments are ~32MB.
export const MAX_DECODED_BODY_BYTES = 256 * 1024 * 1024;

const gunzipAsync = promisify(gunzip);

/**
* @param {Buffer} buffer
* @returns {Promise<Buffer>}
*/
function decodeGzip(buffer) {
return gunzipAsync(buffer, { maxOutputLength: MAX_DECODED_BODY_BYTES });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium - Over-limit gzip metadata bypasses minimum-age filtering

A registry can return a gzip metadata response whose decoded size exceeds the new 256 MiB limit. The decoder rejects it, but the response handler catches that error and forwards the original compressed bytes without invoking the metadata interceptor, so npm or pip receives unfiltered version/file metadata and can select releases that the minimum-age rewrite was meant to remove.

Show fix

Treat an over-limit decode as a failed upstream response and do not forward it to the package manager, or use a bounded streaming rewrite path that can safely enforce the limit while still applying the metadata checks. Add an integration test proving that an over-limit gzip response cannot reach the client as an unmodified metadata response.

More info - Reply on this comment to give feedback or ignore the issue.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially fixed in commit 7f82be1.

This issue remains because packages/safe-chain/src/registryProxy/mitmRequestHandler.js:270-278 catches the MAX_DECODED_BODY_BYTES rejection from decodeBody and sends status/headers plus originalBuffer, allowing the over-limit compressed metadata to reach the client unmodified.

}

/**
* Only gzip: npm's br responses are no smaller, and each decoder is attack surface.
* @type {Record<string, (buffer: Buffer) => Promise<Buffer>>}
*/
const decoders = {
gzip: decodeGzip,
"x-gzip": decodeGzip,
};

/**
* Decodes a response body off the event loop.
* @param {Buffer} body
* @param {string | string[] | undefined} contentEncoding
* @returns {Promise<Buffer>}
*/
export async function decodeBody(body, contentEncoding) {
const encoding = String(contentEncoding ?? "identity").trim().toLowerCase();
if (encoding === "identity" || encoding === "") {
return body;
}

if (!Object.hasOwn(decoders, encoding)) {
throw new Error(`Unsupported content-encoding: ${encoding}`);
}

return decoders[encoding](body);
}

/**
* Narrows a client's Accept-Encoding to the offered encodings decodeBody supports.
* @param {string | string[] | undefined} acceptEncoding
* @returns {string}
*/
export function supportedAcceptEncoding(acceptEncoding) {
const offered = String(acceptEncoding ?? "")
.split(",")
.map((token) => token.trim())
.filter(Boolean);
const supported = offered.filter((token) =>
Object.hasOwn(decoders, token.split(";")[0].trim().toLowerCase())
);
return supported.length > 0 ? supported.join(", ") : "identity";
}
Comment on lines +50 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low - Accept-Encoding wildcard with identity refusal is rewritten to an unacceptable encoding

A client that sends Accept-Encoding: *;q=1, identity;q=0 can accept gzip, but supportedAcceptEncoding drops the wildcard and falls back to identity. The proxy therefore asks the registry for identity and forwards an identity metadata response, violating the client's explicit refusal and causing clients that enforce their encoding negotiation to abort the metadata request or installation.

Show fix
Suggested change
export function supportedAcceptEncoding(acceptEncoding) {
const offered = String(acceptEncoding ?? "")
.split(",")
.map((token) => token.trim())
.filter(Boolean);
const supported = offered.filter((token) =>
Object.hasOwn(decoders, token.split(";")[0].trim().toLowerCase())
);
return supported.length > 0 ? supported.join(", ") : "identity";
}
export function supportedAcceptEncoding(acceptEncoding) {
const entries = String(acceptEncoding ?? "").split(",").map((token) => {
const [name, ...params] = token.trim().split(";");
const q = params.find((param) => param.trim().toLowerCase().startsWith("q="));
return { token, name: name.trim().toLowerCase(), q: q ? Number(q.split("=")[1]) : 1 };
}).filter(({ name }) => name);
const supported = entries.filter(({ name, q }) => Object.hasOwn(decoders, name) && q > 0);
if (supported.length) return supported.map(({ token }) => token).join(", ");
const wildcard = entries.find(({ name, q }) => name === "*" && q > 0);
const blocked = new Set(entries.filter(({ q }) => q === 0).map(({ name }) => name));
const wildcardEncoding = wildcard && Object.keys(decoders).find((name) => !blocked.has(name));
if (wildcardEncoding) return `${wildcardEncoding}${wildcard.q === 1 ? "" : `;q=${wildcard.q}`}`;
const identity = entries.find(({ name }) => name === "identity");
if (!identity || identity.q > 0) return "identity";
throw new Error("No acceptable content-encoding supported");
}

More info - Reply on this comment to give feedback or ignore the issue.

81 changes: 81 additions & 0 deletions packages/safe-chain/src/registryProxy/decodeBody.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import zlib from "node:zlib";
import {
decodeBody,
supportedAcceptEncoding,
MAX_DECODED_BODY_BYTES,
} from "./decodeBody.js";

describe("decodeBody", () => {
const body = Buffer.from('{"name":"pkg"}');

for (const [encoding, encode] of [
["gzip", zlib.gzipSync],
["x-gzip", zlib.gzipSync],
]) {
it(`decodes ${encoding}`, async () => {
const decoded = await decodeBody(encode(body), encoding);

assert.strictEqual(decoded.toString(), body.toString());
});
}

it("returns uncompressed bodies as the same buffer", async () => {
assert.strictEqual(await decodeBody(body, undefined), body);
assert.strictEqual(await decodeBody(body, "identity"), body);
});

it("rejects encodings it cannot decode, including inherited property names", async () => {
await assert.rejects(decodeBody(body, "zstd"), /Unsupported/);
await assert.rejects(decodeBody(body, "constructor"), /Unsupported/);
await assert.rejects(decodeBody(body, "gzip, br"), /Unsupported/);
});

it("does not decode br or deflate", async () => {
await assert.rejects(
decodeBody(zlib.brotliCompressSync(body), "br"),
/Unsupported/
);
await assert.rejects(
decodeBody(zlib.deflateSync(body), "deflate"),
/Unsupported/
);
});

it("refuses to expand a body beyond the decoded size limit", async () => {
// Concatenated gzip members: a tiny body that decodes past the limit.
const oneMegabyte = 1024 * 1024;
const member = zlib.gzipSync(Buffer.alloc(oneMegabyte));
const bomb = Buffer.concat(
Array(MAX_DECODED_BODY_BYTES / oneMegabyte + 1).fill(member)
);

await assert.rejects(decodeBody(bomb, "gzip"), {
code: "ERR_BUFFER_TOO_LARGE",
});
});
});

describe("supportedAcceptEncoding", () => {
it("keeps only offered encodings that can be decoded", () => {
assert.strictEqual(
supportedAcceptEncoding("gzip, deflate, br, zstd, x-gzip"),
"gzip, x-gzip"
);
});

it("keeps quality values on the encodings it keeps", () => {
assert.strictEqual(
supportedAcceptEncoding("zstd;q=1.0, br;q=0.9, gzip;q=0.5"),
"gzip;q=0.5"
);
});

it("falls back to identity when nothing offered can be decoded", () => {
assert.strictEqual(supportedAcceptEncoding("zstd"), "identity");
assert.strictEqual(supportedAcceptEncoding("br, deflate"), "identity");
assert.strictEqual(supportedAcceptEncoding(undefined), "identity");
assert.strictEqual(supportedAcceptEncoding("constructor"), "identity");
});
});
18 changes: 11 additions & 7 deletions packages/safe-chain/src/registryProxy/mitmRequestHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import https from "https";
import { generateCertForHost } from "./certUtils.js";
import { HttpsProxyAgent } from "https-proxy-agent";
import { ui } from "../environment/userInteraction.js";
import { gunzipSync } from "zlib";
import { decodeBody, supportedAcceptEncoding } from "./decodeBody.js";
import { omitHeaders } from "./http-utils.js";

/**
Expand Down Expand Up @@ -186,6 +186,11 @@ function createProxyRequest(hostname, port, req, res, requestHandler) {
delete headers.host;
}
headers = requestHandler.modifyRequestHeaders(headers);
if (headers && requestHandler.modifiesResponse()) {
headers["accept-encoding"] = supportedAcceptEncoding(
headers["accept-encoding"]
);
}

/** @type {import("http").RequestOptions} */
const options = {
Expand Down Expand Up @@ -229,16 +234,15 @@ function createProxyRequest(hostname, port, req, res, requestHandler) {

proxyRes.on("data", (chunk) => chunks.push(chunk));

proxyRes.on("end", () => {
proxyRes.on("end", async () => {
/** @type {Buffer} */
const originalBuffer = Buffer.concat(chunks);

try {
let decodedBuffer = originalBuffer;

if (proxyRes.headers["content-encoding"] === "gzip") {
decodedBuffer = gunzipSync(originalBuffer);
}
const decodedBuffer = await decodeBody(
originalBuffer,
proxyRes.headers["content-encoding"]
);

const modifiedBuffer = requestHandler.modifyBody(decodedBuffer, headers);

Expand Down
108 changes: 105 additions & 3 deletions packages/safe-chain/src/registryProxy/mitmRequestHandler.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import zlib from "node:zlib";
describe("mitmRequestHandler", async () => {
let capturedHandler;
let capturedOptions;
let upstreamEncoding = "gzip";
let upstreamBody = Buffer.from("rewritten body");

const encoders = {
gzip: zlib.gzipSync,
br: zlib.brotliCompressSync,
deflate: zlib.deflateSync,
zstd: (buffer) => buffer,
};

mock.module("https", {
defaultExport: {
Expand All @@ -22,7 +31,7 @@ describe("mitmRequestHandler", async () => {
const proxyRes = {
statusCode: 200,
headers: {
"content-encoding": "gzip",
"content-encoding": upstreamEncoding,
"content-length": "999",
"transfer-encoding": "chunked",
},
Expand All @@ -37,8 +46,7 @@ describe("mitmRequestHandler", async () => {
on: () => {},
write: () => {},
end: () => {
const payload = Buffer.from("rewritten body");
listeners["data"]?.(zlib.gzipSync(payload));
listeners["data"]?.(encoders[upstreamEncoding](upstreamBody));
listeners["end"]?.();
},
destroy: () => {},
Expand Down Expand Up @@ -103,6 +111,8 @@ describe("mitmRequestHandler", async () => {
body: undefined,
};

let responseEnded;
const ended = new Promise((resolve) => (responseEnded = resolve));
const res = {
headersSent: false,
writeHead: (statusCode, headers) => {
Expand All @@ -111,6 +121,7 @@ describe("mitmRequestHandler", async () => {
},
end: (body) => {
resState.body = body;
responseEnded();
},
};

Expand All @@ -126,6 +137,7 @@ describe("mitmRequestHandler", async () => {
};

await capturedHandler(request, res);
await ended;

assert.equal(capturedOptions.hostname, "pypi.org");
assert.equal(resState.statusCode, 200);
Expand Down Expand Up @@ -167,6 +179,8 @@ describe("mitmRequestHandler", async () => {
body: undefined,
};

let responseEnded;
const ended = new Promise((resolve) => (responseEnded = resolve));
const res = {
headersSent: false,
writeHead: (statusCode, headers) => {
Expand All @@ -175,6 +189,7 @@ describe("mitmRequestHandler", async () => {
},
end: (body) => {
resState.body = body;
responseEnded();
},
};

Expand All @@ -190,6 +205,7 @@ describe("mitmRequestHandler", async () => {
};

await capturedHandler(request, res);
await ended;

// Caching/encoding headers are preserved so npm and the registry can keep
// serving the response from cache instead of re-reading on the next install.
Expand All @@ -200,4 +216,90 @@ describe("mitmRequestHandler", async () => {
// The body is forwarded still-compressed, exactly as received from upstream.
assert.deepEqual(resState.body, zlib.gzipSync(Buffer.from("rewritten body")));
});

/**
* Runs one intercepted request whose upstream response uses `encoding`, and
* returns the body the interceptor saw plus what was sent to the client.
*/
async function interceptWithEncoding(encoding, modifyBody) {
upstreamEncoding = encoding;
upstreamBody = Buffer.from('{"name":"pkg"}');
let seenByInterceptor;
const interceptor = {
handleRequest: async () => ({
blockResponse: undefined,
modifyRequestHeaders: (headers) => headers,
modifiesResponse: () => true,
modifyBody: (body) => {
seenByInterceptor = body;
return modifyBody(body);
},
}),
};

mitmConnect(
{ url: "registry.npmjs.org:443" },
{ on: () => {}, write: () => {}, writable: true, end: () => {} },
interceptor
);

const sent = {};
const ended = new Promise((resolve) => {
const res = {
headersSent: false,
writeHead: (statusCode, headers) => {
sent.headers = headers;
},
end: (body) => {
sent.body = body;
resolve();
},
};
capturedHandler(
{
url: "/pkg",
headers: {},
method: "GET",
on: (event, handler) => event === "end" && handler(),
},
res
);
});
await ended;

upstreamEncoding = "gzip";
upstreamBody = Buffer.from("rewritten body");
return { seenByInterceptor, sent };
}

it("decodes a gzip response before the interceptor sees it", async () => {
const { seenByInterceptor, sent } = await interceptWithEncoding(
"gzip",
() => Buffer.from('{"name":"rewritten"}')
);

assert.equal(seenByInterceptor.toString(), '{"name":"pkg"}');
assert.equal(sent.headers["content-encoding"], undefined);
assert.equal(sent.body.toString(), '{"name":"rewritten"}');
});

for (const encoding of ["zstd", "br", "deflate"]) {
it(`forwards a ${encoding} response unmodified since it is not decoded`, async () => {
let interceptorCalled = false;
const { sent } = await interceptWithEncoding(encoding, (body) => {
interceptorCalled = true;
return body;
});

assert.equal(interceptorCalled, false);
assert.equal(sent.headers["content-encoding"], encoding);
assert.deepEqual(sent.body, encoders[encoding](Buffer.from('{"name":"pkg"}')));
});
}

it("only offers upstream the encodings it can decode when it may read the response", async () => {
await interceptWithEncoding("gzip", (body) => body);

assert.equal(capturedOptions.headers["accept-encoding"], "identity");
});
});