diff --git a/tests/e2e/functional/mtls.spec.ts b/tests/e2e/functional/mtls.spec.ts new file mode 100644 index 00000000..db8b8fa9 --- /dev/null +++ b/tests/e2e/functional/mtls.spec.ts @@ -0,0 +1,232 @@ +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>): 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))); + }); +}); diff --git a/tests/helpers/certs.ts b/tests/helpers/certs.ts new file mode 100644 index 00000000..2b885b84 --- /dev/null +++ b/tests/helpers/certs.ts @@ -0,0 +1,91 @@ +import forge from 'node-forge'; + +export interface GeneratedCertificate { + certificatePem: string; + privateKeyPem: string; +} + +export interface Pkcs12Identity { + certificatePem: string; + privateKeyPem: string; +} + +function randomSerialNumber(): string { + const bytes = forge.random.getBytesSync(16).split(''); + const firstByte = bytes[0]?.charCodeAt(0) ?? 1; + bytes[0] = String.fromCharCode(firstByte & 0x7f || 1); + return forge.util.bytesToHex(bytes.join('')); +} + +export function createSelfSignedServerCertificate( + commonName: string, + altNames: string[], + validityDays = 30 +): GeneratedCertificate { + const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048 }); + const cert = forge.pki.createCertificate(); + + cert.publicKey = keypair.publicKey; + cert.serialNumber = randomSerialNumber(); + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + validityDays); + + const subject = [ + { name: 'commonName', value: commonName }, + { name: 'organizationName', value: 'Caddy Proxy Manager E2E' }, + ]; + + cert.setSubject(subject); + cert.setIssuer(subject); + cert.setExtensions([ + { name: 'basicConstraints', cA: false }, + { name: 'keyUsage', digitalSignature: true, keyEncipherment: true, critical: true }, + { name: 'extKeyUsage', serverAuth: true }, + { + name: 'subjectAltName', + altNames: altNames.map((value) => ({ type: 2, value })), + }, + ]); + + cert.sign(keypair.privateKey, forge.md.sha256.create()); + + return { + certificatePem: forge.pki.certificateToPem(cert), + privateKeyPem: forge.pki.privateKeyToPem(keypair.privateKey), + }; +} + +export function parsePkcs12Identity(bundle: Buffer, password: string): Pkcs12Identity { + const der = forge.util.createBuffer(bundle.toString('binary')); + const p12Asn1 = forge.asn1.fromDer(der); + const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password); + + const keyBags = p12.getBags({ + bagType: forge.pki.oids.pkcs8ShroudedKeyBag, + })[forge.pki.oids.pkcs8ShroudedKeyBag] ?? []; + const key = keyBags[0]?.key; + if (!key) { + throw new Error('PKCS#12 bundle did not contain a private key'); + } + + const certBags = p12.getBags({ + bagType: forge.pki.oids.certBag, + })[forge.pki.oids.certBag] ?? []; + const certBag = certBags.find((bag) => { + const extKeyUsage = bag.cert?.getExtension('extKeyUsage'); + const basicConstraints = bag.cert?.getExtension('basicConstraints'); + const isClientCert = Boolean(extKeyUsage && 'clientAuth' in extKeyUsage && extKeyUsage.clientAuth); + const isCa = Boolean(basicConstraints && 'cA' in basicConstraints && basicConstraints.cA); + return isClientCert || !isCa; + }) ?? certBags[0]; + + if (!certBag?.cert) { + throw new Error('PKCS#12 bundle did not contain a certificate'); + } + + return { + certificatePem: forge.pki.certificateToPem(certBag.cert), + privateKeyPem: forge.pki.privateKeyToPem(key), + }; +} diff --git a/tests/helpers/https.ts b/tests/helpers/https.ts new file mode 100644 index 00000000..d438279d --- /dev/null +++ b/tests/helpers/https.ts @@ -0,0 +1,101 @@ +import https from 'node:https'; + +export interface HttpsResponse { + status: number; + headers: Record; + body: string; +} + +export interface ClientTlsIdentity { + cert?: string; + key?: string; +} + +export interface HttpsOutcome { + response?: HttpsResponse; + error?: Error; +} + +export function httpsGet( + domain: string, + path = '/', + tlsIdentity: ClientTlsIdentity = {}, + extraHeaders: Record = {} +): Promise { + return new Promise((resolve, reject) => { + const req = https.request( + { + hostname: '127.0.0.1', + port: 443, + path, + method: 'GET', + headers: { Host: domain, ...extraHeaders }, + servername: domain, + rejectUnauthorized: false, + cert: tlsIdentity.cert, + key: tlsIdentity.key, + }, + (res) => { + let body = ''; + res.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers as HttpsResponse['headers'], + body, + }) + ); + } + ); + + req.setTimeout(10_000, () => { + req.destroy(new Error(`HTTPS request to "${domain}" timed out`)); + }); + req.on('error', reject); + req.end(); + }); +} + +export async function httpsGetOutcome( + domain: string, + path = '/', + tlsIdentity: ClientTlsIdentity = {}, + extraHeaders: Record = {} +): Promise { + try { + const response = await httpsGet(domain, path, tlsIdentity, extraHeaders); + return { response }; + } catch (error) { + return { error: error instanceof Error ? error : new Error(String(error)) }; + } +} + +export async function waitForHttpsRoute( + domain: string, + tlsIdentity: ClientTlsIdentity = {}, + timeoutMs = 20_000 +): Promise { + const deadline = Date.now() + timeoutMs; + let lastStatus = 0; + let lastError = ''; + + while (Date.now() < deadline) { + try { + const res = await httpsGet(domain, '/', tlsIdentity); + lastStatus = res.status; + if (res.status !== 502 && res.status !== 503 && res.status !== 504) { + return; + } + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + throw new Error( + `HTTPS route for "${domain}" not ready after ${timeoutMs}ms (last status: ${lastStatus}, last error: ${lastError || 'none'})` + ); +} diff --git a/tests/helpers/proxy-api.ts b/tests/helpers/proxy-api.ts index 092643c5..17515077 100644 --- a/tests/helpers/proxy-api.ts +++ b/tests/helpers/proxy-api.ts @@ -6,7 +6,8 @@ * global storageState) so they integrate cleanly with the standard * `page` test fixture. */ -import { expect, type Page } from '@playwright/test'; +import { expect, type Download, type Page } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; import { injectFormFields } from './http'; export interface ProxyHostConfig { @@ -14,9 +15,44 @@ export interface ProxyHostConfig { domain: string; upstream: string; // e.g. "echo-server:8080" accessListName?: string; // name of an existing access list to attach + certificateName?: string; + mtlsCaNames?: string[]; enableWaf?: boolean; // enable WAF with OWASP CRS in blocking mode } +export interface ImportedCertificateConfig { + name: string; + domains: string[]; + certificatePem: string; + privateKeyPem: string; +} + +export interface GeneratedCaConfig { + name: string; + commonName?: string; + validityDays?: number; +} + +export interface IssuedClientCertificateConfig { + caName: string; + commonName: string; + exportPassword: string; + validityDays?: number; + compatibilityMode?: boolean; +} + +async function openCertificatesTab(page: Page, tabName: RegExp): Promise { + await page.goto('/certificates'); + await page.getByRole('tab', { name: tabName }).click(); +} + +async function expandCaRow(page: Page, caName: string): Promise { + const row = page.locator('tr').filter({ hasText: caName }).first(); + await expect(row).toBeVisible({ timeout: 10_000 }); + await row.locator('button').first().click(); + await expect(page.getByText(/issued client certificates/i)).toBeVisible({ timeout: 10_000 }); +} + /** * Create a proxy host via the browser UI. * ssl_forced is always set to false so functional tests can use plain HTTP. @@ -39,12 +75,54 @@ export async function createProxyHost(page: Page, config: ProxyHostConfig): Prom await page.getByPlaceholder('10.0.0.5:8080').nth(i).fill(upstreamList[i]); } + if (config.certificateName) { + await page.getByRole('combobox', { name: /certificate/i }).click(); + await page.getByRole('option', { name: config.certificateName, exact: true }).click(); + } + if (config.accessListName) { // MUI TextField select — click to open dropdown, then pick the option await page.getByRole('combobox', { name: /access list/i }).click(); await page.getByRole('option', { name: config.accessListName }).click(); } + if (config.mtlsCaNames?.length) { + await page.evaluate(() => { + const form = document.getElementById('create-host-form'); + const mtlsHidden = form?.querySelector('input[name="mtls_enabled"]'); + const section = mtlsHidden?.parentElement; + const switchInput = section?.querySelector('input[type="checkbox"]'); + if (!form || !mtlsHidden || !section || !switchInput) { + throw new Error('mTLS section not found in create host dialog'); + } + switchInput.click(); + }); + + await expect(page.getByText(/trusted client ca certificates/i)).toBeVisible({ timeout: 10_000 }); + await page.evaluate((caNames: string[]) => { + const form = document.getElementById('create-host-form'); + const mtlsHidden = form?.querySelector('input[name="mtls_enabled"]'); + const section = mtlsHidden?.parentElement; + if (!form || !mtlsHidden || !section) { + throw new Error('mTLS section not found in create host dialog'); + } + + for (const caName of caNames) { + const label = Array.from(section.querySelectorAll('label')).find((el) => + el.textContent?.includes(caName) + ); + const input = label?.querySelector('input[type="checkbox"]'); + if (!label || !input) { + throw new Error(`mTLS CA checkbox not found for "${caName}"`); + } + if (!input.checked) { + input.click(); + } + } + }, config.mtlsCaNames); + await expect(page.locator('input[name="mtls_ca_cert_id"]')).toHaveCount(config.mtlsCaNames.length); + } + // Inject hidden fields: // ssl_forced_present=on → tells the action the field was in the form // (ssl_forced absent) → parseCheckbox(null) = false → no HTTPS redirect @@ -67,6 +145,97 @@ export async function createProxyHost(page: Page, config: ProxyHostConfig): Prom await expect(page.getByText(config.name)).toBeVisible({ timeout: 10_000 }); } +export async function importCertificate(page: Page, config: ImportedCertificateConfig): Promise { + await openCertificatesTab(page, /^Imported \(/i); + await page.getByRole('button', { name: /import certificate/i }).click(); + await expect(page.getByRole('heading', { name: /^import certificate$/i })).toBeVisible(); + + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(config.name); + await page.getByLabel(/domains \(one per line\)/i).fill(config.domains.join('\n')); + await page.locator('[name="certificate_pem"]').fill(config.certificatePem); + await page.getByRole('button', { name: /show/i }).click(); + await page.locator('[name="private_key_pem"]').fill(config.privateKeyPem); + await page.getByRole('button', { name: /^import certificate$/i }).click(); + + await expect(page.getByText(config.name)).toBeVisible({ timeout: 10_000 }); +} + +export async function generateCaCertificate(page: Page, config: GeneratedCaConfig): Promise { + await openCertificatesTab(page, /^CA \/ mTLS \(/i); + await page.getByRole('button', { name: /add ca certificate/i }).click(); + await expect(page.getByRole('heading', { name: /^add ca certificate$/i })).toBeVisible(); + + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(config.name); + if (config.commonName) { + await page.getByRole('textbox', { name: 'Common Name (CN)', exact: true }).fill(config.commonName); + } + if (config.validityDays !== undefined) { + await page.getByRole('spinbutton', { name: 'Validity', exact: true }).fill(String(config.validityDays)); + } + + await page.getByRole('button', { name: /generate ca certificate/i }).click(); + await expect(page.getByText(config.name)).toBeVisible({ timeout: 15_000 }); +} + +export async function issueClientCertificate( + page: Page, + config: IssuedClientCertificateConfig +): Promise { + await openCertificatesTab(page, /^CA \/ mTLS \(/i); + await expandCaRow(page, config.caName); + await page.getByRole('button', { name: /^issue cert$/i }).click(); + await expect(page.getByRole('dialog', { name: /issue client certificate/i })).toBeVisible(); + + await page.getByRole('textbox', { name: 'Common Name (CN)', exact: true }).fill(config.commonName); + if (config.validityDays !== undefined) { + await page.getByRole('spinbutton', { name: 'Validity', exact: true }).fill(String(config.validityDays)); + } + await page.getByLabel(/export password/i).fill(config.exportPassword); + + const shouldBeChecked = config.compatibilityMode ?? true; + if (!shouldBeChecked) { + const compatibilityToggle = page.locator('input[name="compatibility_mode"]').first(); + await compatibilityToggle.click({ force: true }); + } + + await page.getByRole('button', { name: /issue certificate/i }).click(); + await expect(page.getByRole('button', { name: /download client certificate/i })).toBeVisible({ timeout: 15_000 }); + + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: /download client certificate/i }).click(); + const download = await downloadPromise; + const downloadPath = await saveDownload(download); + + await page.getByRole('button', { name: /^done$/i }).click(); + await expect(page.getByRole('dialog', { name: /issue client certificate/i })).not.toBeVisible({ timeout: 10_000 }); + + return readFile(downloadPath); +} + +export async function revokeIssuedClientCertificate(page: Page, caName: string, commonName: string): Promise { + await openCertificatesTab(page, /^CA \/ mTLS \(/i); + await expandCaRow(page, caName); + await page.getByRole('button', { name: /^manage$/i }).click(); + const dialog = page.getByRole('dialog', { name: /issued client certificates/i }); + await expect(dialog).toBeVisible(); + + const card = dialog + .getByRole('heading', { name: commonName, exact: true }) + .locator('xpath=ancestor::div[.//button[normalize-space()="Revoke"]][1]'); + await expect(card).toBeVisible({ timeout: 10_000 }); + await card.getByRole('button', { name: /^revoke$/i }).click(); + await expect(dialog.getByRole('heading', { name: commonName, exact: true })).toHaveCount(0, { timeout: 15_000 }); + await page.getByRole('button', { name: /^close$/i }).click(); +} + +async function saveDownload(download: Download): Promise { + const downloadPath = await download.path(); + if (!downloadPath) { + throw new Error('Playwright download did not produce a local file path'); + } + return downloadPath; +} + export interface AccessListUser { username: string; password: string;