mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 20:20:49 +00:00
Fix tests for mitm registryproxy
This commit is contained in:
parent
cf57a98b54
commit
16e6d0a8f2
1 changed files with 105 additions and 27 deletions
|
|
@ -2,12 +2,17 @@ import { before, after, describe, it } from "node:test";
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import net from "net";
|
import net from "net";
|
||||||
import tls from "tls";
|
import tls from "tls";
|
||||||
|
import { gunzipSync } from "zlib";
|
||||||
import {
|
import {
|
||||||
createSafeChainProxy,
|
createSafeChainProxy,
|
||||||
mergeSafeChainProxyEnvironmentVariables,
|
mergeSafeChainProxyEnvironmentVariables,
|
||||||
} from "./registryProxy.js";
|
} from "./registryProxy.js";
|
||||||
import { getCaCertPath } from "./certUtils.js";
|
import { getCaCertPath } from "./certUtils.js";
|
||||||
import { setEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
import {
|
||||||
|
setEcoSystem,
|
||||||
|
ECOSYSTEM_JS,
|
||||||
|
ECOSYSTEM_PY,
|
||||||
|
} from "../config/settings.js";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
|
||||||
describe("registryProxy.mitm", () => {
|
describe("registryProxy.mitm", () => {
|
||||||
|
|
@ -33,7 +38,7 @@ describe("registryProxy.mitm", () => {
|
||||||
proxyHost,
|
proxyHost,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
"registry.npmjs.org",
|
"registry.npmjs.org",
|
||||||
"/lodash"
|
"/lodash",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(response.statusCode, 200);
|
assert.strictEqual(response.statusCode, 200);
|
||||||
|
|
@ -45,7 +50,7 @@ describe("registryProxy.mitm", () => {
|
||||||
proxyHost,
|
proxyHost,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
"registry.npmjs.org",
|
"registry.npmjs.org",
|
||||||
"/lodash/-/lodash-4.17.21.tgz"
|
"/lodash/-/lodash-4.17.21.tgz",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should get a response (200 or redirect, but not 403 blocked)
|
// Should get a response (200 or redirect, but not 403 blocked)
|
||||||
|
|
@ -57,7 +62,7 @@ describe("registryProxy.mitm", () => {
|
||||||
proxyHost,
|
proxyHost,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
"registry.npmjs.org",
|
"registry.npmjs.org",
|
||||||
"/this-package-definitely-does-not-exist-12345"
|
"/this-package-definitely-does-not-exist-12345",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(response.statusCode, 404);
|
assert.strictEqual(response.statusCode, 404);
|
||||||
|
|
@ -68,7 +73,7 @@ describe("registryProxy.mitm", () => {
|
||||||
proxyHost,
|
proxyHost,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
"registry.npmjs.org",
|
"registry.npmjs.org",
|
||||||
"/lodash?write=true"
|
"/lodash?write=true",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(response.statusCode, 200);
|
assert.strictEqual(response.statusCode, 200);
|
||||||
|
|
@ -79,7 +84,7 @@ describe("registryProxy.mitm", () => {
|
||||||
proxyHost,
|
proxyHost,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
"registry.yarnpkg.com",
|
"registry.yarnpkg.com",
|
||||||
"/lodash"
|
"/lodash",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(response.statusCode, 200);
|
assert.strictEqual(response.statusCode, 200);
|
||||||
|
|
@ -90,7 +95,7 @@ describe("registryProxy.mitm", () => {
|
||||||
proxyHost,
|
proxyHost,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
"registry.npmjs.org",
|
"registry.npmjs.org",
|
||||||
"/lodash"
|
"/lodash",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check certificate common name matches the target hostname
|
// Check certificate common name matches the target hostname
|
||||||
|
|
@ -109,14 +114,14 @@ describe("registryProxy.mitm", () => {
|
||||||
proxyHost,
|
proxyHost,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
"registry.npmjs.org",
|
"registry.npmjs.org",
|
||||||
"/lodash"
|
"/lodash",
|
||||||
);
|
);
|
||||||
|
|
||||||
const { cert: cert2 } = await makeRegistryRequestAndGetCert(
|
const { cert: cert2 } = await makeRegistryRequestAndGetCert(
|
||||||
proxyHost,
|
proxyHost,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
"registry.yarnpkg.com",
|
"registry.yarnpkg.com",
|
||||||
"/lodash"
|
"/lodash",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Different hostnames should have different certificates
|
// Different hostnames should have different certificates
|
||||||
|
|
@ -130,14 +135,14 @@ describe("registryProxy.mitm", () => {
|
||||||
proxyHost,
|
proxyHost,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
"registry.npmjs.org",
|
"registry.npmjs.org",
|
||||||
"/lodash"
|
"/lodash",
|
||||||
);
|
);
|
||||||
|
|
||||||
const { cert: cert2 } = await makeRegistryRequestAndGetCert(
|
const { cert: cert2 } = await makeRegistryRequestAndGetCert(
|
||||||
proxyHost,
|
proxyHost,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
"registry.npmjs.org",
|
"registry.npmjs.org",
|
||||||
"/package/lodash"
|
"/package/lodash",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Same hostname should get the same certificate (fingerprint)
|
// Same hostname should get the same certificate (fingerprint)
|
||||||
|
|
@ -159,7 +164,7 @@ describe("registryProxy.mitm", () => {
|
||||||
proxyHost,
|
proxyHost,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
"pypi.org",
|
"pypi.org",
|
||||||
"/packages/source/f/foo_bar/foo_bar-2.0.0.tar.gz"
|
"/packages/source/f/foo_bar/foo_bar-2.0.0.tar.gz",
|
||||||
);
|
);
|
||||||
assert.notStrictEqual(response.statusCode, 403);
|
assert.notStrictEqual(response.statusCode, 403);
|
||||||
assert.ok(typeof response.body === "string");
|
assert.ok(typeof response.body === "string");
|
||||||
|
|
@ -172,7 +177,7 @@ describe("registryProxy.mitm", () => {
|
||||||
proxyHost,
|
proxyHost,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
"files.pythonhosted.org",
|
"files.pythonhosted.org",
|
||||||
"/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl"
|
"/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl",
|
||||||
);
|
);
|
||||||
assert.notStrictEqual(response.statusCode, 403);
|
assert.notStrictEqual(response.statusCode, 403);
|
||||||
assert.ok(typeof response.body === "string");
|
assert.ok(typeof response.body === "string");
|
||||||
|
|
@ -185,7 +190,7 @@ describe("registryProxy.mitm", () => {
|
||||||
proxyHost,
|
proxyHost,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
"pypi.org",
|
"pypi.org",
|
||||||
"/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz"
|
"/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz",
|
||||||
);
|
);
|
||||||
assert.notStrictEqual(response.statusCode, 403);
|
assert.notStrictEqual(response.statusCode, 403);
|
||||||
assert.ok(typeof response.body === "string");
|
assert.ok(typeof response.body === "string");
|
||||||
|
|
@ -198,7 +203,7 @@ describe("registryProxy.mitm", () => {
|
||||||
proxyHost,
|
proxyHost,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
"pypi.org",
|
"pypi.org",
|
||||||
"/packages/source/f/foo_bar/foo_bar-latest.tar.gz"
|
"/packages/source/f/foo_bar/foo_bar-latest.tar.gz",
|
||||||
);
|
);
|
||||||
assert.notStrictEqual(response.statusCode, 403);
|
assert.notStrictEqual(response.statusCode, 403);
|
||||||
assert.ok(typeof response.body === "string");
|
assert.ok(typeof response.body === "string");
|
||||||
|
|
@ -234,34 +239,73 @@ async function makeRegistryRequest(proxyHost, proxyPort, targetHost, path) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 4: Send HTTP request over TLS
|
// Step 4: Send HTTP request over TLS
|
||||||
const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\n\r\n`;
|
const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\nAccept-encoding: gzip\r\n\r\n`;
|
||||||
tlsSocket.write(httpRequest);
|
tlsSocket.write(httpRequest);
|
||||||
|
|
||||||
// Step 5: Read response
|
// Step 5: Read response as binary chunks
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let data = "";
|
const chunks = [];
|
||||||
|
|
||||||
tlsSocket.on("data", (chunk) => {
|
tlsSocket.on("data", (chunk) => {
|
||||||
data += chunk.toString();
|
chunks.push(chunk);
|
||||||
});
|
});
|
||||||
|
|
||||||
tlsSocket.on("end", () => {
|
tlsSocket.on("end", () => {
|
||||||
const lines = data.split("\r\n");
|
const buffer = Buffer.concat(chunks);
|
||||||
const statusLine = lines[0];
|
|
||||||
|
// Find the header/body separator (\r\n\r\n) in binary
|
||||||
|
const separator = Buffer.from("\r\n\r\n");
|
||||||
|
let separatorIndex = buffer.indexOf(separator);
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return reject(
|
||||||
|
new Error("Invalid HTTP response: no header/body separator"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract headers as text
|
||||||
|
const headersText = buffer.subarray(0, separatorIndex).toString("utf8");
|
||||||
|
const headerLines = headersText.split("\r\n");
|
||||||
|
const statusLine = headerLines[0];
|
||||||
const statusCode = parseInt(statusLine.split(" ")[1]);
|
const statusCode = parseInt(statusLine.split(" ")[1]);
|
||||||
|
|
||||||
// Find body after empty line
|
// Parse headers into object
|
||||||
const emptyLineIndex = lines.findIndex(line => line === "");
|
const headers = {};
|
||||||
const body = lines.slice(emptyLineIndex + 1).join("\r\n");
|
for (let i = 1; i < headerLines.length; i++) {
|
||||||
|
const colonIndex = headerLines[i].indexOf(":");
|
||||||
|
if (colonIndex > 0) {
|
||||||
|
const key = headerLines[i].substring(0, colonIndex).toLowerCase();
|
||||||
|
const value = headerLines[i].substring(colonIndex + 1).trim();
|
||||||
|
headers[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resolve({ statusCode, body });
|
// Extract body as binary
|
||||||
|
let bodyBuffer = buffer.subarray(separatorIndex + separator.length);
|
||||||
|
|
||||||
|
// Decode chunked transfer encoding if present
|
||||||
|
if (headers["transfer-encoding"] === "chunked") {
|
||||||
|
bodyBuffer = decodeChunked(bodyBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decompress if gzip encoded
|
||||||
|
if (headers["content-encoding"] === "gzip" && bodyBuffer.length > 0) {
|
||||||
|
bodyBuffer = gunzipSync(bodyBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = bodyBuffer.toString("utf8");
|
||||||
|
resolve({ statusCode, body, headers });
|
||||||
});
|
});
|
||||||
|
|
||||||
tlsSocket.on("error", reject);
|
tlsSocket.on("error", reject);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function makeRegistryRequestAndGetCert(proxyHost, proxyPort, targetHost, path) {
|
async function makeRegistryRequestAndGetCert(
|
||||||
|
proxyHost,
|
||||||
|
proxyPort,
|
||||||
|
targetHost,
|
||||||
|
path,
|
||||||
|
) {
|
||||||
// Step 1: Connect to proxy
|
// Step 1: Connect to proxy
|
||||||
const socket = await new Promise((resolve, reject) => {
|
const socket = await new Promise((resolve, reject) => {
|
||||||
const sock = net.connect({ host: proxyHost, port: proxyPort }, () => {
|
const sock = net.connect({ host: proxyHost, port: proxyPort }, () => {
|
||||||
|
|
@ -311,7 +355,7 @@ async function makeRegistryRequestAndGetCert(proxyHost, proxyPort, targetHost, p
|
||||||
const statusCode = parseInt(statusLine.split(" ")[1]);
|
const statusCode = parseInt(statusLine.split(" ")[1]);
|
||||||
|
|
||||||
// Find body after empty line
|
// Find body after empty line
|
||||||
const emptyLineIndex = lines.findIndex(line => line === "");
|
const emptyLineIndex = lines.findIndex((line) => line === "");
|
||||||
const body = lines.slice(emptyLineIndex + 1).join("\r\n");
|
const body = lines.slice(emptyLineIndex + 1).join("\r\n");
|
||||||
|
|
||||||
resolve({ statusCode, body });
|
resolve({ statusCode, body });
|
||||||
|
|
@ -322,3 +366,37 @@ async function makeRegistryRequestAndGetCert(proxyHost, proxyPort, targetHost, p
|
||||||
|
|
||||||
return { cert: peerCert, response };
|
return { cert: peerCert, response };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode HTTP chunked transfer encoding
|
||||||
|
* Format: <chunk-size-hex>\r\n<chunk-data>\r\n ... 0\r\n\r\n
|
||||||
|
* @param {Buffer} buffer
|
||||||
|
* @returns {Buffer}
|
||||||
|
*/
|
||||||
|
function decodeChunked(buffer) {
|
||||||
|
const chunks = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset < buffer.length) {
|
||||||
|
// Find the end of the chunk size line
|
||||||
|
const lineEnd = buffer.indexOf(Buffer.from("\r\n"), offset);
|
||||||
|
if (lineEnd === -1) break;
|
||||||
|
|
||||||
|
// Parse chunk size (hex)
|
||||||
|
const sizeHex = buffer.subarray(offset, lineEnd).toString("utf8");
|
||||||
|
const chunkSize = parseInt(sizeHex, 16);
|
||||||
|
|
||||||
|
// End of chunks
|
||||||
|
if (chunkSize === 0) break;
|
||||||
|
|
||||||
|
// Extract chunk data
|
||||||
|
const dataStart = lineEnd + 2;
|
||||||
|
const dataEnd = dataStart + chunkSize;
|
||||||
|
chunks.push(buffer.subarray(dataStart, dataEnd));
|
||||||
|
|
||||||
|
// Move past chunk data and trailing \r\n
|
||||||
|
offset = dataEnd + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue