server-webmail-sso.test.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. import assert from 'node:assert/strict';
  2. import { spawn, spawnSync } from 'node:child_process';
  3. import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
  4. import net from 'node:net';
  5. import { tmpdir } from 'node:os';
  6. import path from 'node:path';
  7. import process from 'node:process';
  8. import { test } from 'node:test';
  9. const webmailUrl = 'https://mail.us.ss5.xyz/';
  10. const webmailAudience = 'https://mail.us.ss5.xyz';
  11. const sharedSecret = 'cd'.repeat(32);
  12. const userPassword = 'password123';
  13. test('Webmail SSO requires an authorized MailHub session and safely exchanges one-time tickets', async (t) => {
  14. const fixture = await startTestServer();
  15. t.after(async () => {
  16. await stopChild(fixture.child);
  17. rmSync(fixture.tempDir, { recursive: true, force: true });
  18. });
  19. const ownerCookie = await login(fixture.baseUrl, 'sso-owner', userPassword);
  20. const receiverCookie = await login(fixture.baseUrl, 'sso-receiver', userPassword);
  21. const viewerCookie = await login(fixture.baseUrl, 'sso-viewer', userPassword);
  22. const adminCookie = await login(fixture.baseUrl, 'sso-admin', userPassword);
  23. const loginPath = `/api/inbound-mailboxes/${fixture.seed.mailboxId}/webmail-login`;
  24. await t.test('anonymous, Basic, and Bearer requests cannot issue a login ticket', async () => {
  25. const anonymous = await requestJson(fixture.baseUrl, loginPath, {
  26. method: 'POST',
  27. origin: fixture.baseUrl
  28. });
  29. assert.equal(anonymous.status, 401);
  30. assertNoOpaqueCredential(anonymous.raw);
  31. const basic = await requestJson(fixture.baseUrl, loginPath, {
  32. method: 'POST',
  33. origin: fixture.baseUrl,
  34. authorization: `Basic ${Buffer.from(`sso-owner:${userPassword}`).toString('base64')}`
  35. });
  36. assert.equal(basic.status, 403);
  37. assertNoOpaqueCredential(basic.raw);
  38. const bearer = await requestJson(fixture.baseUrl, loginPath, {
  39. method: 'POST',
  40. origin: fixture.baseUrl,
  41. authorization: `Bearer ${fixture.seed.ownerToken}`
  42. });
  43. assert.equal(bearer.status, 401);
  44. assertNoOpaqueCredential(bearer.raw);
  45. });
  46. await t.test('ticket issuance rejects missing and cross-origin requests', async () => {
  47. const missingOrigin = await requestJson(fixture.baseUrl, loginPath, {
  48. method: 'POST',
  49. cookie: ownerCookie
  50. });
  51. assert.equal(missingOrigin.status, 403);
  52. assertNoOpaqueCredential(missingOrigin.raw);
  53. const crossOrigin = await requestJson(fixture.baseUrl, loginPath, {
  54. method: 'POST',
  55. cookie: ownerCookie,
  56. origin: 'https://evil.example'
  57. });
  58. assert.equal(crossOrigin.status, 403);
  59. assertNoOpaqueCredential(crossOrigin.raw);
  60. });
  61. let ownerTicket = '';
  62. await t.test('the owner and receive delegate can issue private one-time tickets', async () => {
  63. const owner = await issueTicket(fixture.baseUrl, loginPath, ownerCookie);
  64. ownerTicket = owner.body.webmailLogin.ticket;
  65. assert.equal(owner.body.webmailLogin.action, webmailUrl);
  66. assert.match(ownerTicket, /^mht_[A-Za-z0-9_-]{43}$/);
  67. assert.ok(Date.parse(owner.body.webmailLogin.expiresAt) > Date.now());
  68. const receiver = await issueTicket(fixture.baseUrl, loginPath, receiverCookie);
  69. assert.equal(receiver.body.webmailLogin.action, webmailUrl);
  70. assert.match(receiver.body.webmailLogin.ticket, /^mht_[A-Za-z0-9_-]{43}$/);
  71. assert.notEqual(receiver.body.webmailLogin.ticket, ownerTicket);
  72. });
  73. await t.test('view-only users and administrators have no implicit login access', async () => {
  74. for (const cookie of [viewerCookie, adminCookie]) {
  75. const response = await requestJson(fixture.baseUrl, loginPath, {
  76. method: 'POST',
  77. cookie,
  78. origin: fixture.baseUrl
  79. });
  80. assert.equal(response.status, 404);
  81. assertNoOpaqueCredential(response.raw);
  82. assert.equal(response.raw.includes(fixture.seed.mailboxAddress), false);
  83. }
  84. });
  85. await t.test('the internal exchange authenticates, consumes once, and revokes the credential', async () => {
  86. const exchangePath = '/internal/webmail-sso/exchange';
  87. const wrongSecret = await requestJson(fixture.baseUrl, exchangePath, {
  88. method: 'POST',
  89. authorization: 'Bearer definitely-wrong-secret',
  90. body: { ticket: ownerTicket, audience: webmailAudience }
  91. });
  92. assert.equal(wrongSecret.status, 401);
  93. assertPrivateResponse(wrongSecret);
  94. assert.equal(wrongSecret.raw.includes(ownerTicket), false);
  95. assertNoOpaqueCredential(wrongSecret.raw);
  96. const exchanged = await requestJson(fixture.baseUrl, exchangePath, {
  97. method: 'POST',
  98. authorization: `Bearer ${sharedSecret}`,
  99. body: { ticket: ownerTicket, audience: webmailAudience }
  100. });
  101. assert.equal(exchanged.status, 200, exchanged.raw);
  102. assertPrivateResponse(exchanged);
  103. assert.equal(exchanged.body.username, fixture.seed.mailboxAddress);
  104. assert.match(exchanged.body.credential, /^mhw_[A-Za-z0-9_-]{43}$/);
  105. assert.ok(Date.parse(exchanged.body.expiresAt) > Date.now());
  106. const credential = exchanged.body.credential;
  107. const replay = await requestJson(fixture.baseUrl, exchangePath, {
  108. method: 'POST',
  109. authorization: `Bearer ${sharedSecret}`,
  110. body: { ticket: ownerTicket, audience: webmailAudience }
  111. });
  112. assert.equal(replay.status, 400);
  113. assertPrivateResponse(replay);
  114. assert.equal(replay.raw.includes(ownerTicket), false);
  115. assert.equal(replay.raw.includes(credential), false);
  116. assertNoOpaqueCredential(replay.raw);
  117. const revoked = await requestJson(fixture.baseUrl, '/internal/webmail-sso/revoke', {
  118. method: 'POST',
  119. authorization: `Bearer ${sharedSecret}`,
  120. body: { credential, audience: webmailAudience }
  121. });
  122. assert.equal(revoked.status, 200, revoked.raw);
  123. assertPrivateResponse(revoked);
  124. assert.deepEqual(revoked.body, { revoked: true });
  125. assert.equal(revoked.raw.includes(credential), false);
  126. });
  127. await t.test('ticket issuance returns 429 after the bounded per-user burst', async () => {
  128. // The owner already created one ticket earlier in this fixture.
  129. for (let index = 1; index < 20; index += 1) {
  130. const response = await requestJson(fixture.baseUrl, loginPath, {
  131. method: 'POST',
  132. cookie: ownerCookie,
  133. origin: fixture.baseUrl
  134. });
  135. assert.equal(response.status, 201, response.raw);
  136. }
  137. const limited = await requestJson(fixture.baseUrl, loginPath, {
  138. method: 'POST',
  139. cookie: ownerCookie,
  140. origin: fixture.baseUrl
  141. });
  142. assert.equal(limited.status, 429, limited.raw);
  143. assert.equal(limited.headers.get('retry-after'), '60');
  144. assert.match(limited.body.error, /请求过于频繁/);
  145. assertNoOpaqueCredential(limited.raw);
  146. });
  147. });
  148. async function issueTicket(baseUrl, pathname, cookie) {
  149. const response = await requestJson(baseUrl, pathname, {
  150. method: 'POST',
  151. cookie,
  152. origin: baseUrl
  153. });
  154. assert.equal(response.status, 201, response.raw);
  155. assertPrivateResponse(response);
  156. return response;
  157. }
  158. async function startTestServer() {
  159. const port = await freePort();
  160. const baseUrl = `http://127.0.0.1:${port}`;
  161. const tempDir = mkdtempSync(path.join(tmpdir(), 'mailhub-server-webmail-sso-'));
  162. const dataDir = path.join(tempDir, 'data');
  163. const secretFile = path.join(tempDir, 'webmail-sso-secret');
  164. const sessionSecret = 'mailhub-webmail-server-session-secret';
  165. writeFileSync(secretFile, `${sharedSecret}\n`, { mode: 0o600 });
  166. chmodSync(secretFile, 0o600);
  167. const seed = seedFixture(dataDir, sessionSecret);
  168. const child = spawn(process.execPath, ['src/server.js'], {
  169. cwd: process.cwd(),
  170. env: {
  171. ...process.env,
  172. PORT: String(port),
  173. DATA_DIR: dataDir,
  174. APP_BASE_URL: baseUrl,
  175. SESSION_SECRET: sessionSecret,
  176. ADMIN_USER: 'sso-admin',
  177. ADMIN_EMAIL: 'sso-admin@example.test',
  178. ADMIN_PASSWORD: userPassword,
  179. WEBMAIL_SSO_URL: webmailUrl,
  180. WEBMAIL_SSO_SECRET_FILE: secretFile,
  181. WEBMAIL_SSO_TICKET_TTL_SECONDS: '60',
  182. WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS: '3600',
  183. DNS_AUTO_CHECK_ENABLED: 'false',
  184. DELIVERY_TRACKING_ENABLED: 'false',
  185. WEBHOOK_WORKER_ENABLED: 'false',
  186. SUBMISSION_ENABLED: 'false',
  187. IMAP_ENABLED: 'false',
  188. POP3_ENABLED: 'false',
  189. DOVECOT_AUTH_ENABLED: 'false',
  190. MAIL_ACCESS_BACKEND: 'legacy'
  191. },
  192. stdio: ['ignore', 'pipe', 'pipe']
  193. });
  194. try {
  195. await waitForServer(child, baseUrl);
  196. return { child, baseUrl, tempDir, seed };
  197. } catch (error) {
  198. await stopChild(child);
  199. rmSync(tempDir, { recursive: true, force: true });
  200. throw error;
  201. }
  202. }
  203. function seedFixture(dataDir, sessionSecret) {
  204. const script = `
  205. import {
  206. createApiToken,
  207. createDomain,
  208. createInboundMailbox,
  209. createUser,
  210. initDatabase,
  211. replaceInboundMailboxGrants
  212. } from './src/db.js';
  213. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  214. const admin = createUser({
  215. username: 'sso-admin',
  216. email: 'sso-admin@example.test',
  217. password: '${userPassword}',
  218. role: 'admin',
  219. status: 'active'
  220. });
  221. const owner = createUser({
  222. username: 'sso-owner',
  223. email: 'sso-owner@example.test',
  224. password: '${userPassword}',
  225. status: 'active'
  226. });
  227. const receiver = createUser({
  228. username: 'sso-receiver',
  229. email: 'sso-receiver@example.test',
  230. password: '${userPassword}',
  231. status: 'active'
  232. });
  233. const viewer = createUser({
  234. username: 'sso-viewer',
  235. email: 'sso-viewer@example.test',
  236. password: '${userPassword}',
  237. status: 'active'
  238. });
  239. createDomain(owner.id, {
  240. domain: 'webmail-sso.example',
  241. selector: 'mh',
  242. verificationToken: 'verify-webmail-sso',
  243. dkimPublic: 'test-public-key',
  244. dkimPrivate: 'test-private-key',
  245. senderHost: 'mail.webmail-sso.example',
  246. sendingIp: '127.0.0.1',
  247. spfExtra: '',
  248. dmarcPolicy: 'none',
  249. dmarcRua: ''
  250. });
  251. const mailbox = createInboundMailbox(owner.id, {
  252. address: 'shared@webmail-sso.example',
  253. password: 'mailbox-password'
  254. });
  255. replaceInboundMailboxGrants(mailbox.id, [
  256. { userId: receiver.id, permissions: ['receive'] },
  257. { userId: viewer.id, permissions: ['view'] }
  258. ]);
  259. const ownerToken = createApiToken(owner.id, 'Webmail route rejection', {
  260. scopes: ['messages:read'],
  261. mailboxAccess: 'owner'
  262. });
  263. console.log(JSON.stringify({
  264. adminUserId: admin.id,
  265. ownerUserId: owner.id,
  266. receiverUserId: receiver.id,
  267. viewerUserId: viewer.id,
  268. mailboxId: mailbox.id,
  269. mailboxAddress: mailbox.address,
  270. ownerToken: ownerToken.token
  271. }));
  272. `;
  273. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  274. cwd: process.cwd(),
  275. env: {
  276. ...process.env,
  277. DATA_DIR: dataDir,
  278. SESSION_SECRET: sessionSecret
  279. },
  280. encoding: 'utf8'
  281. });
  282. assert.equal(result.status, 0, result.stderr || result.stdout);
  283. return JSON.parse(result.stdout);
  284. }
  285. async function login(baseUrl, username, password) {
  286. const response = await requestJson(baseUrl, '/api/login', {
  287. method: 'POST',
  288. body: { username, password }
  289. });
  290. assert.equal(response.status, 200, response.raw);
  291. const cookie = response.headers.get('set-cookie')?.split(';', 1)[0] || '';
  292. assert.ok(cookie);
  293. return cookie;
  294. }
  295. async function requestJson(baseUrl, pathname, {
  296. method = 'GET',
  297. cookie = '',
  298. origin = '',
  299. authorization = '',
  300. body
  301. } = {}) {
  302. const headers = {};
  303. if (cookie) headers.Cookie = cookie;
  304. if (origin) headers.Origin = origin;
  305. if (authorization) headers.Authorization = authorization;
  306. if (body !== undefined) headers['Content-Type'] = 'application/json';
  307. const response = await fetch(`${baseUrl}${pathname}`, {
  308. method,
  309. headers,
  310. body: body === undefined ? undefined : JSON.stringify(body),
  311. redirect: 'manual'
  312. });
  313. const raw = await response.text();
  314. return {
  315. status: response.status,
  316. headers: response.headers,
  317. raw,
  318. body: raw ? JSON.parse(raw) : null
  319. };
  320. }
  321. function assertPrivateResponse(response) {
  322. assert.match(response.headers.get('cache-control') || '', /(?:^|[,\s])no-store(?:[,\s]|$)/);
  323. assert.equal(response.headers.get('pragma'), 'no-cache');
  324. }
  325. function assertNoOpaqueCredential(raw) {
  326. assert.doesNotMatch(raw, /\bmht_[A-Za-z0-9_-]+\b/);
  327. assert.doesNotMatch(raw, /\bmhw_[A-Za-z0-9_-]+\b/);
  328. }
  329. async function waitForServer(child, baseUrl, timeoutMs = 8000) {
  330. let output = '';
  331. child.stdout.on('data', (chunk) => { output += chunk.toString(); });
  332. child.stderr.on('data', (chunk) => { output += chunk.toString(); });
  333. const deadline = Date.now() + timeoutMs;
  334. while (Date.now() < deadline) {
  335. if (child.exitCode !== null) {
  336. throw new Error(`MailHub exited before becoming ready (code ${child.exitCode}).\n${output}`);
  337. }
  338. try {
  339. const response = await fetch(`${baseUrl}/healthz`);
  340. if (response.status === 200) return;
  341. } catch {
  342. // The child process may still be binding its HTTP listener.
  343. }
  344. await delay(50);
  345. }
  346. throw new Error(`Timed out waiting for MailHub.\n${output}`);
  347. }
  348. function freePort() {
  349. return new Promise((resolve, reject) => {
  350. const server = net.createServer();
  351. server.once('error', reject);
  352. server.listen(0, '127.0.0.1', () => {
  353. const address = server.address();
  354. server.close((error) => {
  355. if (error) return reject(error);
  356. if (address && typeof address === 'object') return resolve(address.port);
  357. reject(new Error('Unable to allocate a test port.'));
  358. });
  359. });
  360. });
  361. }
  362. async function stopChild(child) {
  363. if (!child || child.exitCode !== null) return;
  364. child.kill('SIGTERM');
  365. if (await waitForExit(child, 1500)) return;
  366. child.kill('SIGKILL');
  367. await waitForExit(child, 1000);
  368. }
  369. function waitForExit(child, timeoutMs) {
  370. if (child.exitCode !== null) return Promise.resolve(true);
  371. return new Promise((resolve) => {
  372. const onExit = () => {
  373. clearTimeout(timeout);
  374. resolve(true);
  375. };
  376. const timeout = setTimeout(() => {
  377. child.off('exit', onExit);
  378. resolve(false);
  379. }, timeoutMs);
  380. child.once('exit', onExit);
  381. });
  382. }
  383. function delay(milliseconds) {
  384. return new Promise((resolve) => setTimeout(resolve, milliseconds));
  385. }