| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532 |
- import assert from 'node:assert/strict';
- import { spawn, spawnSync } from 'node:child_process';
- import { mkdtempSync } 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';
- test('API tokens enforce inbound mailbox scope and keep token management session-only', async (t) => {
- const fixture = await startTestServer();
- try {
- const seeded = seedInboundAccessFixtures(fixture.dataDir, fixture.sessionSecret);
- const adminCookie = await login(fixture.baseUrl, 'admin', 'password123');
- const securityAdminCookie = await login(fixture.baseUrl, 'token-security-admin', 'password123');
- const aliceCookie = await login(fixture.baseUrl, 'token-alice', 'password123');
- const bobCookie = await login(fixture.baseUrl, 'token-bob', 'password123');
- const sendOnly = await createToken(fixture.baseUrl, aliceCookie, {
- name: 'alice send only',
- scopes: ['send']
- });
- const aliceOwner = await createToken(fixture.baseUrl, aliceCookie, {
- name: 'alice owner messages',
- scopes: ['messages:read'],
- mailboxAccess: 'owner'
- });
- const aliceSelected = await createToken(fixture.baseUrl, aliceCookie, {
- name: 'alice selected messages',
- scopes: ['messages:read', 'mailboxes:read'],
- mailboxAccess: 'selected',
- mailboxIds: [seeded.alicePrimaryMailboxId]
- });
- const adminSelected = await createToken(fixture.baseUrl, adminCookie, {
- name: 'admin selected bob',
- scopes: ['messages:read', 'mailboxes:read'],
- mailboxAccess: 'selected',
- mailboxIds: [seeded.bobMailboxId]
- });
- const adminAll = await createToken(fixture.baseUrl, adminCookie, {
- name: 'admin all messages',
- scopes: ['messages:read', 'mailboxes:read'],
- mailboxAccess: 'all'
- });
- await t.test('Bearer message reads are scope-limited, isolated, and read-only', async () => {
- const deniedByScope = await requestJson(fixture.baseUrl, '/api/inbound-messages', {
- bearer: sendOnly.token
- });
- assert.equal(deniedByScope.status, 403);
- assert.match(deniedByScope.body.error, /messages:read/);
- const ownerList = await requestJson(fixture.baseUrl, '/api/inbound-messages?page=1&pageSize=100', {
- bearer: aliceOwner.token
- });
- assert.equal(ownerList.status, 200);
- assert.equal(ownerList.body.total, 2);
- assert.deepEqual(
- new Set(ownerList.body.messages.map((message) => message.mailboxId)),
- new Set([seeded.alicePrimaryMailboxId, seeded.aliceSecondaryMailboxId])
- );
- assert.equal(ownerList.body.messages.some((message) => message.mailboxId === seeded.bobMailboxId), false);
- const ownerCrossUserFilter = await requestJson(
- fixture.baseUrl,
- `/api/inbound-messages?mailboxId=${seeded.bobMailboxId}`,
- { bearer: aliceOwner.token }
- );
- assert.equal(ownerCrossUserFilter.status, 200);
- assert.equal(ownerCrossUserFilter.body.total, 0);
- assert.deepEqual(ownerCrossUserFilter.body.messages, []);
- const selectedList = await requestJson(fixture.baseUrl, '/api/inbound-messages?page=1&pageSize=100', {
- bearer: aliceSelected.token
- });
- assert.equal(selectedList.status, 200);
- assert.equal(selectedList.body.total, 1);
- assert.deepEqual(selectedList.body.messages.map((message) => message.id), [seeded.alicePrimaryMessageId]);
- const selectedMailboxes = await requestJson(fixture.baseUrl, '/api/mailboxes', {
- bearer: aliceSelected.token
- });
- assert.equal(selectedMailboxes.status, 200);
- assert.deepEqual(selectedMailboxes.body.mailboxes.map((mailbox) => mailbox.id), [seeded.alicePrimaryMailboxId]);
- const selectedUnlistedFilter = await requestJson(
- fixture.baseUrl,
- `/api/inbound-messages?mailboxId=${seeded.aliceSecondaryMailboxId}`,
- { bearer: aliceSelected.token }
- );
- assert.equal(selectedUnlistedFilter.status, 200);
- assert.equal(selectedUnlistedFilter.body.total, 0);
- const selectedAuthorizedDetail = await requestJson(
- fixture.baseUrl,
- `/api/inbound-messages/${seeded.alicePrimaryMessageId}`,
- { bearer: aliceSelected.token }
- );
- assert.equal(selectedAuthorizedDetail.status, 200);
- assert.equal(selectedAuthorizedDetail.body.message.subject, 'Alice primary message');
- for (const [pathname, bearer] of [
- [`/api/inbound-messages/${seeded.bobMessageId}`, aliceOwner.token],
- [`/api/inbound-messages/${seeded.aliceSecondaryMessageId}`, aliceSelected.token],
- [`/api/inbound-mailboxes/${seeded.bobMailboxId}/folders`, aliceOwner.token],
- [`/api/inbound-mailboxes/${seeded.aliceSecondaryMailboxId}/folders`, aliceSelected.token]
- ]) {
- const hidden = await requestJson(fixture.baseUrl, pathname, { bearer });
- assert.equal(hidden.status, 404, pathname);
- }
- const adminSelectedList = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', {
- bearer: adminSelected.token
- });
- assert.equal(adminSelectedList.status, 200);
- assert.equal(adminSelectedList.body.total, 1);
- assert.deepEqual(adminSelectedList.body.messages.map((message) => message.id), [seeded.bobMessageId]);
- const adminSelectedMailboxes = await requestJson(fixture.baseUrl, '/api/mailboxes', {
- bearer: adminSelected.token
- });
- assert.equal(adminSelectedMailboxes.status, 200);
- assert.deepEqual(adminSelectedMailboxes.body.mailboxes.map((mailbox) => mailbox.id), [seeded.bobMailboxId]);
- const adminSelectedDetail = await requestJson(
- fixture.baseUrl,
- `/api/inbound-messages/${seeded.bobMessageId}`,
- { bearer: adminSelected.token }
- );
- assert.equal(adminSelectedDetail.status, 200);
- assert.equal(adminSelectedDetail.body.message.subject, 'Bob private message');
- const adminAllList = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', {
- bearer: adminAll.token
- });
- assert.equal(adminAllList.status, 200);
- assert.equal(adminAllList.body.total, 4);
- assert.equal(adminAllList.body.messages.length, 4);
- const adminAllMailboxList = await requestJson(fixture.baseUrl, '/api/mailboxes', {
- bearer: adminAll.token
- });
- assert.equal(adminAllMailboxList.status, 200);
- assert.equal(adminAllMailboxList.body.mailboxes.length, 4);
- const deniedMutation = await requestJson(
- fixture.baseUrl,
- `/api/inbound-messages/${seeded.alicePrimaryMessageId}`,
- {
- method: 'PATCH',
- bearer: aliceSelected.token,
- body: { read: true }
- }
- );
- assert.equal(deniedMutation.status, 401);
- const unchanged = await requestJson(
- fixture.baseUrl,
- `/api/inbound-messages/${seeded.alicePrimaryMessageId}`,
- { cookie: aliceCookie }
- );
- assert.equal(unchanged.status, 200);
- assert.equal(unchanged.body.message.read, false);
- });
- await t.test('session token management reveals recoverable values and enforces admin-only all-mailbox access', async () => {
- const aliceTokens = await requestJson(fixture.baseUrl, '/api/api-tokens', { cookie: aliceCookie });
- assert.equal(aliceTokens.status, 200);
- assert.match(aliceTokens.response.headers.get('cache-control') || '', /no-store/i);
- for (const created of [sendOnly, aliceOwner, aliceSelected]) {
- const listed = aliceTokens.body.tokens.find((token) => token.id === created.id);
- assert.ok(listed, created.name);
- assert.equal(listed.tokenRecoverable, true);
- assert.equal(listed.token, created.token);
- }
- const adminTokens = await requestJson(fixture.baseUrl, '/api/api-tokens', { cookie: adminCookie });
- assert.equal(adminTokens.status, 200);
- assert.match(adminTokens.response.headers.get('cache-control') || '', /no-store/i);
- const historical = adminTokens.body.tokens.find((token) => token.id === seeded.historicalTokenId);
- assert.ok(historical);
- assert.equal(historical.name, 'historical unrecoverable');
- assert.equal(historical.tokenRecoverable, false);
- assert.equal(Object.hasOwn(historical, 'token'), false);
- const basicAdminTokens = await requestJson(fixture.baseUrl, '/api/api-tokens', {
- basic: ['admin', 'password123']
- });
- assert.equal(basicAdminTokens.status, 200);
- assert.equal(basicAdminTokens.body.tokens.every((token) => !Object.hasOwn(token, 'token')), true);
- const ordinaryAll = await requestJson(fixture.baseUrl, '/api/api-tokens', {
- method: 'POST',
- cookie: aliceCookie,
- body: {
- name: 'ordinary all denied',
- scopes: ['messages:read'],
- mailboxAccess: 'all'
- }
- });
- assert.equal(ordinaryAll.status, 400);
- assert.match(ordinaryAll.body.error, /管理员/);
- const ordinaryCrossUserSelected = await requestJson(fixture.baseUrl, '/api/api-tokens', {
- method: 'POST',
- cookie: aliceCookie,
- body: {
- name: 'ordinary cross-user selected denied',
- scopes: ['messages:read'],
- mailboxAccess: 'selected',
- mailboxIds: [seeded.bobMailboxId]
- }
- });
- assert.equal(ordinaryCrossUserSelected.status, 400);
- assert.match(ordinaryCrossUserSelected.body.error, /不存在|无权访问/);
- const adminAllMailboxes = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?all=true', {
- cookie: adminCookie
- });
- assert.equal(adminAllMailboxes.status, 200);
- assert.equal(adminAllMailboxes.body.mailboxes.length, 4);
- assert.deepEqual(
- new Set(adminAllMailboxes.body.mailboxes.map((mailbox) => mailbox.id)),
- new Set([
- seeded.adminMailboxId,
- seeded.alicePrimaryMailboxId,
- seeded.aliceSecondaryMailboxId,
- seeded.bobMailboxId
- ])
- );
- const ordinaryAllMailboxes = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?all=true', {
- cookie: bobCookie
- });
- assert.equal(ordinaryAllMailboxes.status, 403);
- const basicAdminAllMailboxes = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?all=true', {
- basic: ['admin', 'password123']
- });
- assert.equal(basicAdminAllMailboxes.status, 403);
- const rotated = await requestJson(fixture.baseUrl, `/api/api-tokens/${aliceOwner.id}/rotate`, {
- method: 'POST',
- cookie: aliceCookie
- });
- assert.equal(rotated.status, 200);
- assert.match(rotated.response.headers.get('cache-control') || '', /no-store/i);
- assert.equal(rotated.body.token.id, aliceOwner.id);
- assert.equal(rotated.body.token.tokenRecoverable, true);
- assert.ok(rotated.body.token.token);
- assert.notEqual(rotated.body.token.token, aliceOwner.token);
- const oldTokenRejected = await requestJson(fixture.baseUrl, '/api/inbound-messages', {
- bearer: aliceOwner.token
- });
- assert.equal(oldTokenRejected.status, 401);
- const newTokenAccepted = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', {
- bearer: rotated.body.token.token
- });
- assert.equal(newTokenAccepted.status, 200);
- assert.equal(newTokenAccepted.body.total, 2);
- const listAfterRotate = await requestJson(fixture.baseUrl, '/api/api-tokens', { cookie: aliceCookie });
- const rotatedSummary = listAfterRotate.body.tokens.find((token) => token.id === aliceOwner.id);
- assert.equal(rotatedSummary.token, rotated.body.token.token);
- assert.notEqual(rotatedSummary.token, aliceOwner.token);
- const demoted = await requestJson(fixture.baseUrl, `/api/admin/users/${seeded.adminUserId}`, {
- method: 'PATCH',
- cookie: securityAdminCookie,
- body: { role: 'user' }
- });
- assert.equal(demoted.status, 200);
- assert.equal(demoted.body.user.role, 'user');
- const allMessagesAfterDemotion = await requestJson(fixture.baseUrl, '/api/inbound-messages', {
- bearer: adminAll.token
- });
- assert.equal(allMessagesAfterDemotion.status, 200);
- assert.equal(allMessagesAfterDemotion.body.total, 0);
- const allMailboxesAfterDemotion = await requestJson(fixture.baseUrl, '/api/mailboxes', {
- bearer: adminAll.token
- });
- assert.equal(allMailboxesAfterDemotion.status, 200);
- assert.deepEqual(allMailboxesAfterDemotion.body.mailboxes, []);
- });
- } finally {
- fixture.child.kill('SIGTERM');
- await waitForExit(fixture.child, 1000);
- }
- });
- async function createToken(baseUrl, cookie, input) {
- const result = await requestJson(baseUrl, '/api/api-tokens', {
- method: 'POST',
- cookie,
- body: input
- });
- assert.equal(result.status, 201, JSON.stringify(result.body));
- assert.match(result.response.headers.get('cache-control') || '', /no-store/i);
- assert.ok(result.body.token.token);
- return result.body.token;
- }
- function seedInboundAccessFixtures(dataDir, sessionSecret) {
- const script = `
- import { DatabaseSync } from 'node:sqlite';
- import path from 'node:path';
- import {
- createApiToken,
- createDomain,
- createInboundMailbox,
- createInboundMessage,
- createUser,
- getUserByLogin,
- initDatabase
- } from './src/db.js';
- initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
- const admin = getUserByLogin('admin');
- const alice = createUser({
- username: 'token-alice',
- email: 'token-alice@example.test',
- password: 'password123',
- status: 'active'
- });
- const bob = createUser({
- username: 'token-bob',
- email: 'token-bob@example.test',
- password: 'password123',
- status: 'active'
- });
- createUser({
- username: 'token-security-admin',
- email: 'token-security-admin@example.test',
- password: 'password123',
- role: 'admin',
- status: 'active'
- });
- const createUserDomain = (user, name) => createDomain(user.id, {
- domain: name,
- selector: 'mh',
- verificationToken: 'verify-' + name,
- dkimPublic: 'public-' + name,
- dkimPrivate: 'private-' + name,
- senderHost: 'mail.' + name,
- sendingIp: '127.0.0.1',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- createUserDomain(admin, 'token-admin.example');
- createUserDomain(alice, 'token-alice.example');
- createUserDomain(bob, 'token-bob.example');
- const adminMailbox = createInboundMailbox(admin.id, {
- address: 'inbox@token-admin.example',
- password: 'mailbox-password'
- });
- const alicePrimaryMailbox = createInboundMailbox(alice.id, {
- address: 'primary@token-alice.example',
- password: 'mailbox-password'
- });
- const aliceSecondaryMailbox = createInboundMailbox(alice.id, {
- address: 'secondary@token-alice.example',
- password: 'mailbox-password'
- });
- const bobMailbox = createInboundMailbox(bob.id, {
- address: 'inbox@token-bob.example',
- password: 'mailbox-password'
- });
- const createMessage = (mailbox, subject, sequence) => createInboundMessage(mailbox, {
- sender: 'sender@example.net',
- recipients: [mailbox.address],
- subject,
- messageId: '<token-inbound-' + sequence + '@example.net>',
- rawMessage: 'Subject: ' + subject + '\\r\\n\\r\\n' + subject,
- textBody: subject,
- receivedAt: '2026-07-14T0' + sequence + ':00:00.000Z'
- });
- const adminMessage = createMessage(adminMailbox, 'Admin private message', 1);
- const alicePrimaryMessage = createMessage(alicePrimaryMailbox, 'Alice primary message', 2);
- const aliceSecondaryMessage = createMessage(aliceSecondaryMailbox, 'Alice secondary message', 3);
- const bobMessage = createMessage(bobMailbox, 'Bob private message', 4);
- const historical = createApiToken(admin.id, 'historical unrecoverable', {
- scopes: ['messages:read'],
- mailboxAccess: 'owner'
- });
- const database = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
- database.prepare("UPDATE api_tokens SET token_secret = '' WHERE id = ?").run(historical.id);
- database.close();
- console.log(JSON.stringify({
- adminUserId: admin.id,
- adminMailboxId: adminMailbox.id,
- alicePrimaryMailboxId: alicePrimaryMailbox.id,
- aliceSecondaryMailboxId: aliceSecondaryMailbox.id,
- bobMailboxId: bobMailbox.id,
- adminMessageId: adminMessage.id,
- alicePrimaryMessageId: alicePrimaryMessage.id,
- aliceSecondaryMessageId: aliceSecondaryMessage.id,
- bobMessageId: bobMessage.id,
- historicalTokenId: historical.id
- }));
- `;
- 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 requestJson(baseUrl, pathname, {
- method = 'GET',
- cookie = '',
- bearer = '',
- basic = null,
- body
- } = {}) {
- const headers = {};
- if (cookie) headers.Cookie = cookie;
- if (bearer) headers.Authorization = `Bearer ${bearer}`;
- if (basic) headers.Authorization = `Basic ${Buffer.from(basic.join(':')).toString('base64')}`;
- 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 text = await response.text();
- return {
- response,
- status: response.status,
- body: text ? JSON.parse(text) : null
- };
- }
- async function login(baseUrl, username, password) {
- const response = await fetch(`${baseUrl}/api/login`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ username, password })
- });
- assert.equal(response.status, 200);
- const cookie = response.headers.get('set-cookie')?.split(';')[0] || '';
- assert.ok(cookie);
- return cookie;
- }
- async function startTestServer() {
- const port = await freePort();
- const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-token-inbound-auth-'));
- const sessionSecret = 'token-inbound-auth-session-secret';
- const child = spawn(process.execPath, ['src/server.js'], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- PORT: String(port),
- DATA_DIR: dataDir,
- SESSION_SECRET: sessionSecret,
- ADMIN_USER: 'admin',
- ADMIN_EMAIL: 'admin@example.test',
- ADMIN_PASSWORD: 'password123',
- DNS_AUTO_CHECK_ENABLED: 'false',
- DELIVERY_TRACKING_ENABLED: 'false',
- WEBHOOK_WORKER_ENABLED: 'false',
- SUBMISSION_ENABLED: 'false',
- IMAP_ENABLED: 'false',
- POP3_ENABLED: 'false'
- },
- stdio: ['ignore', 'pipe', 'pipe']
- });
- await waitForOutput(child, 'MailHub listening');
- return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
- }
- function freePort() {
- return new Promise((resolve, reject) => {
- const server = net.createServer();
- server.listen(0, '127.0.0.1', () => {
- const address = server.address();
- server.close(() => {
- if (address && typeof address === 'object') resolve(address.port);
- else reject(new Error('Unable to allocate a test port.'));
- });
- });
- });
- }
- function waitForOutput(child, text) {
- return new Promise((resolve, reject) => {
- const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}`)), 5000);
- let output = '';
- const onData = (chunk) => {
- output += chunk.toString();
- if (!output.includes(text)) return;
- clearTimeout(timeout);
- child.stdout.off('data', onData);
- resolve();
- };
- child.stdout.on('data', onData);
- child.once('exit', (code) => {
- clearTimeout(timeout);
- reject(new Error(`Server exited before startup with code ${code}`));
- });
- });
- }
- function waitForExit(child, timeoutMs) {
- if (child.exitCode !== null) return Promise.resolve(child.exitCode);
- return new Promise((resolve) => {
- const timeout = setTimeout(() => resolve(null), timeoutMs);
- child.once('exit', (code) => {
- clearTimeout(timeout);
- resolve(code);
- });
- });
- }
|