import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import net from 'node:net'; import { tmpdir } from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { test } from 'node:test'; const webmailUrl = 'https://mail.us.ss5.xyz/'; const webmailAudience = 'https://mail.us.ss5.xyz'; const sharedSecret = 'cd'.repeat(32); const userPassword = 'password123'; test('Webmail SSO requires an authorized MailHub session and safely exchanges one-time tickets', async (t) => { const fixture = await startTestServer(); t.after(async () => { await stopChild(fixture.child); rmSync(fixture.tempDir, { recursive: true, force: true }); }); const ownerCookie = await login(fixture.baseUrl, 'sso-owner', userPassword); const receiverCookie = await login(fixture.baseUrl, 'sso-receiver', userPassword); const viewerCookie = await login(fixture.baseUrl, 'sso-viewer', userPassword); const adminCookie = await login(fixture.baseUrl, 'sso-admin', userPassword); const loginPath = `/api/inbound-mailboxes/${fixture.seed.mailboxId}/webmail-login`; await t.test('anonymous, Basic, and Bearer requests cannot issue a login ticket', async () => { const anonymous = await requestJson(fixture.baseUrl, loginPath, { method: 'POST', origin: fixture.baseUrl }); assert.equal(anonymous.status, 401); assertNoOpaqueCredential(anonymous.raw); const basic = await requestJson(fixture.baseUrl, loginPath, { method: 'POST', origin: fixture.baseUrl, authorization: `Basic ${Buffer.from(`sso-owner:${userPassword}`).toString('base64')}` }); assert.equal(basic.status, 403); assertNoOpaqueCredential(basic.raw); const bearer = await requestJson(fixture.baseUrl, loginPath, { method: 'POST', origin: fixture.baseUrl, authorization: `Bearer ${fixture.seed.ownerToken}` }); assert.equal(bearer.status, 401); assertNoOpaqueCredential(bearer.raw); }); await t.test('ticket issuance rejects missing and cross-origin requests', async () => { const missingOrigin = await requestJson(fixture.baseUrl, loginPath, { method: 'POST', cookie: ownerCookie }); assert.equal(missingOrigin.status, 403); assertNoOpaqueCredential(missingOrigin.raw); const crossOrigin = await requestJson(fixture.baseUrl, loginPath, { method: 'POST', cookie: ownerCookie, origin: 'https://evil.example' }); assert.equal(crossOrigin.status, 403); assertNoOpaqueCredential(crossOrigin.raw); }); let ownerTicket = ''; await t.test('the owner and receive delegate can issue private one-time tickets', async () => { const owner = await issueTicket(fixture.baseUrl, loginPath, ownerCookie); ownerTicket = owner.body.webmailLogin.ticket; assert.equal(owner.body.webmailLogin.action, webmailUrl); assert.match(ownerTicket, /^mht_[A-Za-z0-9_-]{43}$/); assert.ok(Date.parse(owner.body.webmailLogin.expiresAt) > Date.now()); const receiver = await issueTicket(fixture.baseUrl, loginPath, receiverCookie); assert.equal(receiver.body.webmailLogin.action, webmailUrl); assert.match(receiver.body.webmailLogin.ticket, /^mht_[A-Za-z0-9_-]{43}$/); assert.notEqual(receiver.body.webmailLogin.ticket, ownerTicket); }); await t.test('view-only users and administrators have no implicit login access', async () => { for (const cookie of [viewerCookie, adminCookie]) { const response = await requestJson(fixture.baseUrl, loginPath, { method: 'POST', cookie, origin: fixture.baseUrl }); assert.equal(response.status, 404); assertNoOpaqueCredential(response.raw); assert.equal(response.raw.includes(fixture.seed.mailboxAddress), false); } }); await t.test('the internal exchange authenticates, consumes once, and revokes the credential', async () => { const exchangePath = '/internal/webmail-sso/exchange'; const wrongSecret = await requestJson(fixture.baseUrl, exchangePath, { method: 'POST', authorization: 'Bearer definitely-wrong-secret', body: { ticket: ownerTicket, audience: webmailAudience } }); assert.equal(wrongSecret.status, 401); assertPrivateResponse(wrongSecret); assert.equal(wrongSecret.raw.includes(ownerTicket), false); assertNoOpaqueCredential(wrongSecret.raw); const exchanged = await requestJson(fixture.baseUrl, exchangePath, { method: 'POST', authorization: `Bearer ${sharedSecret}`, body: { ticket: ownerTicket, audience: webmailAudience } }); assert.equal(exchanged.status, 200, exchanged.raw); assertPrivateResponse(exchanged); assert.equal(exchanged.body.username, fixture.seed.mailboxAddress); assert.match(exchanged.body.credential, /^mhw_[A-Za-z0-9_-]{43}$/); assert.ok(Date.parse(exchanged.body.expiresAt) > Date.now()); const credential = exchanged.body.credential; const replay = await requestJson(fixture.baseUrl, exchangePath, { method: 'POST', authorization: `Bearer ${sharedSecret}`, body: { ticket: ownerTicket, audience: webmailAudience } }); assert.equal(replay.status, 400); assertPrivateResponse(replay); assert.equal(replay.raw.includes(ownerTicket), false); assert.equal(replay.raw.includes(credential), false); assertNoOpaqueCredential(replay.raw); const revoked = await requestJson(fixture.baseUrl, '/internal/webmail-sso/revoke', { method: 'POST', authorization: `Bearer ${sharedSecret}`, body: { credential, audience: webmailAudience } }); assert.equal(revoked.status, 200, revoked.raw); assertPrivateResponse(revoked); assert.deepEqual(revoked.body, { revoked: true }); assert.equal(revoked.raw.includes(credential), false); }); await t.test('ticket issuance returns 429 after the bounded per-user burst', async () => { // The owner already created one ticket earlier in this fixture. for (let index = 1; index < 20; index += 1) { const response = await requestJson(fixture.baseUrl, loginPath, { method: 'POST', cookie: ownerCookie, origin: fixture.baseUrl }); assert.equal(response.status, 201, response.raw); } const limited = await requestJson(fixture.baseUrl, loginPath, { method: 'POST', cookie: ownerCookie, origin: fixture.baseUrl }); assert.equal(limited.status, 429, limited.raw); assert.equal(limited.headers.get('retry-after'), '60'); assert.match(limited.body.error, /请求过于频繁/); assertNoOpaqueCredential(limited.raw); }); }); async function issueTicket(baseUrl, pathname, cookie) { const response = await requestJson(baseUrl, pathname, { method: 'POST', cookie, origin: baseUrl }); assert.equal(response.status, 201, response.raw); assertPrivateResponse(response); return response; } async function startTestServer() { const port = await freePort(); const baseUrl = `http://127.0.0.1:${port}`; const tempDir = mkdtempSync(path.join(tmpdir(), 'mailhub-server-webmail-sso-')); const dataDir = path.join(tempDir, 'data'); const secretFile = path.join(tempDir, 'webmail-sso-secret'); const sessionSecret = 'mailhub-webmail-server-session-secret'; writeFileSync(secretFile, `${sharedSecret}\n`, { mode: 0o600 }); chmodSync(secretFile, 0o600); const seed = seedFixture(dataDir, sessionSecret); const child = spawn(process.execPath, ['src/server.js'], { cwd: process.cwd(), env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, APP_BASE_URL: baseUrl, SESSION_SECRET: sessionSecret, ADMIN_USER: 'sso-admin', ADMIN_EMAIL: 'sso-admin@example.test', ADMIN_PASSWORD: userPassword, WEBMAIL_SSO_URL: webmailUrl, WEBMAIL_SSO_SECRET_FILE: secretFile, WEBMAIL_SSO_TICKET_TTL_SECONDS: '60', WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS: '3600', DNS_AUTO_CHECK_ENABLED: 'false', DELIVERY_TRACKING_ENABLED: 'false', WEBHOOK_WORKER_ENABLED: 'false', SUBMISSION_ENABLED: 'false', IMAP_ENABLED: 'false', POP3_ENABLED: 'false', DOVECOT_AUTH_ENABLED: 'false', MAIL_ACCESS_BACKEND: 'legacy' }, stdio: ['ignore', 'pipe', 'pipe'] }); try { await waitForServer(child, baseUrl); return { child, baseUrl, tempDir, seed }; } catch (error) { await stopChild(child); rmSync(tempDir, { recursive: true, force: true }); throw error; } } function seedFixture(dataDir, sessionSecret) { const script = ` import { createApiToken, createDomain, createInboundMailbox, createUser, initDatabase, replaceInboundMailboxGrants } from './src/db.js'; initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET); const admin = createUser({ username: 'sso-admin', email: 'sso-admin@example.test', password: '${userPassword}', role: 'admin', status: 'active' }); const owner = createUser({ username: 'sso-owner', email: 'sso-owner@example.test', password: '${userPassword}', status: 'active' }); const receiver = createUser({ username: 'sso-receiver', email: 'sso-receiver@example.test', password: '${userPassword}', status: 'active' }); const viewer = createUser({ username: 'sso-viewer', email: 'sso-viewer@example.test', password: '${userPassword}', status: 'active' }); createDomain(owner.id, { domain: 'webmail-sso.example', selector: 'mh', verificationToken: 'verify-webmail-sso', dkimPublic: 'test-public-key', dkimPrivate: 'test-private-key', senderHost: 'mail.webmail-sso.example', sendingIp: '127.0.0.1', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }); const mailbox = createInboundMailbox(owner.id, { address: 'shared@webmail-sso.example', password: 'mailbox-password' }); replaceInboundMailboxGrants(mailbox.id, [ { userId: receiver.id, permissions: ['receive'] }, { userId: viewer.id, permissions: ['view'] } ]); const ownerToken = createApiToken(owner.id, 'Webmail route rejection', { scopes: ['messages:read'], mailboxAccess: 'owner' }); console.log(JSON.stringify({ adminUserId: admin.id, ownerUserId: owner.id, receiverUserId: receiver.id, viewerUserId: viewer.id, mailboxId: mailbox.id, mailboxAddress: mailbox.address, ownerToken: ownerToken.token })); `; const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { cwd: process.cwd(), env: { ...process.env, DATA_DIR: dataDir, SESSION_SECRET: sessionSecret }, encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr || result.stdout); return JSON.parse(result.stdout); } async function login(baseUrl, username, password) { const response = await requestJson(baseUrl, '/api/login', { method: 'POST', body: { username, password } }); assert.equal(response.status, 200, response.raw); const cookie = response.headers.get('set-cookie')?.split(';', 1)[0] || ''; assert.ok(cookie); return cookie; } async function requestJson(baseUrl, pathname, { method = 'GET', cookie = '', origin = '', authorization = '', body } = {}) { const headers = {}; if (cookie) headers.Cookie = cookie; if (origin) headers.Origin = origin; if (authorization) headers.Authorization = authorization; if (body !== undefined) headers['Content-Type'] = 'application/json'; const response = await fetch(`${baseUrl}${pathname}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual' }); const raw = await response.text(); return { status: response.status, headers: response.headers, raw, body: raw ? JSON.parse(raw) : null }; } function assertPrivateResponse(response) { assert.match(response.headers.get('cache-control') || '', /(?:^|[,\s])no-store(?:[,\s]|$)/); assert.equal(response.headers.get('pragma'), 'no-cache'); } function assertNoOpaqueCredential(raw) { assert.doesNotMatch(raw, /\bmht_[A-Za-z0-9_-]+\b/); assert.doesNotMatch(raw, /\bmhw_[A-Za-z0-9_-]+\b/); } async function waitForServer(child, baseUrl, timeoutMs = 8000) { let output = ''; child.stdout.on('data', (chunk) => { output += chunk.toString(); }); child.stderr.on('data', (chunk) => { output += chunk.toString(); }); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (child.exitCode !== null) { throw new Error(`MailHub exited before becoming ready (code ${child.exitCode}).\n${output}`); } try { const response = await fetch(`${baseUrl}/healthz`); if (response.status === 200) return; } catch { // The child process may still be binding its HTTP listener. } await delay(50); } throw new Error(`Timed out waiting for MailHub.\n${output}`); } function freePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); server.close((error) => { if (error) return reject(error); if (address && typeof address === 'object') return resolve(address.port); reject(new Error('Unable to allocate a test port.')); }); }); }); } async function stopChild(child) { if (!child || child.exitCode !== null) return; child.kill('SIGTERM'); if (await waitForExit(child, 1500)) return; child.kill('SIGKILL'); await waitForExit(child, 1000); } function waitForExit(child, timeoutMs) { if (child.exitCode !== null) return Promise.resolve(true); return new Promise((resolve) => { const onExit = () => { clearTimeout(timeout); resolve(true); }; const timeout = setTimeout(() => { child.off('exit', onExit); resolve(false); }, timeoutMs); child.once('exit', onExit); }); } function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); }