199 lines
9.4 KiB
TypeScript
199 lines
9.4 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { X509Certificate } from "node:crypto";
|
|
import { access, chmod, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
import http from "node:http";
|
|
import https from "node:https";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import tls from "node:tls";
|
|
import { test } from "node:test";
|
|
|
|
import { stageTrustedEnterpriseTlsRoot, startEnterpriseTlsReverseEdge } from "../src/egress.ts";
|
|
|
|
function listen(server: http.Server): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") return reject(new Error("server did not bind"));
|
|
resolve(address.port);
|
|
});
|
|
});
|
|
}
|
|
|
|
function close(server: http.Server): Promise<void> {
|
|
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
|
|
function request(url: string, options: { ca?: string; method?: string; path?: string; body?: string } = {}): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const target = new URL(url);
|
|
const outgoing = https.request({
|
|
hostname: target.hostname,
|
|
port: target.port,
|
|
ca: options.ca,
|
|
method: options.method,
|
|
path: options.path ?? `${target.pathname}${target.search}`,
|
|
}, (response) => {
|
|
const chunks: Buffer[] = [];
|
|
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
});
|
|
outgoing.on("error", reject);
|
|
outgoing.end(options.body);
|
|
});
|
|
}
|
|
|
|
function peerCertificatePem(url: string, ca: string): Promise<string> {
|
|
const target = new URL(url);
|
|
return new Promise((resolve, reject) => {
|
|
const socket = tls.connect({ host: target.hostname, port: Number(target.port), ca, servername: "localhost" }, () => {
|
|
const raw = socket.getPeerCertificate().raw;
|
|
socket.end();
|
|
if (!raw) reject(new Error("peer did not provide a certificate"));
|
|
else resolve(new X509Certificate(raw).toString());
|
|
});
|
|
socket.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function manifest(rootPemPath: string): string {
|
|
return `${JSON.stringify({
|
|
pid: process.pid,
|
|
candidateUrl: "https://localhost:8443",
|
|
negativeUrl: "https://localhost:9443",
|
|
adminUrl: "http://127.0.0.1:8445",
|
|
rootPemPath,
|
|
})}\n`;
|
|
}
|
|
|
|
test("enterprise TLS privileged trust material is validated and staged", async (t) => {
|
|
const edge = await startEnterpriseTlsReverseEdge({ upstream: "http://127.0.0.1:1" });
|
|
const dir = await mkdtemp(path.join(tmpdir(), "openwork-enterprise-tls-validation-test-"));
|
|
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
try {
|
|
await t.test("accepts an owned regular manifest and CA PEM", async () => {
|
|
const rootPemPath = path.join(dir, "happy-root.pem");
|
|
const manifestPath = path.join(dir, "happy-manifest.json");
|
|
await writeFile(rootPemPath, edge.rootPem, { mode: 0o600 });
|
|
await writeFile(manifestPath, manifest(rootPemPath), { mode: 0o600 });
|
|
const staged = await stageTrustedEnterpriseTlsRoot(manifestPath, [uid]);
|
|
try {
|
|
assert.deepEqual(staged.rootPem, Buffer.from(edge.rootPem));
|
|
assert.deepEqual(await readFile(staged.stagedRootPemPath), staged.rootPem);
|
|
assert.notEqual(staged.stagedRootPemPath, rootPemPath);
|
|
assert.equal((await stat(staged.stagedRootPemPath)).mode & 0o777, 0o600);
|
|
} finally {
|
|
await staged.cleanup();
|
|
}
|
|
});
|
|
|
|
await t.test("rejects a symlinked manifest", async () => {
|
|
const rootPemPath = path.join(dir, "manifest-link-root.pem");
|
|
const targetPath = path.join(dir, "manifest-target.json");
|
|
const manifestPath = path.join(dir, "manifest-link.json");
|
|
await writeFile(rootPemPath, edge.rootPem, { mode: 0o600 });
|
|
await writeFile(targetPath, manifest(rootPemPath), { mode: 0o600 });
|
|
await symlink(targetPath, manifestPath);
|
|
await assert.rejects(stageTrustedEnterpriseTlsRoot(manifestPath, [uid]), { name: "ENTERPRISE_TLS_MANIFEST_UNTRUSTED" });
|
|
});
|
|
|
|
await t.test("rejects a symlinked PEM", async () => {
|
|
const targetPath = path.join(dir, "pem-target.pem");
|
|
const rootPemPath = path.join(dir, "pem-link.pem");
|
|
const manifestPath = path.join(dir, "pem-link-manifest.json");
|
|
await writeFile(targetPath, edge.rootPem, { mode: 0o600 });
|
|
await symlink(targetPath, rootPemPath);
|
|
await writeFile(manifestPath, manifest(rootPemPath), { mode: 0o600 });
|
|
await assert.rejects(stageTrustedEnterpriseTlsRoot(manifestPath, [uid]), { name: "ENTERPRISE_TLS_ROOT_PEM_UNTRUSTED" });
|
|
});
|
|
|
|
await t.test("rejects a world-writable PEM", async () => {
|
|
const rootPemPath = path.join(dir, "writable-root.pem");
|
|
const manifestPath = path.join(dir, "writable-manifest.json");
|
|
await writeFile(rootPemPath, edge.rootPem, { mode: 0o600 });
|
|
await chmod(rootPemPath, 0o666);
|
|
await writeFile(manifestPath, manifest(rootPemPath), { mode: 0o600 });
|
|
await assert.rejects(stageTrustedEnterpriseTlsRoot(manifestPath, [uid]), { name: "ENTERPRISE_TLS_ROOT_PEM_UNTRUSTED" });
|
|
});
|
|
|
|
await t.test("rejects non-CA and unparseable PEM files", async () => {
|
|
const manifestPath = path.join(dir, "invalid-manifest.json");
|
|
const rootPemPath = path.join(dir, "invalid-root.pem");
|
|
await writeFile(manifestPath, manifest(rootPemPath), { mode: 0o600 });
|
|
await writeFile(rootPemPath, await peerCertificatePem(edge.candidateUrl, edge.rootPem), { mode: 0o600 });
|
|
await assert.rejects(stageTrustedEnterpriseTlsRoot(manifestPath, [uid]), /certificate is not a CA/u);
|
|
await writeFile(rootPemPath, "not a certificate\n", { mode: 0o600 });
|
|
await assert.rejects(stageTrustedEnterpriseTlsRoot(manifestPath, [uid]), { name: "ENTERPRISE_TLS_ROOT_PEM_UNTRUSTED" });
|
|
});
|
|
|
|
await t.test("rejects a relative rootPemPath", async () => {
|
|
const manifestPath = path.join(dir, "relative-manifest.json");
|
|
await writeFile(manifestPath, manifest("relative-root.pem"), { mode: 0o600 });
|
|
await assert.rejects(stageTrustedEnterpriseTlsRoot(manifestPath, [uid]), /rootPemPath must be absolute/u);
|
|
});
|
|
|
|
await t.test("rejects an owner outside the explicit allowed uid set", async () => {
|
|
const rootPemPath = path.join(dir, "owner-root.pem");
|
|
const manifestPath = path.join(dir, "owner-manifest.json");
|
|
await writeFile(rootPemPath, edge.rootPem, { mode: 0o600 });
|
|
await writeFile(manifestPath, manifest(rootPemPath), { mode: 0o600 });
|
|
await assert.rejects(stageTrustedEnterpriseTlsRoot(manifestPath, [uid + 1]), /owner uid .* is not allowed/u);
|
|
});
|
|
} finally {
|
|
await edge.stop();
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("enterprise TLS edge pins its upstream and exposes selective trust", async () => {
|
|
const upstreamRequests: { method: string; url: string; body: string }[] = [];
|
|
const upstream = http.createServer(async (incoming, response) => {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of incoming) chunks.push(Buffer.from(chunk));
|
|
upstreamRequests.push({ method: incoming.method ?? "GET", url: incoming.url ?? "/", body: Buffer.concat(chunks).toString("utf8") });
|
|
response.end("from-pinned-upstream");
|
|
});
|
|
const attackerHits: string[] = [];
|
|
const attacker = http.createServer((incoming, response) => {
|
|
attackerHits.push(incoming.url ?? "/");
|
|
response.end("attacker");
|
|
});
|
|
const upstreamPort = await listen(upstream);
|
|
const attackerPort = await listen(attacker);
|
|
const edge = await startEnterpriseTlsReverseEdge({ upstream: `http://127.0.0.1:${upstreamPort}` });
|
|
const rootPath = edge.rootPemPath;
|
|
try {
|
|
await assert.rejects(request(`${edge.candidateUrl}/default-trust`));
|
|
assert.equal(await request(`${edge.candidateUrl}/v1/me?full=1`, { ca: edge.rootPem, method: "POST", body: "payload" }), "from-pinned-upstream");
|
|
await assert.rejects(request(`${edge.negativeUrl}/negative`, { ca: edge.rootPem }));
|
|
|
|
const absoluteTarget = `http://127.0.0.1:${attackerPort}/stolen?token=yes`;
|
|
assert.equal(await request(edge.candidateUrl, { ca: edge.rootPem, path: absoluteTarget }), "from-pinned-upstream");
|
|
assert.deepEqual(attackerHits, []);
|
|
assert.deepEqual(upstreamRequests, [
|
|
{ method: "POST", url: "/v1/me?full=1", body: "payload" },
|
|
{ method: "GET", url: "/stolen?token=yes", body: "" },
|
|
]);
|
|
assert.deepEqual(edge.requests.map(({ endpoint, method, path, body }) => ({ endpoint, method, path, body })), [
|
|
{ endpoint: "trusted-candidate", method: "POST", path: "/v1/me?full=1", body: "payload" },
|
|
{ endpoint: "trusted-candidate", method: "GET", path: "/stolen?token=yes", body: "" },
|
|
]);
|
|
assert.equal(edge.linuxTrust.restartApplication, true);
|
|
assert.equal(edge.linuxTrust.install()[1]?.file, "/usr/sbin/update-ca-certificates");
|
|
assert.equal(edge.linuxTrust.prerequisiteFailures.root, "ENTERPRISE_TLS_LINUX_ROOT_REQUIRED");
|
|
const prerequisites = await edge.linuxTrust.checkPrerequisites();
|
|
if (!prerequisites.ok) {
|
|
assert.ok([
|
|
"ENTERPRISE_TLS_LINUX_ROOT_REQUIRED",
|
|
"ENTERPRISE_TLS_UPDATE_CA_CERTIFICATES_REQUIRED",
|
|
].includes(prerequisites.failure));
|
|
}
|
|
} finally {
|
|
await edge.stop();
|
|
await Promise.all([close(upstream), close(attacker)]);
|
|
}
|
|
await assert.rejects(access(rootPath));
|
|
await assert.rejects(request(edge.candidateUrl, { ca: edge.rootPem }));
|
|
});
|