Files
akanealw 99819b70ff
Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
added caddy-proxy-manager for testing
2026-04-21 22:49:08 +00:00

233 lines
8.0 KiB
TypeScript
Executable File

import { test, expect } from '@playwright/test';
import { createSelfSignedServerCertificate, parsePkcs12Identity, type Pkcs12Identity } from '../../helpers/certs';
import { httpsGet, httpsGetOutcome, type ClientTlsIdentity, waitForHttpsRoute } from '../../helpers/https';
import {
createProxyHost,
generateCaCertificate,
importCertificate,
issueClientCertificate,
revokeIssuedClientCertificate,
} from '../../helpers/proxy-api';
const PREFIX = `func-mtls-${Date.now()}`;
const SERVER_CERT_NAME = `${PREFIX}-server-cert`;
const CA_A_NAME = `${PREFIX}-ca-a`;
const CA_B_NAME = `${PREFIX}-ca-b`;
const CA_C_NAME = `${PREFIX}-ca-c`;
const CLIENT_A_CN = `${PREFIX}-client-a`;
const CLIENT_B_CN = `${PREFIX}-client-b`;
const REVOKED_CLIENT_CN = `${PREFIX}-client-revoked`;
const LONE_CLIENT_CN = `${PREFIX}-client-lone`;
const BUNDLE_PASSWORD = 'TestBundlePassword2026!';
const ECHO_BODY = 'echo-ok';
const ALLOW_DOMAIN = `${PREFIX}-allow.test`;
const MULTI_CA_DOMAIN = `${PREFIX}-multi.test`;
const APP_DOMAIN = `${PREFIX}-app.test`;
const API_DOMAIN = `${PREFIX}-api.test`;
const REVOKED_DOMAIN = `${PREFIX}-revoked.test`;
const ALL_REVOKED_DOMAIN = `${PREFIX}-all-revoked.test`;
let clientA: Pkcs12Identity;
let clientB: Pkcs12Identity;
let revokedClient: Pkcs12Identity;
let loneClient: Pkcs12Identity;
function tlsIdentity(identity: Pkcs12Identity): ClientTlsIdentity {
return {
cert: identity.certificatePem,
key: identity.privateKeyPem,
};
}
function expectMtlsBlocked(outcome: Awaited<ReturnType<typeof httpsGetOutcome>>): void {
if (outcome.response) {
expect(outcome.response.status).not.toBe(200);
return;
}
expect(outcome.error).toBeDefined();
}
test.describe.serial('mTLS HTTPS enforcement', () => {
test.setTimeout(180_000);
test('setup: import server certs, generate CAs, issue clients, and create mTLS hosts', async ({ page }) => {
const serverCert = createSelfSignedServerCertificate(ALLOW_DOMAIN, [
ALLOW_DOMAIN,
MULTI_CA_DOMAIN,
APP_DOMAIN,
API_DOMAIN,
REVOKED_DOMAIN,
ALL_REVOKED_DOMAIN,
]);
await importCertificate(page, {
name: SERVER_CERT_NAME,
domains: [
ALLOW_DOMAIN,
MULTI_CA_DOMAIN,
APP_DOMAIN,
API_DOMAIN,
REVOKED_DOMAIN,
ALL_REVOKED_DOMAIN,
],
certificatePem: serverCert.certificatePem,
privateKeyPem: serverCert.privateKeyPem,
});
await generateCaCertificate(page, { name: CA_A_NAME, commonName: `${CA_A_NAME} Root` });
await generateCaCertificate(page, { name: CA_B_NAME, commonName: `${CA_B_NAME} Root` });
await generateCaCertificate(page, { name: CA_C_NAME, commonName: `${CA_C_NAME} Root` });
clientA = parsePkcs12Identity(
await issueClientCertificate(page, {
caName: CA_A_NAME,
commonName: CLIENT_A_CN,
exportPassword: BUNDLE_PASSWORD,
}),
BUNDLE_PASSWORD
);
clientB = parsePkcs12Identity(
await issueClientCertificate(page, {
caName: CA_B_NAME,
commonName: CLIENT_B_CN,
exportPassword: BUNDLE_PASSWORD,
}),
BUNDLE_PASSWORD
);
revokedClient = parsePkcs12Identity(
await issueClientCertificate(page, {
caName: CA_A_NAME,
commonName: REVOKED_CLIENT_CN,
exportPassword: BUNDLE_PASSWORD,
}),
BUNDLE_PASSWORD
);
loneClient = parsePkcs12Identity(
await issueClientCertificate(page, {
caName: CA_C_NAME,
commonName: LONE_CLIENT_CN,
exportPassword: BUNDLE_PASSWORD,
}),
BUNDLE_PASSWORD
);
await createProxyHost(page, {
name: `${PREFIX} Allow Host`,
domain: ALLOW_DOMAIN,
upstream: 'echo-server:8080',
certificateName: SERVER_CERT_NAME,
mtlsCaNames: [CA_A_NAME],
});
await createProxyHost(page, {
name: `${PREFIX} Multi-CA Host`,
domain: MULTI_CA_DOMAIN,
upstream: 'echo-server:8080',
certificateName: SERVER_CERT_NAME,
mtlsCaNames: [CA_A_NAME, CA_B_NAME],
});
await createProxyHost(page, {
name: `${PREFIX} App Host`,
domain: APP_DOMAIN,
upstream: 'echo-server:8080',
certificateName: SERVER_CERT_NAME,
mtlsCaNames: [CA_A_NAME],
});
await createProxyHost(page, {
name: `${PREFIX} API Host`,
domain: API_DOMAIN,
upstream: 'echo-server:8080',
certificateName: SERVER_CERT_NAME,
mtlsCaNames: [CA_B_NAME],
});
await createProxyHost(page, {
name: `${PREFIX} Revoked Host`,
domain: REVOKED_DOMAIN,
upstream: 'echo-server:8080',
certificateName: SERVER_CERT_NAME,
mtlsCaNames: [CA_A_NAME],
});
await createProxyHost(page, {
name: `${PREFIX} All Revoked Host`,
domain: ALL_REVOKED_DOMAIN,
upstream: 'echo-server:8080',
certificateName: SERVER_CERT_NAME,
mtlsCaNames: [CA_C_NAME],
});
await waitForHttpsRoute(ALLOW_DOMAIN, tlsIdentity(clientA));
await waitForHttpsRoute(MULTI_CA_DOMAIN, tlsIdentity(clientB));
await waitForHttpsRoute(APP_DOMAIN, tlsIdentity(clientA));
await waitForHttpsRoute(API_DOMAIN, tlsIdentity(clientB));
await waitForHttpsRoute(REVOKED_DOMAIN, tlsIdentity(revokedClient));
await waitForHttpsRoute(ALL_REVOKED_DOMAIN, tlsIdentity(loneClient));
});
test('blocks HTTPS requests when no client certificate is presented', async () => {
expectMtlsBlocked(await httpsGetOutcome(ALLOW_DOMAIN));
});
test('allows HTTPS requests with a client certificate signed by the configured CA', async () => {
const response = await httpsGet(ALLOW_DOMAIN, '/', tlsIdentity(clientA));
expect(response.status).toBe(200);
expect(response.body).toContain(ECHO_BODY);
});
test('blocks client certificates signed by the wrong CA', async () => {
expectMtlsBlocked(await httpsGetOutcome(ALLOW_DOMAIN, '/', tlsIdentity(clientB)));
});
test('accepts client certificates from either trusted CA on a multi-CA host', async () => {
const responseFromA = await httpsGet(MULTI_CA_DOMAIN, '/', tlsIdentity(clientA));
const responseFromB = await httpsGet(MULTI_CA_DOMAIN, '/', tlsIdentity(clientB));
expect(responseFromA.status).toBe(200);
expect(responseFromA.body).toContain(ECHO_BODY);
expect(responseFromB.status).toBe(200);
expect(responseFromB.body).toContain(ECHO_BODY);
});
test('isolates per-host CA trust even when hosts share the same imported server certificate', async () => {
const appResponse = await httpsGet(APP_DOMAIN, '/', tlsIdentity(clientA));
const apiResponse = await httpsGet(API_DOMAIN, '/', tlsIdentity(clientB));
expect(appResponse.status).toBe(200);
expect(apiResponse.status).toBe(200);
expectMtlsBlocked(await httpsGetOutcome(APP_DOMAIN, '/', tlsIdentity(clientB)));
expectMtlsBlocked(await httpsGetOutcome(API_DOMAIN, '/', tlsIdentity(clientA)));
});
test('revokes a tracked client certificate and blocks it while leaving other active certs usable', async ({ page }) => {
const beforeRevocation = await httpsGet(REVOKED_DOMAIN, '/', tlsIdentity(revokedClient));
expect(beforeRevocation.status).toBe(200);
await revokeIssuedClientCertificate(page, CA_A_NAME, REVOKED_CLIENT_CN);
await waitForHttpsRoute(ALLOW_DOMAIN, tlsIdentity(clientA));
expectMtlsBlocked(await httpsGetOutcome(REVOKED_DOMAIN, '/', tlsIdentity(revokedClient)));
const stillActive = await httpsGet(REVOKED_DOMAIN, '/', tlsIdentity(clientA));
expect(stillActive.status).toBe(200);
expect(stillActive.body).toContain(ECHO_BODY);
});
test('fails closed when the only issued client certificate for a CA is revoked', async ({ page }) => {
const beforeRevocation = await httpsGet(ALL_REVOKED_DOMAIN, '/', tlsIdentity(loneClient));
expect(beforeRevocation.status).toBe(200);
await revokeIssuedClientCertificate(page, CA_C_NAME, LONE_CLIENT_CN);
await waitForHttpsRoute(ALLOW_DOMAIN, tlsIdentity(clientA));
expectMtlsBlocked(await httpsGetOutcome(ALL_REVOKED_DOMAIN));
expectMtlsBlocked(await httpsGetOutcome(ALL_REVOKED_DOMAIN, '/', tlsIdentity(loneClient)));
});
});