Merge pull request #119 from AikidoSec/proxy-unit-tests

Add tests for the proxy
This commit is contained in:
Sander Declerck 2025-10-22 15:47:16 +02:00 committed by GitHub
commit 2e1ee0dfa4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 658 additions and 0 deletions

View file

@ -0,0 +1,172 @@
import { before, after, describe, it } from "node:test";
import assert from "node:assert";
import net from "net";
import tls from "tls";
import {
createSafeChainProxy,
mergeSafeChainProxyEnvironmentVariables,
} from "./registryProxy.js";
describe("registryProxy.connectTunnel", () => {
let proxy, proxyHost, proxyPort;
before(async () => {
proxy = createSafeChainProxy();
await proxy.startServer();
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
const proxyUrl = new URL(envVars.HTTPS_PROXY);
proxyHost = proxyUrl.hostname;
proxyPort = parseInt(proxyUrl.port, 10);
});
after(async () => {
await proxy.stopServer();
});
it("should establish a tunnel for HTTP connect", async () => {
const socket = await connectToProxy(proxyHost, proxyPort);
const tunnelResponse = await establishHttpsTunnel(
socket,
"postman-echo.com",
443
);
assert.ok(tunnelResponse.includes("HTTP/1.1 200 Connection Established"));
socket.destroy();
});
it("should send HTTPS request through the established tunnel", async () => {
const socket = await connectToProxy(proxyHost, proxyPort);
await establishHttpsTunnel(socket, "postman-echo.com", 443);
const httpsResponse = await sendHttpsRequestThroughTunnel(
socket,
"GET",
new URL("https://postman-echo.com/status/200")
);
assert.ok(httpsResponse.includes("HTTP/1.1 200 OK"));
socket.destroy();
});
describe("Error Handling", () => {
it("should return 502 Bad Gateway for invalid hostname", async () => {
const socket = await connectToProxy(proxyHost, proxyPort);
const connectRequest = `CONNECT invalid.hostname.that.does.not.exist:443 HTTP/1.1\r\nHost: invalid.hostname.that.does.not.exist:443\r\n\r\n`;
socket.write(connectRequest);
let responseData = "";
await new Promise((resolve) => {
socket.once("data", (data) => {
responseData += data.toString();
resolve();
});
});
assert.ok(responseData.includes("HTTP/1.1 502 Bad Gateway"));
socket.destroy();
});
it("should handle client disconnect during tunnel establishment", async () => {
const socket = await connectToProxy(proxyHost, proxyPort);
const connectRequest = `CONNECT postman-echo.com:443 HTTP/1.1\r\nHost: postman-echo.com:443\r\n\r\n`;
socket.write(connectRequest);
// Immediately destroy the socket before tunnel is fully established
socket.destroy();
// If no crash occurs, the test passes
assert.ok(true);
});
it("should handle socket errors without crashing", async () => {
const socket = await connectToProxy(proxyHost, proxyPort);
socket.on("error", () => {
// Error handler is set to prevent crashes
});
const connectRequest = `CONNECT postman-echo.com:443 HTTP/1.1\r\nHost: postman-echo.com:443\r\n\r\n`;
socket.write(connectRequest);
// Force an error by destroying the socket
socket.destroy();
// Wait a bit to ensure error handling completes
await new Promise((resolve) => setTimeout(resolve, 100));
// Test passes if no unhandled error crashes the process
assert.ok(true);
});
});
});
function connectToProxy(host, port) {
return new Promise((resolve, reject) => {
const socket = net.connect({ host, port }, () => {
resolve(socket);
});
socket.on("error", (err) => {
reject(err);
});
});
}
function establishHttpsTunnel(socket, targetHost, targetPort) {
return new Promise((resolve, reject) => {
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n\r\n`;
socket.write(connectRequest);
let responseData = "";
const onData = (data) => {
responseData += data.toString();
if (responseData.includes("\r\n\r\n")) {
socket.removeListener("data", onData);
socket.removeListener("error", onError);
resolve(responseData);
}
};
const onError = (err) => {
socket.removeListener("data", onData);
socket.removeListener("error", onError);
reject(err);
};
socket.on("data", onData);
socket.on("error", onError);
});
}
function sendHttpsRequestThroughTunnel(socket, verb, url) {
return new Promise((resolve, reject) => {
const tlsSocket = tls.connect(
{
socket: socket,
servername: url.hostname,
},
() => {
tlsSocket.write(
`${verb} ${url.pathname} HTTP/1.1\r\nHost: ${url.hostname}\r\nConnection: close\r\n\r\n`
);
}
);
let tlsData = "";
tlsSocket.on("data", (data) => {
tlsData += data.toString();
});
tlsSocket.on("end", () => {
resolve(tlsData);
});
tlsSocket.on("error", (err) => {
reject(err);
});
});
}

View file

@ -0,0 +1,225 @@
import { before, after, describe, it } from "node:test";
import assert from "node:assert";
import http from "http";
import {
createSafeChainProxy,
mergeSafeChainProxyEnvironmentVariables,
} from "./registryProxy.js";
describe("registryProxy.httpProxy", () => {
let proxy, proxyHost, proxyPort;
let testHttpServer, testHttpServerPort;
before(async () => {
// Start safe-chain proxy
proxy = createSafeChainProxy();
await proxy.startServer();
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
const proxyUrl = new URL(envVars.HTTPS_PROXY);
proxyHost = proxyUrl.hostname;
proxyPort = parseInt(proxyUrl.port, 10);
// Start a test HTTP server to forward requests to
testHttpServer = http.createServer((req, res) => {
if (req.url === "/test") {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("HTTP test response");
} else if (req.url === "/echo-headers") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(req.headers));
} else if (req.url === "/echo-method") {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(req.method);
} else if (req.url === "/post-echo") {
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});
req.on("end", () => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(body);
});
} else if (req.url === "/404") {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found");
} else {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("OK");
}
});
testHttpServerPort = await new Promise((resolve) => {
testHttpServer.listen(0, () => {
resolve(testHttpServer.address().port);
});
});
});
after(async () => {
await proxy.stopServer();
await new Promise((resolve) => {
testHttpServer.close(() => resolve());
setTimeout(resolve, 1000);
});
});
it("should forward HTTP GET requests", async () => {
const response = await makeHttpProxyRequest(
proxyHost,
proxyPort,
`http://localhost:${testHttpServerPort}/test`,
"GET"
);
assert.strictEqual(response.statusCode, 200);
assert.strictEqual(response.body, "HTTP test response");
});
it("should forward HTTP POST requests with body", async () => {
const postData = "test post data";
const response = await makeHttpProxyRequest(
proxyHost,
proxyPort,
`http://localhost:${testHttpServerPort}/post-echo`,
"POST",
postData
);
assert.strictEqual(response.statusCode, 200);
assert.strictEqual(response.body, postData);
});
it("should preserve request headers", async () => {
const response = await makeHttpProxyRequest(
proxyHost,
proxyPort,
`http://localhost:${testHttpServerPort}/echo-headers`,
"GET",
null,
{
"X-Custom-Header": "test-value",
"User-Agent": "test-agent/1.0",
}
);
assert.strictEqual(response.statusCode, 200);
const headers = JSON.parse(response.body);
assert.strictEqual(headers["x-custom-header"], "test-value");
assert.strictEqual(headers["user-agent"], "test-agent/1.0");
});
it("should preserve HTTP methods", async () => {
const methods = ["GET", "POST", "PUT", "DELETE"];
for (const method of methods) {
const response = await makeHttpProxyRequest(
proxyHost,
proxyPort,
`http://localhost:${testHttpServerPort}/echo-method`,
method
);
assert.strictEqual(response.statusCode, 200);
assert.strictEqual(response.body, method);
}
});
it("should forward 404 responses correctly", async () => {
const response = await makeHttpProxyRequest(
proxyHost,
proxyPort,
`http://localhost:${testHttpServerPort}/404`,
"GET"
);
assert.strictEqual(response.statusCode, 404);
assert.strictEqual(response.body, "Not Found");
});
it("should handle invalid host with 502 Bad Gateway", async () => {
const response = await makeHttpProxyRequest(
proxyHost,
proxyPort,
"http://invalid-host-that-does-not-exist.test:9999/test",
"GET"
);
assert.strictEqual(response.statusCode, 502);
assert.ok(response.body.includes("Bad Gateway"));
});
it("should handle HTTPS URLs sent to HTTP proxy", async () => {
// Some clients incorrectly send https:// URLs to the HTTP proxy handler
// instead of using CONNECT. The proxy should handle this gracefully.
const response = await makeHttpProxyRequest(
proxyHost,
proxyPort,
"https://registry.npmjs.org/lodash",
"GET"
);
// Should successfully forward the HTTPS request
assert.strictEqual(response.statusCode, 200);
assert.ok(response.body.includes("lodash"));
});
it("should handle unsupported protocols with 502", async () => {
const response = await makeHttpProxyRequest(
proxyHost,
proxyPort,
"ftp://example.com/file.txt",
"GET"
);
assert.strictEqual(response.statusCode, 502);
assert.ok(response.body.includes("Unsupported protocol"));
});
});
function makeHttpProxyRequest(
proxyHost,
proxyPort,
targetUrl,
method = "GET",
body = null,
extraHeaders = {}
) {
return new Promise((resolve, reject) => {
const options = {
hostname: proxyHost,
port: proxyPort,
path: targetUrl,
method: method,
headers: {
Host: new URL(targetUrl).host,
...extraHeaders,
},
};
const req = http.request(options, (res) => {
let responseBody = "";
res.on("data", (chunk) => {
responseBody += chunk.toString();
});
res.on("end", () => {
resolve({
statusCode: res.statusCode,
headers: res.headers,
body: responseBody,
});
});
});
req.on("error", (err) => {
reject(err);
});
if (body) {
req.write(body);
}
req.end();
});
}

View file

@ -0,0 +1,261 @@
import { before, after, describe, it } from "node:test";
import assert from "node:assert";
import net from "net";
import tls from "tls";
import {
createSafeChainProxy,
mergeSafeChainProxyEnvironmentVariables,
} from "./registryProxy.js";
import { getCaCertPath } from "./certUtils.js";
import fs from "fs";
describe("registryProxy.mitm", () => {
let proxy, proxyHost, proxyPort;
before(async () => {
proxy = createSafeChainProxy();
await proxy.startServer();
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
const proxyUrl = new URL(envVars.HTTPS_PROXY);
proxyHost = proxyUrl.hostname;
proxyPort = parseInt(proxyUrl.port, 10);
});
after(async () => {
await proxy.stopServer();
});
it("should intercept HTTPS requests to npm registry", async () => {
const response = await makeRegistryRequest(
proxyHost,
proxyPort,
"registry.npmjs.org",
"/lodash"
);
assert.strictEqual(response.statusCode, 200);
assert.ok(response.body.includes("lodash"));
});
it("should allow non-malicious package downloads", async () => {
const response = await makeRegistryRequest(
proxyHost,
proxyPort,
"registry.npmjs.org",
"/lodash/-/lodash-4.17.21.tgz"
);
// Should get a response (200 or redirect, but not 403 blocked)
assert.notStrictEqual(response.statusCode, 403);
});
it("should handle 404 responses correctly", async () => {
const response = await makeRegistryRequest(
proxyHost,
proxyPort,
"registry.npmjs.org",
"/this-package-definitely-does-not-exist-12345"
);
assert.strictEqual(response.statusCode, 404);
});
it("should handle query parameters in URL", async () => {
const response = await makeRegistryRequest(
proxyHost,
proxyPort,
"registry.npmjs.org",
"/lodash?write=true"
);
assert.strictEqual(response.statusCode, 200);
});
it("should generate valid certificates for yarn registry", async () => {
const response = await makeRegistryRequest(
proxyHost,
proxyPort,
"registry.yarnpkg.com",
"/lodash"
);
assert.strictEqual(response.statusCode, 200);
});
it("should generate certificate with correct hostname in CN", async () => {
const { cert } = await makeRegistryRequestAndGetCert(
proxyHost,
proxyPort,
"registry.npmjs.org",
"/lodash"
);
// Check certificate common name matches the target hostname
assert.strictEqual(cert.subject.CN, "registry.npmjs.org");
// Check Subject Alternative Name includes the hostname
const san = cert.subjectaltname;
assert.ok(san.includes("registry.npmjs.org"));
// Check certificate is issued by safe-chain CA
assert.strictEqual(cert.issuer.CN, "safe-chain proxy");
});
it("should generate different certificates for different hostnames", async () => {
const { cert: cert1 } = await makeRegistryRequestAndGetCert(
proxyHost,
proxyPort,
"registry.npmjs.org",
"/lodash"
);
const { cert: cert2 } = await makeRegistryRequestAndGetCert(
proxyHost,
proxyPort,
"registry.yarnpkg.com",
"/lodash"
);
// Different hostnames should have different certificates
assert.notStrictEqual(cert1.fingerprint, cert2.fingerprint);
assert.strictEqual(cert1.subject.CN, "registry.npmjs.org");
assert.strictEqual(cert2.subject.CN, "registry.yarnpkg.com");
});
it("should cache generated certificates for same hostname", async () => {
const { cert: cert1 } = await makeRegistryRequestAndGetCert(
proxyHost,
proxyPort,
"registry.npmjs.org",
"/lodash"
);
const { cert: cert2 } = await makeRegistryRequestAndGetCert(
proxyHost,
proxyPort,
"registry.npmjs.org",
"/package/lodash"
);
// Same hostname should get the same certificate (fingerprint)
assert.strictEqual(cert1.fingerprint, cert2.fingerprint);
});
});
async function makeRegistryRequest(proxyHost, proxyPort, targetHost, path) {
// Step 1: Connect to proxy
const socket = await new Promise((resolve, reject) => {
const sock = net.connect({ host: proxyHost, port: proxyPort }, () => {
resolve(sock);
});
sock.on("error", reject);
});
// Step 2: Send CONNECT request
await new Promise((resolve) => {
const connectRequest = `CONNECT ${targetHost}:443 HTTP/1.1\r\nHost: ${targetHost}:443\r\n\r\n`;
socket.write(connectRequest);
socket.once("data", resolve);
});
// Step 3: Upgrade to TLS using the proxy's CA cert
const tlsSocket = tls.connect({
socket: socket,
servername: targetHost,
ca: fs.readFileSync(getCaCertPath()),
rejectUnauthorized: true,
});
await new Promise((resolve) => {
tlsSocket.on("secureConnect", resolve);
});
// Step 4: Send HTTP request over TLS
const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\n\r\n`;
tlsSocket.write(httpRequest);
// Step 5: Read response
return new Promise((resolve, reject) => {
let data = "";
tlsSocket.on("data", (chunk) => {
data += chunk.toString();
});
tlsSocket.on("end", () => {
const lines = data.split("\r\n");
const statusLine = lines[0];
const statusCode = parseInt(statusLine.split(" ")[1]);
// Find body after empty line
const emptyLineIndex = lines.findIndex(line => line === "");
const body = lines.slice(emptyLineIndex + 1).join("\r\n");
resolve({ statusCode, body });
});
tlsSocket.on("error", reject);
});
}
async function makeRegistryRequestAndGetCert(proxyHost, proxyPort, targetHost, path) {
// Step 1: Connect to proxy
const socket = await new Promise((resolve, reject) => {
const sock = net.connect({ host: proxyHost, port: proxyPort }, () => {
resolve(sock);
});
sock.on("error", reject);
});
// Step 2: Send CONNECT request
await new Promise((resolve) => {
const connectRequest = `CONNECT ${targetHost}:443 HTTP/1.1\r\nHost: ${targetHost}:443\r\n\r\n`;
socket.write(connectRequest);
socket.once("data", resolve);
});
// Step 3: Upgrade to TLS and capture certificate
const tlsSocket = tls.connect({
socket: socket,
servername: targetHost,
ca: fs.readFileSync(getCaCertPath()),
rejectUnauthorized: true,
});
let peerCert;
await new Promise((resolve) => {
tlsSocket.on("secureConnect", () => {
peerCert = tlsSocket.getPeerCertificate();
resolve();
});
});
// Step 4: Send HTTP request over TLS
const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\n\r\n`;
tlsSocket.write(httpRequest);
// Step 5: Read response
const response = await new Promise((resolve, reject) => {
let data = "";
tlsSocket.on("data", (chunk) => {
data += chunk.toString();
});
tlsSocket.on("end", () => {
const lines = data.split("\r\n");
const statusLine = lines[0];
const statusCode = parseInt(statusLine.split(" ")[1]);
// Find body after empty line
const emptyLineIndex = lines.findIndex(line => line === "");
const body = lines.slice(emptyLineIndex + 1).join("\r\n");
resolve({ statusCode, body });
});
tlsSocket.on("error", reject);
});
return { cert: peerCert, response };
}