fix mTLS cross-CA isolation bug, add instance-sync and mTLS tests

Extract pemToBase64Der and buildClientAuthentication from caddy.ts into
a new caddy-mtls.ts module, adding groupMtlsDomainsByCaSet to group mTLS
domains by their CA fingerprint before building TLS connection policies.

Previously all mTLS domains sharing a cert type (auto-managed, imported,
or managed) were grouped into a single policy, causing CA union: a client
cert from CA_B could authenticate against a host that only trusted CA_A.
The fix creates one policy per unique CA set, ensuring strict per-host
CA isolation across all three TLS policy code paths.

Also adds:
- tests/unit/caddy-mtls.test.ts (26 tests) covering pemToBase64Der,
  buildClientAuthentication, groupMtlsDomainsByCaSet, and cross-CA
  isolation regression tests
- tests/unit/instance-sync-env.test.ts (33 tests) for the five pure
  env-reading functions in instance-sync.ts
- tests/integration/instance-sync.test.ts (16 tests) for
  buildSyncPayload and applySyncPayload using an in-memory SQLite db
- Fix tests/helpers/db.ts to use a relative import for db/schema so it
  works inside vi.mock factory dynamic imports

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-07 18:32:52 +01:00
parent e5ba3e1ed9
commit fd847e7eb5
6 changed files with 1238 additions and 87 deletions
+110
View File
@@ -0,0 +1,110 @@
/**
* mTLS helper functions for building Caddy TLS connection policies.
*
* Extracted from caddy.ts so they can be unit-tested independently.
*/
/**
* Converts a PEM certificate to base64-encoded DER format expected by Caddy's
* `trusted_ca_certs` and `trusted_leaf_certs` fields.
*/
export function pemToBase64Der(pem: string): string {
return pem
.replace(/-----BEGIN CERTIFICATE-----/, "")
.replace(/-----END CERTIFICATE-----/, "")
.replace(/\s+/g, "");
}
/**
* Builds a Caddy `client_authentication` block for the given list of domains.
*
* All CA cert IDs referenced by those domains are unioned into one set, which is
* intentional when every domain in the list shares the same CA configuration.
* Callers must ensure that `domains` is pre-grouped so domains with different CA
* sets are passed in separate calls — see `groupMtlsDomainsByCaSet`.
*
* Strategy per CA:
* - Unmanaged CA (no tracked issued certs): trust any cert signed by that CA.
* - Managed CA with active certs: CA in `trusted_ca_certs` + active leaf certs
* in `trusted_leaf_certs` (revocation enforcement).
* - Managed CA with ALL certs revoked: excluded entirely (chain validation fails).
*
* Returns null if there are no CA certs to trust (all excluded or none configured).
*/
export function buildClientAuthentication(
domains: string[],
mTlsDomainMap: Map<string, number[]>,
caCertMap: Map<number, { id: number; certificatePem: string }>,
issuedClientCertMap: Map<number, string[]>,
cAsWithAnyIssuedCerts: Set<number>
): Record<string, unknown> | null {
const caCertIds = new Set<number>();
for (const domain of domains) {
const ids = mTlsDomainMap.get(domain.toLowerCase());
if (ids) {
for (const id of ids) caCertIds.add(id);
}
}
if (caCertIds.size === 0) return null;
const trustedCaCerts: string[] = [];
const trustedLeafCerts: string[] = [];
for (const id of caCertIds) {
const ca = caCertMap.get(id);
if (!ca) continue;
if (cAsWithAnyIssuedCerts.has(id)) {
// Managed CA: enforce revocation via leaf pinning
const activeLeafCerts = issuedClientCertMap.get(id) ?? [];
if (activeLeafCerts.length === 0) {
// All certs revoked: exclude CA so chain validation fails for its certs
continue;
}
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
for (const certPem of activeLeafCerts) {
trustedLeafCerts.push(pemToBase64Der(certPem));
}
} else {
// Unmanaged CA: trust any cert in the chain
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
}
}
if (trustedCaCerts.length === 0) return null;
const result: Record<string, unknown> = {
mode: "require_and_verify",
trusted_ca_certs: trustedCaCerts,
};
if (trustedLeafCerts.length > 0) result.trusted_leaf_certs = trustedLeafCerts;
return result;
}
/**
* Groups mTLS domains by their sorted CA ID fingerprint so that each group can
* get its own TLS connection policy with the correct, isolated set of trusted CAs.
*
* Domains with the same set of CA IDs (regardless of order) are placed in the
* same group. Domains with different CA sets end up in separate groups, ensuring
* a client certificate from CA_B cannot authenticate against a host that only
* configured CA_A.
*
* @param domains - List of domain names that have mTLS configured.
* @param mTlsDomainMap - Map from lowercased domain to its list of CA cert IDs.
* @returns Map from CA-set fingerprint string to the list of domains sharing it.
*/
export function groupMtlsDomainsByCaSet(
domains: string[],
mTlsDomainMap: Map<string, number[]>
): Map<string, string[]> {
const groups = new Map<string, string[]>();
for (const domain of domains) {
const ids = mTlsDomainMap.get(domain.toLowerCase()) ?? [];
const key = [...ids].sort((a, b) => a - b).join(",");
const group = groups.get(key) ?? [];
group.push(domain);
groups.set(key, group);
}
return groups;
}
+23 -86
View File
@@ -47,6 +47,7 @@ import {
proxyHosts
} from "./db/schema";
import { type GeoBlockMode, type WafHostConfig, type MtlsConfig } from "./models/proxy-hosts";
import { pemToBase64Der, buildClientAuthentication, groupMtlsDomainsByCaSet } from "./caddy-mtls";
const CERTS_DIR = process.env.CERTS_DIRECTORY || join(process.cwd(), "data", "certs");
mkdirSync(CERTS_DIR, { recursive: true, mode: 0o700 });
@@ -467,14 +468,6 @@ async function resolveUpstreamDials(
};
}
function pemToBase64Der(pem: string): string {
// Strip PEM headers/footers and whitespace — what remains is the base64-encoded DER
return pem
.replace(/-----BEGIN CERTIFICATE-----/, "")
.replace(/-----END CERTIFICATE-----/, "")
.replace(/\s+/g, "");
}
function collectCertificateUsage(rows: ProxyHostRow[], certificates: Map<number, CertificateRow>) {
const usage = new Map<number, CertificateUsage>();
const autoManagedDomains = new Set<string>();
@@ -1104,65 +1097,6 @@ async function buildProxyRoutes(
return routes;
}
function buildClientAuthentication(
domains: string[],
mTlsDomainMap: Map<string, number[]>,
caCertMap: Map<number, { id: number; certificatePem: string }>,
issuedClientCertMap: Map<number, string[]>,
cAsWithAnyIssuedCerts: Set<number>
): Record<string, unknown> | null {
// Collect all CA cert IDs for any domain in this policy that has mTLS.
const caCertIds = new Set<number>();
for (const domain of domains) {
const ids = mTlsDomainMap.get(domain.toLowerCase());
if (ids) {
for (const id of ids) caCertIds.add(id);
}
}
if (caCertIds.size === 0) return null;
// trusted_leaf_certs is an ADDITIONAL check on top of CA chain validation
// (Caddy rejects without trusted_ca_certs even when trusted_leaf_certs is set).
//
// Strategy:
// - Unmanaged CA (no tracked issued certs): trust any cert signed by that CA
// → CA cert in trusted_ca_certs only.
// - Managed CA with active certs: trust only those specific leaf certs
// → CA cert in trusted_ca_certs (for chain validation) +
// active leaf certs in trusted_leaf_certs (revocation enforcement).
// - Managed CA with ALL certs revoked: exclude it from trusted_ca_certs so
// chain validation fails — no cert from it will be accepted.
const trustedCaCerts: string[] = [];
const trustedLeafCerts: string[] = [];
for (const id of caCertIds) {
const ca = caCertMap.get(id);
if (!ca) continue;
if (cAsWithAnyIssuedCerts.has(id)) {
// Managed CA: enforce revocation via leaf pinning
const activeLeafCerts = issuedClientCertMap.get(id) ?? [];
if (activeLeafCerts.length === 0) {
// All certs revoked: exclude CA so chain validation fails for its certs
continue;
}
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
for (const certPem of activeLeafCerts) {
trustedLeafCerts.push(pemToBase64Der(certPem));
}
} else {
// Unmanaged CA: trust any cert in the chain
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
}
}
if (trustedCaCerts.length === 0) return null;
const result: Record<string, unknown> = { mode: "require_and_verify", trusted_ca_certs: trustedCaCerts };
if (trustedLeafCerts.length > 0) result.trusted_leaf_certs = trustedLeafCerts;
return result;
}
function buildTlsConnectionPolicies(
usage: Map<number, CertificateUsage>,
managedCertificatesWithAutomation: Set<number>,
@@ -1179,6 +1113,25 @@ function buildTlsConnectionPolicies(
const buildAuth = (domains: string[]) =>
buildClientAuthentication(domains, mTlsDomainMap, caCertMap, issuedClientCertMap, cAsWithAnyIssuedCerts);
/**
* Pushes one TLS policy per unique CA set found in `mTlsDomains`.
* Domains that share the same CA configuration are grouped into one policy;
* domains with different CAs get separate policies so a cert from CA_B cannot
* authenticate against a host that only trusts CA_A.
*/
const pushMtlsPolicies = (mTlsDomains: string[]) => {
const groups = groupMtlsDomainsByCaSet(mTlsDomains, mTlsDomainMap);
for (const domainGroup of groups.values()) {
const mTlsAuth = buildAuth(domainGroup);
if (mTlsAuth) {
policies.push({ match: { sni: domainGroup }, client_authentication: mTlsAuth });
} else {
// All CAs have all certs revoked — drop connections rather than allow through without mTLS
policies.push({ match: { sni: domainGroup }, drop: true });
}
}
};
// Add policy for auto-managed domains (certificate_id = null)
if (autoManagedDomains.size > 0) {
const domains = Array.from(autoManagedDomains);
@@ -1187,13 +1140,7 @@ function buildTlsConnectionPolicies(
const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d));
if (mTlsDomains.length > 0) {
const mTlsAuth = buildAuth(mTlsDomains);
if (mTlsAuth) {
policies.push({ match: { sni: mTlsDomains }, client_authentication: mTlsAuth });
} else {
// All CAs have all certs revoked — drop connections rather than allow through without mTLS
policies.push({ match: { sni: mTlsDomains }, drop: true });
}
pushMtlsPolicies(mTlsDomains);
}
if (nonMTlsDomains.length > 0) {
policies.push({ match: { sni: nonMTlsDomains } });
@@ -1221,12 +1168,7 @@ function buildTlsConnectionPolicies(
const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d));
if (mTlsDomains.length > 0) {
const mTlsAuth = buildAuth(mTlsDomains);
if (mTlsAuth) {
policies.push({ match: { sni: mTlsDomains }, client_authentication: mTlsAuth });
} else {
policies.push({ match: { sni: mTlsDomains }, drop: true });
}
pushMtlsPolicies(mTlsDomains);
}
if (nonMTlsDomains.length > 0) {
policies.push({ match: { sni: nonMTlsDomains } });
@@ -1245,12 +1187,7 @@ function buildTlsConnectionPolicies(
const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d));
if (mTlsDomains.length > 0) {
const mTlsAuth = buildAuth(mTlsDomains);
if (mTlsAuth) {
policies.push({ match: { sni: mTlsDomains }, client_authentication: mTlsAuth });
} else {
policies.push({ match: { sni: mTlsDomains }, drop: true });
}
pushMtlsPolicies(mTlsDomains);
}
if (nonMTlsDomains.length > 0) {
policies.push({ match: { sni: nonMTlsDomains } });
+1 -1
View File
@@ -2,7 +2,7 @@ import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { resolve } from 'node:path';
import * as schema from '@/src/lib/db/schema';
import * as schema from '../../src/lib/db/schema';
const migrationsFolder = resolve(process.cwd(), 'drizzle');
+386
View File
@@ -0,0 +1,386 @@
/**
* Integration tests for buildSyncPayload and applySyncPayload
* in src/lib/instance-sync.ts.
*
* We mock src/lib/db.ts to inject a fresh migrated in-memory SQLite
* database, giving full control over table content without affecting
* any real db file.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { TestDb } from '../helpers/db';
// ---------------------------------------------------------------------------
// Mock src/lib/db — must be declared before any import that uses the db.
// vi.hoisted() creates the mutable container at hoist time so the vi.mock
// factory (which also runs during hoisting) can populate it safely.
// ---------------------------------------------------------------------------
const ctx = vi.hoisted(() => ({ db: null as unknown as TestDb }));
vi.mock('../../src/lib/db', async () => {
const { createTestDb } = await import('../helpers/db');
const schemaModule = await import('../../src/lib/db/schema');
ctx.db = createTestDb();
return {
default: ctx.db,
schema: schemaModule,
nowIso: () => new Date().toISOString(),
toIso: (value: string | Date | null | undefined): string | null => {
if (!value) return null;
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
},
};
});
// These imports must come AFTER vi.mock to pick up the mocked module.
import { buildSyncPayload, applySyncPayload } from '../../src/lib/instance-sync';
import * as schema from '../../src/lib/db/schema';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function nowIso() {
return new Date().toISOString();
}
/** A minimal proxy host record that satisfies the schema. */
function makeProxyHost(overrides: Partial<typeof schema.proxyHosts.$inferInsert> = {}) {
const now = nowIso();
return {
name: 'Test Host',
domains: JSON.stringify(['test.example.com']),
upstreams: JSON.stringify(['backend:8080']),
certificateId: null,
accessListId: null,
ownerUserId: null,
sslForced: false,
hstsEnabled: false,
hstsSubdomains: false,
allowWebsocket: false,
preserveHostHeader: false,
skipHttpsHostnameValidation: false,
meta: null,
enabled: true,
createdAt: now,
updatedAt: now,
...overrides,
} satisfies typeof schema.proxyHosts.$inferInsert;
}
/** Clean all relevant tables between tests to avoid cross-test contamination. */
async function clearTables() {
await ctx.db.delete(schema.proxyHosts);
await ctx.db.delete(schema.accessListEntries);
await ctx.db.delete(schema.accessLists);
await ctx.db.delete(schema.issuedClientCertificates);
await ctx.db.delete(schema.certificates);
await ctx.db.delete(schema.caCertificates);
await ctx.db.delete(schema.settings);
}
beforeEach(async () => {
await clearTables();
});
// ---------------------------------------------------------------------------
// buildSyncPayload
// ---------------------------------------------------------------------------
describe('buildSyncPayload', () => {
it('returns empty arrays when db has no rows', async () => {
const payload = await buildSyncPayload();
expect(payload.data.proxyHosts).toEqual([]);
expect(payload.data.certificates).toEqual([]);
expect(payload.data.caCertificates).toEqual([]);
expect(payload.data.issuedClientCertificates).toEqual([]);
expect(payload.data.accessLists).toEqual([]);
expect(payload.data.accessListEntries).toEqual([]);
});
it('returns null settings when no settings are stored', async () => {
const payload = await buildSyncPayload();
expect(payload.settings.general).toBeNull();
expect(payload.settings.cloudflare).toBeNull();
expect(payload.settings.authentik).toBeNull();
expect(payload.settings.dns).toBeNull();
expect(payload.settings.waf).toBeNull();
expect(payload.settings.geoblock).toBeNull();
});
it('includes generated_at as an ISO date string', async () => {
const before = Date.now();
const payload = await buildSyncPayload();
const after = Date.now();
const ts = new Date(payload.generated_at).getTime();
expect(ts).toBeGreaterThanOrEqual(before);
expect(ts).toBeLessThanOrEqual(after);
});
it('sanitizes proxy host ownerUserId to null', async () => {
// buildSyncPayload always spreads ...row then sets ownerUserId: null
await ctx.db.insert(schema.proxyHosts).values(makeProxyHost());
const payload = await buildSyncPayload();
expect(payload.data.proxyHosts).toHaveLength(1);
expect(payload.data.proxyHosts[0].ownerUserId).toBeNull();
});
it('includes proxy host data fields correctly', async () => {
await ctx.db.insert(schema.proxyHosts).values(
makeProxyHost({ name: 'My Host', domains: JSON.stringify(['myhost.example.com']) })
);
const payload = await buildSyncPayload();
expect(payload.data.proxyHosts[0].name).toBe('My Host');
expect(JSON.parse(payload.data.proxyHosts[0].domains)).toEqual(['myhost.example.com']);
});
it('sanitizes certificate createdBy to null', async () => {
const now = nowIso();
await ctx.db.insert(schema.certificates).values({
name: 'Test Cert',
type: 'managed',
domainNames: JSON.stringify(['cert.example.com']),
autoRenew: true,
providerOptions: null,
certificatePem: null,
privateKeyPem: null,
createdBy: null,
createdAt: now,
updatedAt: now,
});
const payload = await buildSyncPayload();
expect(payload.data.certificates).toHaveLength(1);
expect(payload.data.certificates[0].createdBy).toBeNull();
expect(payload.data.certificates[0].name).toBe('Test Cert');
});
it('sanitizes access list createdBy to null', async () => {
const now = nowIso();
await ctx.db.insert(schema.accessLists).values({
name: 'My List',
description: null,
createdBy: null,
createdAt: now,
updatedAt: now,
});
const payload = await buildSyncPayload();
expect(payload.data.accessLists).toHaveLength(1);
expect(payload.data.accessLists[0].createdBy).toBeNull();
expect(payload.data.accessLists[0].name).toBe('My List');
});
it('includes access list entries unchanged', async () => {
const now = nowIso();
const [list] = await ctx.db.insert(schema.accessLists).values({
name: 'List A',
description: null,
createdBy: null,
createdAt: now,
updatedAt: now,
}).returning();
await ctx.db.insert(schema.accessListEntries).values({
accessListId: list.id,
username: 'user1',
passwordHash: '$2b$10$fakehashhhhh',
createdAt: now,
updatedAt: now,
});
const payload = await buildSyncPayload();
expect(payload.data.accessListEntries).toHaveLength(1);
expect(payload.data.accessListEntries[0].username).toBe('user1');
});
});
// ---------------------------------------------------------------------------
// applySyncPayload
// ---------------------------------------------------------------------------
describe('applySyncPayload', () => {
/** Build a minimal valid payload (all data empty, all settings null). */
function emptyPayload() {
return {
generated_at: nowIso(),
settings: {
general: null,
cloudflare: null,
authentik: null,
metrics: null,
logging: null,
dns: null,
upstream_dns_resolution: null,
waf: null,
geoblock: null,
},
data: {
certificates: [] as any[],
caCertificates: [] as any[],
issuedClientCertificates: [] as any[],
accessLists: [] as any[],
accessListEntries: [] as any[],
proxyHosts: [] as any[],
},
};
}
it('runs without error on an empty payload', async () => {
await expect(applySyncPayload(emptyPayload() as any)).resolves.toBeUndefined();
});
it('clears existing proxy hosts when payload has empty array', async () => {
await ctx.db.insert(schema.proxyHosts).values(makeProxyHost({ name: 'Old Host' }));
const before = await ctx.db.select().from(schema.proxyHosts);
expect(before).toHaveLength(1);
await applySyncPayload(emptyPayload() as any);
const after = await ctx.db.select().from(schema.proxyHosts);
expect(after).toHaveLength(0);
});
it('inserts proxy hosts from payload', async () => {
const now = nowIso();
const payload = emptyPayload();
payload.data.proxyHosts = [
{
id: 1,
name: 'Synced Host',
domains: JSON.stringify(['synced.example.com']),
upstreams: JSON.stringify(['backend:8080']),
certificateId: null,
accessListId: null,
ownerUserId: null,
sslForced: false,
hstsEnabled: false,
hstsSubdomains: false,
allowWebsocket: false,
preserveHostHeader: false,
skipHttpsHostnameValidation: false,
meta: null,
enabled: true,
createdAt: now,
updatedAt: now,
},
];
await applySyncPayload(payload as any);
const rows = await ctx.db.select().from(schema.proxyHosts);
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('Synced Host');
expect(JSON.parse(rows[0].domains)).toEqual(['synced.example.com']);
});
it('replaces existing proxy hosts with payload contents (full override)', async () => {
await ctx.db.insert(schema.proxyHosts).values(makeProxyHost({ name: 'Old Host' }));
const now = nowIso();
const payload = emptyPayload();
payload.data.proxyHosts = [
{
id: 99,
name: 'New Host',
domains: JSON.stringify(['new.example.com']),
upstreams: JSON.stringify(['new-backend:9090']),
certificateId: null,
accessListId: null,
ownerUserId: null,
sslForced: false,
hstsEnabled: false,
hstsSubdomains: false,
allowWebsocket: false,
preserveHostHeader: false,
skipHttpsHostnameValidation: false,
meta: null,
enabled: true,
createdAt: now,
updatedAt: now,
},
];
await applySyncPayload(payload as any);
const rows = await ctx.db.select().from(schema.proxyHosts);
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('New Host');
});
it('is idempotent: applying the same payload twice gives the same result', async () => {
const now = nowIso();
const payload = emptyPayload();
payload.data.proxyHosts = [
{
id: 1,
name: 'Idempotent Host',
domains: JSON.stringify(['idempotent.example.com']),
upstreams: JSON.stringify(['backend:8080']),
certificateId: null,
accessListId: null,
ownerUserId: null,
sslForced: false,
hstsEnabled: false,
hstsSubdomains: false,
allowWebsocket: false,
preserveHostHeader: false,
skipHttpsHostnameValidation: false,
meta: null,
enabled: true,
createdAt: now,
updatedAt: now,
},
];
await applySyncPayload(payload as any);
await applySyncPayload(payload as any);
const rows = await ctx.db.select().from(schema.proxyHosts);
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('Idempotent Host');
});
it('stores settings with synced: prefix', async () => {
const payload = emptyPayload();
payload.settings.general = { primaryDomain: 'example.com' };
await applySyncPayload(payload as any);
const row = await ctx.db.query.settings.findFirst({
where: (t, { eq }) => eq(t.key, 'synced:general'),
});
expect(row).toBeDefined();
expect(JSON.parse(row!.value)).toEqual({ primaryDomain: 'example.com' });
});
it('stores null settings as JSON null value', async () => {
const payload = emptyPayload();
payload.settings.cloudflare = null;
await applySyncPayload(payload as any);
const row = await ctx.db.query.settings.findFirst({
where: (t, { eq }) => eq(t.key, 'synced:cloudflare'),
});
expect(row).toBeDefined();
expect(JSON.parse(row!.value)).toBeNull();
});
it('inserts access lists and entries from payload', async () => {
const now = nowIso();
const payload = emptyPayload();
payload.data.accessLists = [
{ id: 1, name: 'Synced List', description: null, createdBy: null, createdAt: now, updatedAt: now },
];
payload.data.accessListEntries = [
{ id: 1, accessListId: 1, username: 'synceduser', passwordHash: '$2b$10$fakehash', createdAt: now, updatedAt: now },
];
await applySyncPayload(payload as any);
const lists = await ctx.db.select().from(schema.accessLists);
expect(lists).toHaveLength(1);
expect(lists[0].name).toBe('Synced List');
const entries = await ctx.db.select().from(schema.accessListEntries);
expect(entries).toHaveLength(1);
expect(entries[0].username).toBe('synceduser');
});
});
+470
View File
@@ -0,0 +1,470 @@
/**
* Unit tests for src/lib/caddy-mtls.ts
*
* Covers:
* - pemToBase64Der: PEM stripping
* - buildClientAuthentication: CA trust configuration per domain set
* - groupMtlsDomainsByCaSet: isolation of CA sets per TLS policy
*
* The key bug these tests document and verify the fix for:
* If two proxy hosts (app.example.com → CA_A, api.example.com → CA_B) share an
* auto-managed TLS certificate, their mTLS domains must NOT be grouped into a
* single policy — otherwise a client cert signed by CA_B can authenticate against
* app.example.com (which only trusts CA_A) and vice-versa.
*/
import { describe, it, expect } from 'vitest';
import {
pemToBase64Der,
buildClientAuthentication,
groupMtlsDomainsByCaSet,
} from '../../src/lib/caddy-mtls';
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
function makeCaPem(label: string): string {
return `-----BEGIN CERTIFICATE-----\n${label}\n-----END CERTIFICATE-----`;
}
function makeCaCertMap(...entries: [number, string][]) {
return new Map(entries.map(([id, label]) => [id, { id, certificatePem: makeCaPem(label) }]));
}
// ---------------------------------------------------------------------------
// pemToBase64Der
// ---------------------------------------------------------------------------
describe('pemToBase64Der', () => {
it('strips PEM header and footer', () => {
const pem = '-----BEGIN CERTIFICATE-----\nABCDEFGH\n-----END CERTIFICATE-----';
expect(pemToBase64Der(pem)).toBe('ABCDEFGH');
});
it('strips all whitespace including newlines and spaces', () => {
const pem = '-----BEGIN CERTIFICATE-----\n ABCD \n EFGH \n-----END CERTIFICATE-----';
expect(pemToBase64Der(pem)).toBe('ABCDEFGH');
});
it('handles multi-line base64 content', () => {
const pem = '-----BEGIN CERTIFICATE-----\nAAAA\nBBBB\nCCCC\n-----END CERTIFICATE-----';
expect(pemToBase64Der(pem)).toBe('AAAABBBBCCCC');
});
it('returns only the base64 body without any whitespace', () => {
const result = pemToBase64Der(makeCaPem('CA_A'));
expect(result).toBe('CA_A');
expect(result).not.toMatch(/\s/);
expect(result).not.toContain('BEGIN');
expect(result).not.toContain('END');
});
});
// ---------------------------------------------------------------------------
// buildClientAuthentication
// ---------------------------------------------------------------------------
describe('buildClientAuthentication', () => {
it('returns null when no domains have mTLS config', () => {
const result = buildClientAuthentication(
['app.example.com'],
new Map(),
new Map(),
new Map(),
new Set()
);
expect(result).toBeNull();
});
it('returns null when domain references CA IDs that do not exist in caCertMap', () => {
const mTlsDomainMap = new Map([['app.example.com', [99]]]);
const result = buildClientAuthentication(
['app.example.com'],
mTlsDomainMap,
new Map(), // CA 99 not in map
new Map(),
new Set()
);
expect(result).toBeNull();
});
it('returns mode=require_and_verify and trusted_ca_certs for unmanaged CA', () => {
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
const caCertMap = makeCaCertMap([1, 'CA_A']);
const result = buildClientAuthentication(
['app.example.com'],
mTlsDomainMap,
caCertMap,
new Map(),
new Set() // CA 1 is not managed
);
expect(result).not.toBeNull();
expect(result!.mode).toBe('require_and_verify');
expect(result!.trusted_ca_certs).toEqual(['CA_A']);
expect(result!.trusted_leaf_certs).toBeUndefined();
});
it('excludes managed CA when all its issued certs are revoked (no active certs)', () => {
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
const caCertMap = makeCaCertMap([1, 'CA_A']);
const issuedClientCertMap = new Map([[1, []]]); // CA 1 managed, zero active certs
const cAsWithAnyIssuedCerts = new Set([1]);
const result = buildClientAuthentication(
['app.example.com'],
mTlsDomainMap,
caCertMap,
issuedClientCertMap,
cAsWithAnyIssuedCerts
);
expect(result).toBeNull(); // No CA trusted → null
});
it('includes CA cert and active leaf certs for managed CA with active certs', () => {
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
const caCertMap = makeCaCertMap([1, 'CA_A']);
const leafPem = makeCaPem('LEAF_1');
const issuedClientCertMap = new Map([[1, [leafPem]]]);
const cAsWithAnyIssuedCerts = new Set([1]);
const result = buildClientAuthentication(
['app.example.com'],
mTlsDomainMap,
caCertMap,
issuedClientCertMap,
cAsWithAnyIssuedCerts
);
expect(result).not.toBeNull();
expect(result!.trusted_ca_certs).toEqual(['CA_A']);
expect(result!.trusted_leaf_certs).toEqual(['LEAF_1']);
});
it('includes multiple active leaf certs for managed CA', () => {
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
const caCertMap = makeCaCertMap([1, 'CA_A']);
const leafPems = [makeCaPem('LEAF_1'), makeCaPem('LEAF_2'), makeCaPem('LEAF_3')];
const issuedClientCertMap = new Map([[1, leafPems]]);
const cAsWithAnyIssuedCerts = new Set([1]);
const result = buildClientAuthentication(
['app.example.com'],
mTlsDomainMap,
caCertMap,
issuedClientCertMap,
cAsWithAnyIssuedCerts
);
expect(result!.trusted_leaf_certs).toEqual(['LEAF_1', 'LEAF_2', 'LEAF_3']);
});
it('mixes unmanaged CA (no leaves) and managed CA (with active leaves) correctly', () => {
const mTlsDomainMap = new Map([['app.example.com', [1, 2]]]);
const caCertMap = makeCaCertMap([1, 'CA_A'], [2, 'CA_B']);
const leafPem = makeCaPem('LEAF_B1');
// CA 1 is unmanaged; CA 2 is managed with one active cert
const issuedClientCertMap = new Map([[2, [leafPem]]]);
const cAsWithAnyIssuedCerts = new Set([2]);
const result = buildClientAuthentication(
['app.example.com'],
mTlsDomainMap,
caCertMap,
issuedClientCertMap,
cAsWithAnyIssuedCerts
);
expect(result!.trusted_ca_certs).toContain('CA_A');
expect(result!.trusted_ca_certs).toContain('CA_B');
expect(result!.trusted_leaf_certs).toEqual(['LEAF_B1']);
});
it('returns null when the only configured CA is managed with all certs revoked', () => {
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
const caCertMap = makeCaCertMap([1, 'CA_A']);
const issuedClientCertMap = new Map([[1, []]]);
const cAsWithAnyIssuedCerts = new Set([1]);
const result = buildClientAuthentication(
['app.example.com'],
mTlsDomainMap,
caCertMap,
issuedClientCertMap,
cAsWithAnyIssuedCerts
);
expect(result).toBeNull();
});
it('domain lookup is case-insensitive', () => {
const mTlsDomainMap = new Map([['app.example.com', [1]]]); // lowercase key
const caCertMap = makeCaCertMap([1, 'CA_A']);
const result = buildClientAuthentication(
['APP.EXAMPLE.COM'], // uppercase input
mTlsDomainMap,
caCertMap,
new Map(),
new Set()
);
expect(result).not.toBeNull();
expect(result!.trusted_ca_certs).toEqual(['CA_A']);
});
});
// ---------------------------------------------------------------------------
// groupMtlsDomainsByCaSet
// ---------------------------------------------------------------------------
describe('groupMtlsDomainsByCaSet', () => {
it('returns empty map for empty input', () => {
expect(groupMtlsDomainsByCaSet([], new Map()).size).toBe(0);
});
it('puts a single domain in its own group', () => {
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
const groups = groupMtlsDomainsByCaSet(['app.example.com'], mTlsDomainMap);
expect(groups.size).toBe(1);
const [group] = groups.values();
expect(group).toEqual(['app.example.com']);
});
it('groups two domains with the same single CA into one group', () => {
const mTlsDomainMap = new Map([
['app.example.com', [1]],
['app2.example.com', [1]],
]);
const groups = groupMtlsDomainsByCaSet(
['app.example.com', 'app2.example.com'],
mTlsDomainMap
);
expect(groups.size).toBe(1);
const [group] = groups.values();
expect(group).toHaveLength(2);
expect(group).toContain('app.example.com');
expect(group).toContain('app2.example.com');
});
it('separates domains with different CA sets — the cross-CA isolation test', () => {
// This is the core bug scenario: two hosts with different CAs must each get
// their own TLS policy so CA_B certs cannot authenticate against the CA_A host.
const mTlsDomainMap = new Map([
['app.example.com', [1]], // trusts CA_A only
['api.example.com', [2]], // trusts CA_B only
]);
const groups = groupMtlsDomainsByCaSet(
['app.example.com', 'api.example.com'],
mTlsDomainMap
);
expect(groups.size).toBe(2);
const groupValues = Array.from(groups.values());
const appGroup = groupValues.find(g => g.includes('app.example.com'));
const apiGroup = groupValues.find(g => g.includes('api.example.com'));
expect(appGroup).toEqual(['app.example.com']);
expect(apiGroup).toEqual(['api.example.com']);
});
it('groups domains with the same multi-CA set together regardless of CA order', () => {
const mTlsDomainMap = new Map([
['app.example.com', [1, 2]],
['app2.example.com', [2, 1]], // same CAs, different order
]);
const groups = groupMtlsDomainsByCaSet(
['app.example.com', 'app2.example.com'],
mTlsDomainMap
);
expect(groups.size).toBe(1);
const [group] = groups.values();
expect(group).toHaveLength(2);
});
it('separates domains with subset vs superset CAs', () => {
const mTlsDomainMap = new Map([
['app.example.com', [1]], // trusts CA_A only
['api.example.com', [1, 2]], // trusts CA_A + CA_B
]);
const groups = groupMtlsDomainsByCaSet(
['app.example.com', 'api.example.com'],
mTlsDomainMap
);
expect(groups.size).toBe(2);
});
it('creates three groups for three different CA sets', () => {
const mTlsDomainMap = new Map([
['a.example.com', [1]],
['b.example.com', [2]],
['c.example.com', [3]],
]);
const groups = groupMtlsDomainsByCaSet(
['a.example.com', 'b.example.com', 'c.example.com'],
mTlsDomainMap
);
expect(groups.size).toBe(3);
});
it('correctly handles a mix: two shared, one unique', () => {
const mTlsDomainMap = new Map([
['shared1.example.com', [1]],
['shared2.example.com', [1]],
['unique.example.com', [2]],
]);
const groups = groupMtlsDomainsByCaSet(
['shared1.example.com', 'shared2.example.com', 'unique.example.com'],
mTlsDomainMap
);
expect(groups.size).toBe(2);
const groupValues = Array.from(groups.values());
const sharedGroup = groupValues.find(g => g.length === 2);
const uniqueGroup = groupValues.find(g => g.length === 1);
expect(sharedGroup).toContain('shared1.example.com');
expect(sharedGroup).toContain('shared2.example.com');
expect(uniqueGroup).toContain('unique.example.com');
});
it('domain with empty CA list gets its own group (key="")', () => {
// Edge case: domain in mTlsDomainMap but with an empty CA ID list
const mTlsDomainMap = new Map([
['app.example.com', [1]],
['noca.example.com', []],
]);
const groups = groupMtlsDomainsByCaSet(
['app.example.com', 'noca.example.com'],
mTlsDomainMap
);
// Two distinct groups: key="1" and key=""
expect(groups.size).toBe(2);
});
});
// ---------------------------------------------------------------------------
// Cross-CA isolation integration: groupMtlsDomainsByCaSet + buildClientAuthentication
// ---------------------------------------------------------------------------
describe('mTLS per-host CA isolation (regression test for cross-CA bug)', () => {
const caCertMap = makeCaCertMap([1, 'CA_A'], [2, 'CA_B']);
it('before the fix (union): calling buildClientAuthentication with both domains together gives both CAs', () => {
// This documents the OLD behavior — the caller should NOT do this.
const mTlsDomainMap = new Map([
['app.example.com', [1]],
['api.example.com', [2]],
]);
const result = buildClientAuthentication(
['app.example.com', 'api.example.com'], // both domains in one call — wrong
mTlsDomainMap,
caCertMap,
new Map(),
new Set()
);
// Both CAs end up trusted — this is the unsafe behavior
expect(result!.trusted_ca_certs).toContain('CA_A');
expect(result!.trusted_ca_certs).toContain('CA_B');
});
it('after the fix (grouping): each domain gets a policy with only its own CA', () => {
const mTlsDomainMap = new Map([
['app.example.com', [1]], // trusts CA_A only
['api.example.com', [2]], // trusts CA_B only
]);
const allMtlsDomains = ['app.example.com', 'api.example.com'];
const groups = groupMtlsDomainsByCaSet(allMtlsDomains, mTlsDomainMap);
const policies: { sni: string[]; trusted_ca_certs: unknown[] }[] = [];
for (const domainGroup of groups.values()) {
const auth = buildClientAuthentication(
domainGroup,
mTlsDomainMap,
caCertMap,
new Map(),
new Set()
);
if (auth) {
policies.push({
sni: domainGroup,
trusted_ca_certs: auth.trusted_ca_certs as unknown[],
});
}
}
expect(policies).toHaveLength(2);
const appPolicy = policies.find(p => p.sni.includes('app.example.com'))!;
const apiPolicy = policies.find(p => p.sni.includes('api.example.com'))!;
// app.example.com policy must ONLY trust CA_A
expect(appPolicy.trusted_ca_certs).toContain('CA_A');
expect(appPolicy.trusted_ca_certs).not.toContain('CA_B');
// api.example.com policy must ONLY trust CA_B
expect(apiPolicy.trusted_ca_certs).toContain('CA_B');
expect(apiPolicy.trusted_ca_certs).not.toContain('CA_A');
});
it('three hosts each with different CAs get three separate policies', () => {
const caCertMapExtended = makeCaCertMap([1, 'CA_A'], [2, 'CA_B'], [3, 'CA_C']);
const mTlsDomainMap = new Map([
['a.example.com', [1]],
['b.example.com', [2]],
['c.example.com', [3]],
]);
const allMtlsDomains = ['a.example.com', 'b.example.com', 'c.example.com'];
const groups = groupMtlsDomainsByCaSet(allMtlsDomains, mTlsDomainMap);
expect(groups.size).toBe(3);
const policies: { sni: string[]; trusted_ca_certs: unknown[] }[] = [];
for (const domainGroup of groups.values()) {
const auth = buildClientAuthentication(
domainGroup,
mTlsDomainMap,
caCertMapExtended,
new Map(),
new Set()
);
if (auth) {
policies.push({ sni: domainGroup, trusted_ca_certs: auth.trusted_ca_certs as unknown[] });
}
}
expect(policies).toHaveLength(3);
const aPolicy = policies.find(p => p.sni.includes('a.example.com'))!;
expect(aPolicy.trusted_ca_certs).toEqual(['CA_A']);
expect(aPolicy.trusted_ca_certs).not.toContain('CA_B');
expect(aPolicy.trusted_ca_certs).not.toContain('CA_C');
});
it('two hosts sharing the same CA are correctly grouped into one policy', () => {
const mTlsDomainMap = new Map([
['app.example.com', [1]],
['app2.example.com', [1]], // same CA
]);
const allMtlsDomains = ['app.example.com', 'app2.example.com'];
const groups = groupMtlsDomainsByCaSet(allMtlsDomains, mTlsDomainMap);
expect(groups.size).toBe(1);
const [domainGroup] = groups.values();
const auth = buildClientAuthentication(
domainGroup,
mTlsDomainMap,
caCertMap,
new Map(),
new Set()
);
expect(auth).not.toBeNull();
expect(auth!.trusted_ca_certs).toEqual(['CA_A']);
// Both domains in the same policy
expect(domainGroup).toContain('app.example.com');
expect(domainGroup).toContain('app2.example.com');
});
});
+248
View File
@@ -0,0 +1,248 @@
/**
* Unit tests for the pure environment-variable-reading functions
* exported by src/lib/instance-sync.ts.
*
* These functions have no DB or network dependency — they only read
* from process.env and do simple parsing/validation.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
getEnvSlaveInstances,
getSyncIntervalMs,
isHttpSyncAllowed,
isInstanceModeFromEnv,
isSyncTokenFromEnv,
} from '../../src/lib/instance-sync';
const KEYS = [
'INSTANCE_SLAVES',
'INSTANCE_SYNC_INTERVAL',
'INSTANCE_SYNC_ALLOW_HTTP',
'INSTANCE_MODE',
'INSTANCE_SYNC_TOKEN',
] as const;
beforeEach(() => {
for (const k of KEYS) delete process.env[k];
});
afterEach(() => {
for (const k of KEYS) delete process.env[k];
});
// ---------------------------------------------------------------------------
// getEnvSlaveInstances
// ---------------------------------------------------------------------------
describe('getEnvSlaveInstances', () => {
it('returns empty array when env var is not set', () => {
expect(getEnvSlaveInstances()).toEqual([]);
});
it('returns empty array for empty string', () => {
process.env.INSTANCE_SLAVES = '';
expect(getEnvSlaveInstances()).toEqual([]);
});
it('parses a valid single slave entry', () => {
process.env.INSTANCE_SLAVES = JSON.stringify([
{ name: 'slave1', url: 'https://slave.example.com', token: 'secret123' },
]);
const result = getEnvSlaveInstances();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
name: 'slave1',
url: 'https://slave.example.com',
token: 'secret123',
});
});
it('parses multiple slave entries', () => {
process.env.INSTANCE_SLAVES = JSON.stringify([
{ name: 'slave1', url: 'https://slave1.example.com', token: 'tok1' },
{ name: 'slave2', url: 'https://slave2.example.com', token: 'tok2' },
]);
expect(getEnvSlaveInstances()).toHaveLength(2);
});
it('returns empty array for non-array JSON', () => {
process.env.INSTANCE_SLAVES = '{"name":"slave1"}'; // object, not array
expect(getEnvSlaveInstances()).toEqual([]);
});
it('returns empty array for malformed JSON', () => {
process.env.INSTANCE_SLAVES = '{bad json';
expect(getEnvSlaveInstances()).toEqual([]);
});
it('filters out entries missing required fields', () => {
process.env.INSTANCE_SLAVES = JSON.stringify([
{ name: 'slave1', url: 'https://slave1.example.com', token: 'tok1' }, // valid
{ name: 'slave2', url: 'https://slave2.example.com' }, // missing token
{ name: 'slave3', token: 'tok3' }, // missing url
{ url: 'https://slave4.example.com', token: 'tok4' }, // missing name
]);
const result = getEnvSlaveInstances();
expect(result).toHaveLength(1);
expect(result[0].name).toBe('slave1');
});
it('filters out entries with empty string fields', () => {
process.env.INSTANCE_SLAVES = JSON.stringify([
{ name: '', url: 'https://slave.example.com', token: 'tok' }, // empty name
]);
expect(getEnvSlaveInstances()).toEqual([]);
});
it('filters out non-object entries', () => {
process.env.INSTANCE_SLAVES = JSON.stringify([
42,
null,
'string',
{ name: 'ok', url: 'https://ok.com', token: 'tok' },
]);
const result = getEnvSlaveInstances();
expect(result).toHaveLength(1);
expect(result[0].name).toBe('ok');
});
});
// ---------------------------------------------------------------------------
// getSyncIntervalMs
// ---------------------------------------------------------------------------
describe('getSyncIntervalMs', () => {
it('returns 0 when env var is not set (disabled)', () => {
expect(getSyncIntervalMs()).toBe(0);
});
it('converts seconds to milliseconds', () => {
process.env.INSTANCE_SYNC_INTERVAL = '60';
expect(getSyncIntervalMs()).toBe(60_000);
});
it('enforces minimum of 30 seconds', () => {
process.env.INSTANCE_SYNC_INTERVAL = '10';
expect(getSyncIntervalMs()).toBe(30_000); // clamped to 30s
});
it('exactly 30 seconds is allowed', () => {
process.env.INSTANCE_SYNC_INTERVAL = '30';
expect(getSyncIntervalMs()).toBe(30_000);
});
it('returns 0 for "0"', () => {
process.env.INSTANCE_SYNC_INTERVAL = '0';
expect(getSyncIntervalMs()).toBe(0);
});
it('returns 0 for negative value', () => {
process.env.INSTANCE_SYNC_INTERVAL = '-60';
expect(getSyncIntervalMs()).toBe(0);
});
it('returns 0 for non-numeric string', () => {
process.env.INSTANCE_SYNC_INTERVAL = 'abc';
expect(getSyncIntervalMs()).toBe(0);
});
it('handles large interval correctly', () => {
process.env.INSTANCE_SYNC_INTERVAL = '3600'; // 1 hour
expect(getSyncIntervalMs()).toBe(3_600_000);
});
});
// ---------------------------------------------------------------------------
// isHttpSyncAllowed
// ---------------------------------------------------------------------------
describe('isHttpSyncAllowed', () => {
it('returns false when env var is not set', () => {
expect(isHttpSyncAllowed()).toBe(false);
});
it('returns true for "true"', () => {
process.env.INSTANCE_SYNC_ALLOW_HTTP = 'true';
expect(isHttpSyncAllowed()).toBe(true);
});
it('returns true for "1"', () => {
process.env.INSTANCE_SYNC_ALLOW_HTTP = '1';
expect(isHttpSyncAllowed()).toBe(true);
});
it('returns false for "false"', () => {
process.env.INSTANCE_SYNC_ALLOW_HTTP = 'false';
expect(isHttpSyncAllowed()).toBe(false);
});
it('returns false for "yes"', () => {
process.env.INSTANCE_SYNC_ALLOW_HTTP = 'yes';
expect(isHttpSyncAllowed()).toBe(false);
});
it('returns false for empty string', () => {
process.env.INSTANCE_SYNC_ALLOW_HTTP = '';
expect(isHttpSyncAllowed()).toBe(false);
});
});
// ---------------------------------------------------------------------------
// isInstanceModeFromEnv
// ---------------------------------------------------------------------------
describe('isInstanceModeFromEnv', () => {
it('returns false when env var is not set', () => {
expect(isInstanceModeFromEnv()).toBe(false);
});
it('returns true for "master"', () => {
process.env.INSTANCE_MODE = 'master';
expect(isInstanceModeFromEnv()).toBe(true);
});
it('returns true for "slave"', () => {
process.env.INSTANCE_MODE = 'slave';
expect(isInstanceModeFromEnv()).toBe(true);
});
it('returns true for "standalone"', () => {
process.env.INSTANCE_MODE = 'standalone';
expect(isInstanceModeFromEnv()).toBe(true);
});
it('returns false for invalid mode', () => {
process.env.INSTANCE_MODE = 'invalid';
expect(isInstanceModeFromEnv()).toBe(false);
});
it('returns false for empty string', () => {
process.env.INSTANCE_MODE = '';
expect(isInstanceModeFromEnv()).toBe(false);
});
});
// ---------------------------------------------------------------------------
// isSyncTokenFromEnv
// ---------------------------------------------------------------------------
describe('isSyncTokenFromEnv', () => {
it('returns false when env var is not set', () => {
expect(isSyncTokenFromEnv()).toBe(false);
});
it('returns true when token is set to a non-empty string', () => {
process.env.INSTANCE_SYNC_TOKEN = 'my-secret-token';
expect(isSyncTokenFromEnv()).toBe(true);
});
it('returns false for empty string token', () => {
process.env.INSTANCE_SYNC_TOKEN = '';
expect(isSyncTokenFromEnv()).toBe(false);
});
it('returns true for any non-empty value', () => {
process.env.INSTANCE_SYNC_TOKEN = ' '; // whitespace counts as non-empty
expect(isSyncTokenFromEnv()).toBe(true);
});
});