diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 46cc30c..5b27118 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -1,5 +1,9 @@ import * as cliArguments from "./cliArguments.js"; +export const LOGGING_SILENT = "silent"; +export const LOGGING_NORMAL = "normal"; +export const LOGGING_VERBOSE = "verbose"; + export function getLoggingLevel() { const level = cliArguments.getLoggingLevel(); @@ -14,9 +18,6 @@ export function getLoggingLevel() { return LOGGING_NORMAL; } -export const MALWARE_ACTION_BLOCK = "block"; -export const MALWARE_ACTION_PROMPT = "prompt"; - export const ECOSYSTEM_JS = "js"; export const ECOSYSTEM_PY = "py"; @@ -36,6 +37,6 @@ export function setEcoSystem(setting) { ecosystemSettings.ecoSystem = setting; } -export const LOGGING_SILENT = "silent"; -export const LOGGING_NORMAL = "normal"; -export const LOGGING_VERBOSE = "verbose"; +export function getMinimumPackageAgeHours() { + return 24 * 6; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js b/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js index c97d867..79b5200 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +++ b/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js @@ -3,7 +3,7 @@ import { ECOSYSTEM_PY, getEcoSystem, } from "../../config/settings.js"; -import { npmInterceptorForUrl } from "./npmInterceptor.js"; +import { npmInterceptorForUrl } from "./npm/npmInterceptor.js"; import { pipInterceptorForUrl } from "./pipInterceptor.js"; /** diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js new file mode 100644 index 0000000..e1fd16b --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -0,0 +1,231 @@ +import chalk from "chalk"; +import { getMinimumPackageAgeHours } from "../../../config/settings.js"; +import { isMalwarePackage } from "../../../scanning/audit/index.js"; +import { createInterceptorBuilder } from "../interceptorBuilder.js"; +import { ui } from "../../../environment/userInteraction.js"; +import { writeFileSync } from "node:fs"; + +const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; + +/** + * @param {string} url + * @returns {import("../interceptorBuilder.js").Interceptor | undefined} + */ +export function npmInterceptorForUrl(url) { + const registry = knownJsRegistries.find((reg) => url.includes(reg)); + + if (registry) { + return buildNpmInterceptor(registry); + } + + return undefined; +} + +/** + * @param {string} registry + * @returns {import("../interceptorBuilder.js").Interceptor | undefined} + */ +function buildNpmInterceptor(registry) { + const builder = createInterceptorBuilder(); + + builder.onRequest(async (req) => { + const { packageName, version } = parseNpmPackageUrl( + req.targetUrl, + registry + ); + if (await isMalwarePackage(packageName, version)) { + req.blockMalware(packageName, version, req.targetUrl); + } + + if (isPackageInfoUrl(req.targetUrl)) { + req.modifyRequestHeaders((headers) => { + if ( + headers["accept"]?.includes("application/vnd.npm.install-v1+json") + ) { + // The npm registry sometimes serves a more compact format that lacks + // the time metadata we need to filter out too new packages. + // Force the registry to return the full metadata by changing the Accept header. + headers["accept"] = "application/json"; + } + }); + + req.modifyResponse((res) => { + res.modifyBody(modifyNpmInfoRequestBody); + }); + } + }); + + return builder.build(); +} + +/** + * @param {string} url + * @param {string} registry + * @returns {{packageName: string | undefined, version: string | undefined}} + */ +function parseNpmPackageUrl(url, registry) { + let packageName, version; + if (!registry || !url.endsWith(".tgz")) { + return { packageName, version }; + } + + const registryIndex = url.indexOf(registry); + const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash + + const separatorIndex = afterRegistry.indexOf("/-/"); + if (separatorIndex === -1) { + return { packageName, version }; + } + + packageName = afterRegistry.substring(0, separatorIndex); + const filename = afterRegistry.substring( + separatorIndex + 3, + afterRegistry.length - 4 + ); // Remove /-/ and .tgz + + // Extract version from filename + // For scoped packages like @babel/core, the filename is core-7.21.4.tgz + // For regular packages like lodash, the filename is lodash-4.17.21.tgz + if (packageName.startsWith("@")) { + const scopedPackageName = packageName.substring( + packageName.lastIndexOf("/") + 1 + ); + if (filename.startsWith(scopedPackageName + "-")) { + version = filename.substring(scopedPackageName.length + 1); + } + } else { + if (filename.startsWith(packageName + "-")) { + version = filename.substring(packageName.length + 1); + } + } + + return { packageName, version }; +} + +/** + * @param {string} url + * @returns {boolean} + */ +function isPackageInfoUrl(url) { + // Remove query string and fragment to get the actual path + const urlWithoutParams = url.split("?")[0].split("#")[0]; + + // Tarball downloads end with .tgz + if (urlWithoutParams.endsWith(".tgz")) return false; + + // Special endpoints start with /-/ and should not be modified + // Examples: /-/npm/v1/security/advisories/bulk, /-/v1/search, /-/package/foo/access + if (urlWithoutParams.includes("/-/")) return false; + + // Everything else is package metadata that can be modified + return true; +} + +/** + * + * @param {Buffer} body + * @returns Buffer + */ +function modifyNpmInfoRequestBody(body) { + try { + const bodyContent = body.toString("utf8"); + const bodyJson = JSON.parse(bodyContent); + + if (!bodyJson.time || !bodyJson["dist-tags"] || !bodyJson.versions) { + // Just return the body if the + return body; + } + + const cutOff = new Date( + new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 + ).toISOString(); + + const hasLatestTag = !!bodyJson["dist-tags"]["latest"]; + + const versions = Object.entries(bodyJson.time) + .map(([version, timestamp]) => ({ + version, + timestamp, + })) + .filter((x) => x.version != "created" && x.version != "modified"); + + for (const { version, timestamp } of versions) { + if (version === "created" || version === "modified") { + continue; + } + + if (timestamp > cutOff) { + deleteVersionFromJson(bodyJson, version); + continue; + } + } + + if (hasLatestTag && !bodyJson["dist-tags"]["latest"]) { + // The latest tag was removed because it contained a package younger than the treshold. + // A new latest tag needs to be calculated + bodyJson["dist-tags"]["latest"] = calculateLatestTag(bodyJson); + } + + return Buffer.from(JSON.stringify(bodyJson)); + } catch (err) { + // TODO: better error handling + return body; + } +} + +function deleteVersionFromJson(json, version) { + ui.writeVerbose( + `Safe-chain: Deleting ${version} from npm info request, it's newer than the minimumPackageAgeInHours` + ); + + delete json.time[version]; + delete json.versions[version]; + + for (const [tag, distVersion] of Object.entries(json["dist-tags"])) { + if (version == distVersion) { + delete json["dist-tags"][tag]; + } + } +} + +function calculateLatestTag(json) { + if (!json.time) { + return undefined; + } + + let latest, preview, latestDate, previewDate; + + for (const [version, timestamp] of Object.entries(json.time)) { + if (version == "created" || version == "modified") continue; + + if (version.includes("-")) { + // preview versions include "-" in the name + [preview, previewDate] = getLatest( + preview, + previewDate, + version, + timestamp + ); + } else { + [latest, latestDate] = getLatest(latest, latestDate, version, timestamp); + } + } + + if (latest) { + return latest; + } else { + return preview; + } + + function getLatest(currentLatest, currentLatestDate, version, timestamp) { + if (!currentLatest) { + return [version, timestamp]; + } + + if (timestamp > currentLatestDate) { + return [version, timestamp]; + } + + return [currentLatest, currentLatestDate]; + } +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js new file mode 100644 index 0000000..ea23f9e --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -0,0 +1,237 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; + +describe("npmInterceptor minimum package age", async () => { + let minimumPackageAgeSettings = 48; + + mock.module("../../../config/settings.js", { + namedExports: { + getMinimumPackageAgeHours: () => minimumPackageAgeSettings, + }, + }); + + mock.module("../../../scanning/audit/index.js", { + namedExports: { + isMalwarePackage: async () => { + return false; + }, + }, + }); + const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); + + for (const packageInfoUrl of [ + // Basic package metadata + "https://registry.npmjs.org/lodash", + "https://registry.npmjs.org/express", + // Scoped packages + "https://registry.npmjs.org/@vercel/functions", + "https://registry.npmjs.org/@babel/core", + "https://registry.npmjs.org/@types/node", + // With query parameters + "https://registry.npmjs.org/lodash?write=true", + "https://registry.npmjs.org/@babel/core?param=value&other=test", + // With fragments + "https://registry.npmjs.org/lodash#readme", + "https://registry.npmjs.org/@babel/core#installation", + // Version-specific metadata + "https://registry.npmjs.org/lodash/4.17.21", + "https://registry.npmjs.org/lodash/latest", + "https://registry.npmjs.org/@babel/core/7.21.4", + // URL-encoded scoped packages + "https://registry.npmjs.org/@types%2Fnode", + "https://registry.npmjs.org/@babel%2Fcore", + // With trailing slashes + "https://registry.npmjs.org/lodash/", + "https://registry.npmjs.org/@babel/core/", + ]) { + it(`modifyResponse should be true for package info requests: ${packageInfoUrl}`, async () => { + const interceptor = npmInterceptorForUrl(packageInfoUrl); + const requestInterceptor = await interceptor.handleRequest( + packageInfoUrl + ); + + assert.equal(requestInterceptor.modifiesResponse(), true); + }); + } + + for (const packageUrl of [ + // Regular package tarballs + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + // Scoped package tarballs + "https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz", + "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", + // Tarballs with query parameters (integrity checks) + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123", + "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz?integrity=sha512-def456&cache=false", + // Tarballs with fragments + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#sha512-abc123", + "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz#hash", + // Prerelease versions + "https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz", + "https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz", + ]) { + it(`modifyResponse should be false for package downloads: ${packageUrl}`, async () => { + const interceptor = npmInterceptorForUrl(packageUrl); + const requestInterceptor = await interceptor.handleRequest(packageUrl); + + assert.equal(requestInterceptor.modifiesResponse(), false); + }); + } + + for (const specialEndpoint of [ + // Security advisory endpoints + "https://registry.npmjs.org/-/npm/v1/security/advisories/bulk", + "https://registry.npmjs.org/-/npm/v1/security/audits", + "https://registry.npmjs.org/-/npm/v1/security/audits/quick", + // Search endpoints + "https://registry.npmjs.org/-/v1/search?text=lodash&size=20", + "https://registry.npmjs.org/-/v1/search?text=react&from=0", + // Package access/collaboration endpoints + "https://registry.npmjs.org/-/package/lodash/access", + "https://registry.npmjs.org/-/package/@babel/core/collaborators", + "https://registry.npmjs.org/-/package/lodash/dist-tags", + "https://registry.npmjs.org/-/package/@babel/core/dist-tags/latest", + // User/organization endpoints + "https://registry.npmjs.org/-/user/org.couchdb.user:username", + "https://registry.npmjs.org/-/org/myorg/package", + // Anonymous metrics + "https://registry.npmjs.org/-/npm/anon-metrics/v1/", + // Ping/health check + "https://registry.npmjs.org/-/ping", + ]) { + it(`modifyResponse should be false for special endpoints: ${specialEndpoint}`, async () => { + const interceptor = npmInterceptorForUrl(specialEndpoint); + const requestInterceptor = await interceptor.handleRequest( + specialEndpoint + ); + + assert.equal(requestInterceptor.modifiesResponse(), false); + }); + } + + it("Should remove packages older than the treshold", async () => { + minimumPackageAgeSettings = 5; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "3.0.0", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + ["1.0.0"]: getDate(-7), + // cutoff-date here + ["2.0.0"]: getDate(-4), + ["3.0.0"]: getDate(-3), + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + + assert.equal(Object.keys(modifiedJson.time).length, 2); + assert.equal(Object.keys(modifiedJson.versions).length, 1); + assert.ok(Object.keys(modifiedJson.time).includes("1.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + assert.ok(!Object.keys(modifiedJson.time).includes("2.0.0")); + assert.ok(!Object.keys(modifiedJson.versions).includes("2.0.0")); + assert.ok(!Object.keys(modifiedJson.time).includes("3.0.0")); + assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0")); + }); + + it("Should set the package to the new latest non-preview release", async () => { + minimumPackageAgeSettings = 5; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "3.0.0", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + ["1.0.0"]: getDate(-7), + ["0.0.1"]: getDate(-8), // package order: this package is older than 1.0.0, it should not be considered latest + ["2.0.0-alpha"]: getDate(-6), //package is a pre-release, it should not be latest + // cutoff-date here + ["2.0.0"]: getDate(-4), + ["3.0.0"]: getDate(-3), + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + + assert.equal(modifiedJson["dist-tags"]["latest"], "1.0.0"); + }); + + it("Should remove dist-tags if version was removed", async () => { + minimumPackageAgeSettings = 5; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "3.0.0", + alpha: "2.0.0-alpha", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + ["1.0.0"]: getDate(-7), + // cutoff-date here + ["2.0.0-alpha"]: getDate(-4), + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + console.log(modifiedJson); + + assert.equal(modifiedJson["dist-tags"]["alpha"], undefined); + }); + + /** + * @param {import("../interceptorBuilder.js").Interceptor} interceptor + * @param {string} body + * @returns {Promise} + */ + async function runModifyNpmInfoRequest(url, body) { + const interceptor = npmInterceptorForUrl(url); + const requestInterceptor = await interceptor.handleRequest(url); + const responseInterceptor = requestInterceptor.handleResponse(); + + const modifiedBuffer = responseInterceptor.modifyBody(Buffer.from(body)); + + return modifiedBuffer.toString("utf8"); + } + + function getDate(plusHours) { + const date = new Date(); + date.setHours(date.getHours() + plusHours); + + return date; + } +}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js similarity index 99% rename from packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.spec.js rename to packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index dd09527..a90432e 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -5,7 +5,7 @@ describe("npmInterceptor", async () => { let lastPackage; let malwareResponse = false; - mock.module("../../scanning/audit/index.js", { + mock.module("../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async (packageName, version) => { lastPackage = { packageName, version }; diff --git a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js deleted file mode 100644 index 97ac15d..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js +++ /dev/null @@ -1,92 +0,0 @@ -import chalk from "chalk"; -import { isMalwarePackage } from "../../scanning/audit/index.js"; -import { createInterceptorBuilder } from "./interceptorBuilder.js"; - -const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; - -/** - * @param {string} url - * @returns {import("./interceptorBuilder.js").Interceptor | undefined} - */ -export function npmInterceptorForUrl(url) { - const registry = knownJsRegistries.find((reg) => url.includes(reg)); - - if (registry) { - return buildNpmInterceptor(registry); - } - - return undefined; -} - -/** - * @param {string} registry - * @returns {import("./interceptorBuilder.js").Interceptor | undefined} - */ -function buildNpmInterceptor(registry) { - const builder = createInterceptorBuilder(); - - builder.onRequest(async (req) => { - const { packageName, version } = parseNpmPackageUrl( - req.targetUrl, - registry - ); - if (await isMalwarePackage(packageName, version)) { - req.blockMalware(packageName, version, req.targetUrl); - } - - req.modifyRequestHeaders((headers) => { - if (headers["accept"]?.includes("application/vnd.npm.install-v1+json")) { - // The npm registry sometimes serves a more compact format that lacks - // the time metadata we need to filter out too new packages. - // Force the registry to return the full metadata by changing the Accept header. - headers["accept"] = "application/json"; - } - }); - }); - - return builder.build(); -} - -/** - * @param {string} url - * @param {string} registry - * @returns {{packageName: string | undefined, version: string | undefined}} - */ -function parseNpmPackageUrl(url, registry) { - let packageName, version; - if (!registry || !url.endsWith(".tgz")) { - return { packageName, version }; - } - - const registryIndex = url.indexOf(registry); - const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash - - const separatorIndex = afterRegistry.indexOf("/-/"); - if (separatorIndex === -1) { - return { packageName, version }; - } - - packageName = afterRegistry.substring(0, separatorIndex); - const filename = afterRegistry.substring( - separatorIndex + 3, - afterRegistry.length - 4 - ); // Remove /-/ and .tgz - - // Extract version from filename - // For scoped packages like @babel/core, the filename is core-7.21.4.tgz - // For regular packages like lodash, the filename is lodash-4.17.21.tgz - if (packageName.startsWith("@")) { - const scopedPackageName = packageName.substring( - packageName.lastIndexOf("/") + 1 - ); - if (filename.startsWith(scopedPackageName + "-")) { - version = filename.substring(scopedPackageName.length + 1); - } - } else { - if (filename.startsWith(packageName + "-")) { - version = filename.substring(packageName.length + 1); - } - } - - return { packageName, version }; -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js index e492f57..2944968 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js @@ -4,14 +4,18 @@ * @property {(statusCode: number, message: string) => void} blockRequest * @property {(packageName: string | undefined, version: string | undefined, url: string) => void} blockMalware * @property {(modificationFunc: (headers: NodeJS.Dict) => void) => void} modifyRequestHeaders + * @property {(requestFunc: (responseInterceptorBuilder: import('./responseInterceptorBuilder.js').ResponseInterceptorBuilder) => void) => void} modifyResponse * @property {() => RequestInterceptor} build * * @typedef {Object} RequestInterceptor * @property {{statusCode: number, message: string} | undefined} blockResponse * @property {(headers: NodeJS.Dict | undefined) => void} modifyRequestHeaders + * @property {() => import("./responseInterceptorBuilder.js").ResponseInterceptor} handleResponse * @property {() => boolean} modifiesResponse */ +import { createResponseInterceptorBuilder } from "./responseInterceptorBuilder.js"; + /** * @param {string} targetUrl * @param {import('events').EventEmitter} eventEmitter @@ -20,15 +24,10 @@ export function createRequestInterceptorBuilder(targetUrl, eventEmitter) { /** @type {{statusCode: number, message: string} | undefined} */ let blockResponse = undefined; - - /** - * @type {{ - * requestHeaders: Array<(headers: NodeJS.Dict) => void> - * }} - */ - let modificationFuncs = { - requestHeaders: [], - }; + /** @type {Array<(headers: NodeJS.Dict) => void>} */ + let requestHeaderFuncs = []; + /** @type {Array<(requestFunc: import('./responseInterceptorBuilder.js').ResponseInterceptorBuilder) => void>} */ + let responseModifierFuncs = []; /** * @param {number} statusCode @@ -60,12 +59,16 @@ export function createRequestInterceptorBuilder(targetUrl, eventEmitter) { blockRequest, blockMalware, modifyRequestHeaders(modificationFunc) { - modificationFuncs.requestHeaders.push(modificationFunc); + requestHeaderFuncs.push(modificationFunc); + }, + modifyResponse(modificationFunc) { + responseModifierFuncs.push(modificationFunc); }, build() { return createRequestInterceptor( blockResponse, - modificationFuncs.requestHeaders + requestHeaderFuncs, + responseModifierFuncs ); }, }; @@ -74,11 +77,13 @@ export function createRequestInterceptorBuilder(targetUrl, eventEmitter) { /** * @param {{statusCode: number, message: string} | undefined} blockResponse * @param {Array<(headers: NodeJS.Dict) => void>} requestHeadersModficationFuncs + * @param {Array<(requestFunc: import('./responseInterceptorBuilder.js').ResponseInterceptorBuilder) => void>} responseModifierFuncs * @returns {RequestInterceptor} */ function createRequestInterceptor( blockResponse, - requestHeadersModficationFuncs + requestHeadersModficationFuncs, + responseModifierFuncs ) { /** * @param {NodeJS.Dict | undefined} headers @@ -94,8 +99,23 @@ function createRequestInterceptor( } function modifiesResponse() { - return false; + return responseModifierFuncs.length > 0; } - return { blockResponse, modifyRequestHeaders, modifiesResponse }; + function handleResponse() { + const responseInterceptorBuilder = createResponseInterceptorBuilder(); + + for (const func of responseModifierFuncs) { + func(responseInterceptorBuilder); + } + + return responseInterceptorBuilder.build(); + } + + return { + blockResponse, + modifyRequestHeaders, + modifiesResponse, + handleResponse, + }; } diff --git a/packages/safe-chain/src/registryProxy/interceptors/responseInterceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/responseInterceptorBuilder.js new file mode 100644 index 0000000..86d79e5 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/responseInterceptorBuilder.js @@ -0,0 +1,43 @@ +/** + * @typedef {Object} ResponseInterceptorBuilder + * @property {() => ResponseInterceptor} build + * @property {(modificationFunc: (body: Buffer) => Buffer) => void} modifyBody + * + * @typedef {Object} ResponseInterceptor + * @property {(buffer: Buffer) => Buffer} modifyBody + */ + +/** + * @returns {ResponseInterceptorBuilder} + */ +export function createResponseInterceptorBuilder() { + /** @type {Array<(body: Buffer) => Buffer>} */ + let modifyBodyFuncs = []; + + return { + modifyBody: (func) => modifyBodyFuncs.push(func), + build: () => createResponseInterceptor(modifyBodyFuncs), + }; +} + +/** + * @returns {ResponseInterceptor} + * @param {Array<(body: Buffer) => Buffer>} modifyBodyFuncs + */ +function createResponseInterceptor(modifyBodyFuncs) { + /** + * @param {Buffer} body + * @returns {Buffer} + */ + function modifyBody(body) { + let modifiedBody = body; + + for (var func of modifyBodyFuncs) { + modifiedBody = func(body); + } + + return modifiedBody; + } + + return { modifyBody }; +} diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index a76efb4..aa1391e 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -2,6 +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, gzipSync } from "zlib"; /** * @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor @@ -188,14 +189,36 @@ function createProxyRequest(hostname, req, res, requestInterceptor) { res.end("Internal Server Error"); return; } - res.writeHead(proxyRes.statusCode, proxyRes.headers); - if (!requestInterceptor.modifiesResponse) { - // If the response is not being modified, we can - // just pipe without the need for - proxyRes.pipe(res); + if (requestInterceptor.modifiesResponse()) { + const responseInterceptor = requestInterceptor.handleResponse(); + + /** @type {Array} */ + let chunks = []; + + proxyRes.on("data", (chunk) => chunks.push(chunk)); + + proxyRes.on("end", () => { + /** @type {Buffer} */ + let buffer = Buffer.concat(chunks); + + if (proxyRes.headers["content-encoding"] === "gzip") { + buffer = gunzipSync(buffer); + } + + buffer = responseInterceptor.modifyBody(buffer); + + if (proxyRes.headers["content-encoding"] === "gzip") { + buffer = gzipSync(buffer); + } + + res.end(buffer); + }); } else { + // If the response is not being modified, we can + // just pipe without the need for buffering the output + proxyRes.pipe(res); } });