Files
caddy-proxy-manager/tests/e2e/api-security.spec.ts
fuomag9 60633bf6c3 Fix unused variable lint error in api-security test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 10:28:48 +02:00

398 lines
18 KiB
TypeScript

/**
* E2E tests: API endpoint security.
*
* Verifies that ALL /api/v1/ endpoints properly enforce authentication
* and role-based access control:
*
* 1. Unauthenticated requests → 401
* 2. User role → 403 on admin-only endpoints, allowed on user endpoints
* 3. Viewer role → 403 on admin-only endpoints, allowed on user endpoints
* 4. Admin role → allowed on all endpoints
*/
import { test, expect, type APIRequestContext } from '@playwright/test';
import { execFileSync } from 'node:child_process';
const BASE = 'http://localhost:3000/api/v1';
const ORIGIN = 'http://localhost:3000';
const COMPOSE_ARGS = [
'compose',
'-f', 'docker-compose.yml',
'-f', 'tests/docker-compose.test.yml',
];
// ── Endpoint definitions ────────────────────────────────────────────────
type Endpoint = {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
path: string;
/** 'admin' = requireApiAdmin, 'user' = requireApiUser */
auth: 'admin' | 'user';
/** Optional body for mutating requests (prevents 400 from missing body) */
body?: Record<string, unknown>;
};
// Use real-ish IDs; 999 will return 404 after auth passes, which is fine — we only test auth.
const ENDPOINTS: Endpoint[] = [
// proxy-hosts
{ method: 'GET', path: '/proxy-hosts', auth: 'admin' },
{ method: 'POST', path: '/proxy-hosts', auth: 'admin', body: { name: 'x', domains: ['x.test'], upstreams: ['127.0.0.1:80'] } },
{ method: 'GET', path: '/proxy-hosts/999', auth: 'admin' },
{ method: 'PUT', path: '/proxy-hosts/999', auth: 'admin', body: { name: 'x' } },
{ method: 'DELETE', path: '/proxy-hosts/999', auth: 'admin' },
{ method: 'GET', path: '/proxy-hosts/999/forward-auth-access', auth: 'admin' },
{ method: 'PUT', path: '/proxy-hosts/999/forward-auth-access', auth: 'admin', body: { userIds: [], groupIds: [] } },
{ method: 'GET', path: '/proxy-hosts/999/mtls-access-rules', auth: 'admin' },
{ method: 'POST', path: '/proxy-hosts/999/mtls-access-rules', auth: 'admin', body: { pathPattern: '/', allowedRoleIds: [] } },
{ method: 'GET', path: '/proxy-hosts/999/mtls-access-rules/999', auth: 'admin' },
{ method: 'PUT', path: '/proxy-hosts/999/mtls-access-rules/999', auth: 'admin', body: { pathPattern: '/' } },
{ method: 'DELETE', path: '/proxy-hosts/999/mtls-access-rules/999', auth: 'admin' },
// l4-proxy-hosts
{ method: 'GET', path: '/l4-proxy-hosts', auth: 'admin' },
{ method: 'POST', path: '/l4-proxy-hosts', auth: 'admin', body: { name: 'x', protocol: 'tcp', listenAddress: ':9999', upstreams: ['127.0.0.1:80'] } },
{ method: 'GET', path: '/l4-proxy-hosts/999', auth: 'admin' },
{ method: 'PUT', path: '/l4-proxy-hosts/999', auth: 'admin', body: { name: 'x' } },
{ method: 'DELETE', path: '/l4-proxy-hosts/999', auth: 'admin' },
// certificates
{ method: 'GET', path: '/certificates', auth: 'admin' },
{ method: 'POST', path: '/certificates', auth: 'admin', body: { name: 'x', type: 'custom', domainNames: ['x.test'] } },
{ method: 'GET', path: '/certificates/999', auth: 'admin' },
{ method: 'PUT', path: '/certificates/999', auth: 'admin', body: { name: 'x' } },
{ method: 'DELETE', path: '/certificates/999', auth: 'admin' },
// ca-certificates
{ method: 'GET', path: '/ca-certificates', auth: 'admin' },
{ method: 'POST', path: '/ca-certificates', auth: 'admin', body: { name: 'x', certificatePem: 'x' } },
{ method: 'GET', path: '/ca-certificates/999', auth: 'admin' },
{ method: 'PUT', path: '/ca-certificates/999', auth: 'admin', body: { name: 'x' } },
{ method: 'DELETE', path: '/ca-certificates/999', auth: 'admin' },
// client-certificates
{ method: 'GET', path: '/client-certificates', auth: 'admin' },
{ method: 'POST', path: '/client-certificates', auth: 'admin', body: { caCertificateId: 999, commonName: 'x' } },
{ method: 'GET', path: '/client-certificates/999', auth: 'admin' },
{ method: 'DELETE', path: '/client-certificates/999', auth: 'admin' },
{ method: 'GET', path: '/client-certificates/999/roles', auth: 'admin' },
// access-lists
{ method: 'GET', path: '/access-lists', auth: 'admin' },
{ method: 'POST', path: '/access-lists', auth: 'admin', body: { name: 'x' } },
{ method: 'GET', path: '/access-lists/999', auth: 'admin' },
{ method: 'PUT', path: '/access-lists/999', auth: 'admin', body: { name: 'x' } },
{ method: 'DELETE', path: '/access-lists/999', auth: 'admin' },
{ method: 'POST', path: '/access-lists/999/entries', auth: 'admin', body: { username: 'x', password: 'x' } },
{ method: 'DELETE', path: '/access-lists/999/entries/999', auth: 'admin' },
// mtls-roles
{ method: 'GET', path: '/mtls-roles', auth: 'admin' },
{ method: 'POST', path: '/mtls-roles', auth: 'admin', body: { name: 'x' } },
{ method: 'GET', path: '/mtls-roles/999', auth: 'admin' },
{ method: 'PUT', path: '/mtls-roles/999', auth: 'admin', body: { name: 'x' } },
{ method: 'DELETE', path: '/mtls-roles/999', auth: 'admin' },
{ method: 'POST', path: '/mtls-roles/999/certificates', auth: 'admin', body: { issuedClientCertificateId: 999 } },
{ method: 'DELETE', path: '/mtls-roles/999/certificates/999', auth: 'admin' },
// groups
{ method: 'GET', path: '/groups', auth: 'admin' },
{ method: 'POST', path: '/groups', auth: 'admin', body: { name: 'x' } },
{ method: 'GET', path: '/groups/999', auth: 'admin' },
{ method: 'PATCH', path: '/groups/999', auth: 'admin', body: { name: 'x' } },
{ method: 'DELETE', path: '/groups/999', auth: 'admin' },
{ method: 'POST', path: '/groups/999/members', auth: 'admin', body: { userId: 999 } },
{ method: 'DELETE', path: '/groups/999/members/999', auth: 'admin' },
// settings
{ method: 'GET', path: '/settings/general', auth: 'admin' },
{ method: 'PUT', path: '/settings/general', auth: 'admin', body: {} },
// instances
{ method: 'GET', path: '/instances', auth: 'admin' },
{ method: 'POST', path: '/instances', auth: 'admin', body: { name: 'x', baseUrl: 'http://x.test', apiToken: 'x' } },
{ method: 'DELETE', path: '/instances/999', auth: 'admin' },
{ method: 'POST', path: '/instances/sync', auth: 'admin' },
// forward-auth-sessions
{ method: 'GET', path: '/forward-auth-sessions', auth: 'admin' },
{ method: 'DELETE', path: '/forward-auth-sessions', auth: 'admin' },
{ method: 'DELETE', path: '/forward-auth-sessions/999', auth: 'admin' },
// audit-log
{ method: 'GET', path: '/audit-log', auth: 'admin' },
// caddy
{ method: 'POST', path: '/caddy/apply', auth: 'admin' },
// oauth-providers
{ method: 'GET', path: '/oauth-providers', auth: 'admin' },
{ method: 'POST', path: '/oauth-providers', auth: 'admin', body: { name: 'x', clientId: 'x', clientSecret: 'x' } },
{ method: 'GET', path: '/oauth-providers/999', auth: 'admin' },
{ method: 'PUT', path: '/oauth-providers/999', auth: 'admin', body: { name: 'x' } },
{ method: 'DELETE', path: '/oauth-providers/999', auth: 'admin' },
// openapi.json
{ method: 'GET', path: '/openapi.json', auth: 'admin' },
// users (admin for list; single-user endpoints allow self-access only, so arbitrary ID → admin)
{ method: 'GET', path: '/users', auth: 'admin' },
{ method: 'GET', path: '/users/999', auth: 'admin' },
{ method: 'PUT', path: '/users/999', auth: 'admin', body: { name: 'x' } },
{ method: 'DELETE', path: '/users/999', auth: 'admin' },
// tokens (user-level — any authenticated user can manage their own)
{ method: 'GET', path: '/tokens', auth: 'user' },
{ method: 'POST', path: '/tokens', auth: 'user', body: { name: 'x' } },
{ method: 'DELETE', path: '/tokens/999', auth: 'user' },
];
// ── Helpers ─────────────────────────────────────────────────────────────
function ensureTestUser(username: string, password: string, role: string) {
const script = `
import { Database } from "bun:sqlite";
const db = new Database("./data/caddy-proxy-manager.db");
const email = "${username}@localhost";
const hash = await Bun.password.hash("${password}", { algorithm: "bcrypt", cost: 12 });
const now = new Date().toISOString();
const existing = db.query("SELECT id FROM users WHERE email = ?").get(email);
if (existing) {
db.run("UPDATE users SET passwordHash = ?, role = ?, status = 'active', updatedAt = ? WHERE email = ?",
[hash, "${role}", now, email]);
const acc = db.query("SELECT id FROM accounts WHERE userId = ? AND providerId = 'credential'").get(existing.id);
if (acc) {
db.run("UPDATE accounts SET password = ?, updatedAt = ? WHERE id = ?", [hash, now, acc.id]);
} else {
db.run("INSERT INTO accounts (userId, accountId, providerId, password, createdAt, updatedAt) VALUES (?, ?, 'credential', ?, ?, ?)",
[existing.id, String(existing.id), hash, now, now]);
}
} else {
db.run(
"INSERT INTO users (email, name, passwordHash, role, provider, subject, username, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, 'credentials', ?, ?, 'active', ?, ?)",
[email, "${username}", hash, "${role}", "${username}", "${username}", now, now]
);
const user = db.query("SELECT id FROM users WHERE email = ?").get(email);
db.run("INSERT INTO accounts (userId, accountId, providerId, password, createdAt, updatedAt) VALUES (?, ?, 'credential', ?, ?, ?)",
[user.id, String(user.id), hash, now, now]);
}
`;
execFileSync('docker', [...COMPOSE_ARGS, 'exec', '-T', 'web', 'bun', '-e', script], {
cwd: process.cwd(),
stdio: 'pipe',
});
}
/**
* Create a Bearer API token for a user directly in the DB.
* Returns the raw token string (not hashed).
*/
function createApiToken(username: string): string {
const token = `test-api-token-${username}-${Date.now()}`;
const script = `
import { Database } from "bun:sqlite";
import { createHash } from "crypto";
const db = new Database("./data/caddy-proxy-manager.db");
const email = "${username}@localhost";
const user = db.query("SELECT id FROM users WHERE email = ?").get(email);
if (!user) { console.error("User not found: ${username}"); process.exit(1); }
const hash = createHash("sha256").update("${token}").digest("hex");
const now = new Date().toISOString();
db.run("INSERT INTO api_tokens (name, tokenHash, createdBy, createdAt) VALUES (?, ?, ?, ?)",
["e2e-security-test", hash, user.id, now]);
`;
execFileSync('docker', [...COMPOSE_ARGS, 'exec', '-T', 'web', 'bun', '-e', script], {
cwd: process.cwd(),
stdio: 'pipe',
});
return token;
}
async function apiRequest(
request: APIRequestContext,
endpoint: Endpoint,
token?: string,
): Promise<number> {
const url = `${BASE}${endpoint.path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Origin': ORIGIN,
};
if (token) headers['Authorization'] = `Bearer ${token}`;
let res;
switch (endpoint.method) {
case 'GET':
res = await request.get(url, { headers });
break;
case 'POST':
res = await request.post(url, { headers, data: endpoint.body ?? {} });
break;
case 'PUT':
res = await request.put(url, { headers, data: endpoint.body ?? {} });
break;
case 'DELETE':
res = await request.delete(url, { headers });
break;
case 'PATCH':
res = await request.patch(url, { headers, data: endpoint.body ?? {} });
break;
}
return res.status();
}
// ── Setup ───────────────────────────────────────────────────────────────
// Don't use global auth state — we manage our own sessions
test.use({ storageState: { cookies: [], origins: [] } });
let userToken: string;
let viewerToken: string;
let adminToken: string;
test.beforeAll(async () => {
// Retry user creation — Docker exec can transiently fail under load
for (let i = 0; i < 3; i++) {
try {
ensureTestUser('apisec-user', 'ApiSecUser2026!', 'user');
ensureTestUser('apisec-viewer', 'ApiSecViewer2026!', 'viewer');
break;
} catch (e) {
if (i === 2) throw e;
await new Promise(r => setTimeout(r, 2000));
}
}
userToken = createApiToken('apisec-user');
viewerToken = createApiToken('apisec-viewer');
adminToken = createApiToken('testadmin');
});
// ── Unauthenticated ─────────────────────────────────────────────────────
test.describe('Unauthenticated API access', () => {
for (const ep of ENDPOINTS) {
test(`${ep.method} ${ep.path} → 401`, async ({ request }) => {
const status = await apiRequest(request, ep);
expect(status).toBe(401);
});
}
});
// ── User role ───────────────────────────────────────────────────────────
test.describe('User role API access', () => {
const adminOnly = ENDPOINTS.filter(ep => ep.auth === 'admin');
const userAllowed = ENDPOINTS.filter(ep => ep.auth === 'user');
for (const ep of adminOnly) {
test(`${ep.method} ${ep.path} → 403`, async ({ request }) => {
const status = await apiRequest(request, ep, userToken);
expect(status).toBe(403);
});
}
for (const ep of userAllowed) {
test(`${ep.method} ${ep.path} → allowed (not 401/403)`, async ({ request }) => {
const status = await apiRequest(request, ep, userToken);
expect(status).not.toBe(401);
expect(status).not.toBe(403);
});
}
});
// ── Viewer role ─────────────────────────────────────────────────────────
test.describe('Viewer role API access', () => {
const adminOnly = ENDPOINTS.filter(ep => ep.auth === 'admin');
const userAllowed = ENDPOINTS.filter(ep => ep.auth === 'user');
for (const ep of adminOnly) {
test(`${ep.method} ${ep.path} → 403`, async ({ request }) => {
const status = await apiRequest(request, ep, viewerToken);
expect(status).toBe(403);
});
}
for (const ep of userAllowed) {
test(`${ep.method} ${ep.path} → allowed (not 401/403)`, async ({ request }) => {
const status = await apiRequest(request, ep, viewerToken);
expect(status).not.toBe(401);
expect(status).not.toBe(403);
});
}
});
// ── Admin role ──────────────────────────────────────────────────────────
test.describe('Admin role API access', () => {
for (const ep of ENDPOINTS) {
test(`${ep.method} ${ep.path} → allowed (not 401/403)`, async ({ request }) => {
const status = await apiRequest(request, ep, adminToken);
expect(status).not.toBe(401);
expect(status).not.toBe(403);
});
}
});
// ── Cross-user isolation ────────────────────────────────────────────────
test.describe('Cross-user isolation', () => {
test('user cannot GET another user\'s profile', async ({ request }) => {
// apisec-user tries to read admin (user ID 1)
const status = await apiRequest(request, { method: 'GET', path: '/users/1', auth: 'user' }, userToken);
expect(status).toBe(403);
});
test('user cannot PUT another user\'s profile', async ({ request }) => {
const status = await apiRequest(request, { method: 'PUT', path: '/users/1', auth: 'user', body: { name: 'hacked' } }, userToken);
expect(status).toBe(403);
});
test('user cannot DELETE another user', async ({ request }) => {
const status = await apiRequest(request, { method: 'DELETE', path: '/users/1', auth: 'user' }, userToken);
expect(status).toBe(403);
});
test('viewer cannot GET another user\'s profile', async ({ request }) => {
const status = await apiRequest(request, { method: 'GET', path: '/users/1', auth: 'user' }, viewerToken);
expect(status).toBe(403);
});
test('viewer cannot PUT another user\'s profile', async ({ request }) => {
const status = await apiRequest(request, { method: 'PUT', path: '/users/1', auth: 'user', body: { name: 'hacked' } }, viewerToken);
expect(status).toBe(403);
});
test('viewer cannot DELETE another user', async ({ request }) => {
const status = await apiRequest(request, { method: 'DELETE', path: '/users/1', auth: 'user' }, viewerToken);
expect(status).toBe(403);
});
test('user can GET their own profile', async ({ request }) => {
// First find the user's own ID
await request.get(`${ORIGIN}/api/auth/get-session`, {
headers: { 'Authorization': `Bearer ${userToken}` },
});
// Bearer tokens go through our api-auth, not Better Auth session — use a different approach
// Just verify they CAN'T access admin user, which we tested above.
// Self-access is implicitly tested by tokens endpoint (user-level, always works).
});
test('admin CAN access other users\' profiles', async ({ request }) => {
// Admin reads apisec-user's profile — should work
// We need apisec-user's ID. Use the /users list endpoint.
const res = await request.get(`${BASE}/users`, {
headers: { 'Authorization': `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
});
expect(res.status()).toBe(200);
const users: Array<{ id: number; email: string }> = await res.json();
const apisecUser = users.find(u => u.email === 'apisec-user@localhost');
expect(apisecUser).toBeTruthy();
const profileRes = await request.get(`${BASE}/users/${apisecUser!.id}`, {
headers: { 'Authorization': `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
});
expect(profileRes.status()).toBe(200);
});
});