| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416 |
- 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));
- }
|