webmail-sso-http.test.js 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync, writeFileSync } from 'node:fs';
  3. import http from 'node:http';
  4. import { tmpdir } from 'node:os';
  5. import path from 'node:path';
  6. import { test } from 'node:test';
  7. import {
  8. handleInternalWebmailSso,
  9. isSameOriginRequest,
  10. loadWebmailSsoConfig
  11. } from '../src/webmail-sso.js';
  12. const secret = 'ab'.repeat(32);
  13. const audience = 'https://mail.us.ss5.xyz';
  14. const ticket = `mht_${'a'.repeat(43)}`;
  15. const credential = `mhw_${'b'.repeat(43)}`;
  16. test('Webmail SSO configuration requires HTTPS and a strong file-backed secret', () => {
  17. const secretFile = writeSecret(secret);
  18. const config = loadWebmailSsoConfig({
  19. WEBMAIL_SSO_URL: `${audience}/?_task=mail`,
  20. WEBMAIL_SSO_SECRET_FILE: secretFile,
  21. WEBMAIL_SSO_TICKET_TTL_SECONDS: '45',
  22. WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS: '3600'
  23. });
  24. assert.equal(config.enabled, true);
  25. assert.equal(config.url, `${audience}/`);
  26. assert.equal(config.audience, audience);
  27. assert.equal(config.ticketTtlSeconds, 45);
  28. assert.equal(config.credentialTtlSeconds, 3600);
  29. assert.throws(
  30. () => loadWebmailSsoConfig({ WEBMAIL_SSO_URL: audience }),
  31. /configured together/
  32. );
  33. assert.throws(
  34. () => loadWebmailSsoConfig({
  35. WEBMAIL_SSO_URL: 'http://webmail.example.com/',
  36. WEBMAIL_SSO_SECRET_FILE: secretFile
  37. }),
  38. /must use HTTPS/
  39. );
  40. assert.throws(
  41. () => loadWebmailSsoConfig({
  42. WEBMAIL_SSO_URL: audience,
  43. WEBMAIL_SSO_SECRET_FILE: writeSecret('short')
  44. }),
  45. /64-512 hexadecimal/
  46. );
  47. assert.throws(
  48. () => loadWebmailSsoConfig({
  49. WEBMAIL_SSO_URL: audience,
  50. WEBMAIL_SSO_SECRET_FILE: writeSecret('z'.repeat(64))
  51. }),
  52. /64-512 hexadecimal/
  53. );
  54. });
  55. test('Webmail ticket creation origin check accepts only the configured or proxied application origin', () => {
  56. const request = {
  57. headers: {
  58. origin: 'https://mail-send.ss5.xyz',
  59. host: 'app:3000',
  60. 'x-forwarded-host': 'mail-send.ss5.xyz',
  61. 'x-forwarded-proto': 'https'
  62. },
  63. socket: { encrypted: false }
  64. };
  65. assert.equal(isSameOriginRequest(request, 'https://mail-send.ss5.xyz/', { trustProxy: true }), true);
  66. assert.equal(isSameOriginRequest({ ...request, headers: { ...request.headers, origin: 'https://evil.example' } }, 'https://mail-send.ss5.xyz/', { trustProxy: true }), false);
  67. assert.equal(isSameOriginRequest({ ...request, headers: { ...request.headers, origin: '' } }, 'https://mail-send.ss5.xyz/', { trustProxy: true }), false);
  68. });
  69. test('Webmail internal exchange is authenticated, audience-bound, single purpose, and private', async () => {
  70. const exchanges = [];
  71. const revocations = [];
  72. const config = loadWebmailSsoConfig({
  73. WEBMAIL_SSO_URL: `${audience}/`,
  74. WEBMAIL_SSO_SECRET_FILE: writeSecret(secret)
  75. });
  76. const server = http.createServer((req, res) => {
  77. const url = new URL(req.url || '/', 'http://mailhub.internal');
  78. void handleInternalWebmailSso(req, res, url, config, {
  79. exchangeWebmailLoginTicket(value, options) {
  80. exchanges.push({ value, options });
  81. if (exchanges.length > 1) return null;
  82. return {
  83. mailbox: { address: 'shared@example.com' },
  84. credential,
  85. expiresAt: '2030-01-01T00:00:00.000Z'
  86. };
  87. },
  88. revokeWebmailCredential(value, options) {
  89. revocations.push({ value, options });
  90. return true;
  91. }
  92. });
  93. });
  94. await listen(server);
  95. try {
  96. const unauthorized = await request(server, '/internal/webmail-sso/exchange', {
  97. authorization: 'Bearer wrong-secret',
  98. body: { ticket, audience }
  99. });
  100. assert.equal(unauthorized.status, 401);
  101. assert.equal(exchanges.length, 0);
  102. const wrongAudience = await request(server, '/internal/webmail-sso/exchange', {
  103. body: { ticket, audience: 'https://other.example' }
  104. });
  105. assert.equal(wrongAudience.status, 400);
  106. assert.equal(exchanges.length, 0);
  107. const exchanged = await request(server, '/internal/webmail-sso/exchange', {
  108. body: { ticket, audience }
  109. });
  110. assert.equal(exchanged.status, 200);
  111. assert.equal(exchanged.headers['cache-control'], 'no-store');
  112. assert.deepEqual(exchanged.json, {
  113. username: 'shared@example.com',
  114. credential,
  115. expiresAt: '2030-01-01T00:00:00.000Z'
  116. });
  117. assert.deepEqual(exchanges, [{ value: ticket, options: { audience } }]);
  118. const replayed = await request(server, '/internal/webmail-sso/exchange', {
  119. body: { ticket, audience }
  120. });
  121. assert.equal(replayed.status, 400);
  122. assert.equal(JSON.stringify(replayed.json).includes(ticket), false);
  123. const revoked = await request(server, '/internal/webmail-sso/revoke', {
  124. body: { credential, audience }
  125. });
  126. assert.equal(revoked.status, 200);
  127. assert.deepEqual(revocations, [{ value: credential, options: { audience } }]);
  128. } finally {
  129. await close(server);
  130. }
  131. });
  132. function writeSecret(value) {
  133. const directory = mkdtempSync(path.join(tmpdir(), 'mailhub-webmail-sso-'));
  134. const file = path.join(directory, 'secret');
  135. writeFileSync(file, `${value}\n`, { mode: 0o600 });
  136. return file;
  137. }
  138. function listen(server) {
  139. server.listen(0, '127.0.0.1');
  140. return new Promise((resolve, reject) => {
  141. server.once('listening', resolve);
  142. server.once('error', reject);
  143. });
  144. }
  145. function close(server) {
  146. return new Promise((resolve, reject) => {
  147. server.close((error) => error ? reject(error) : resolve());
  148. });
  149. }
  150. function request(server, requestPath, {
  151. authorization = `Bearer ${secret}`,
  152. body = {}
  153. } = {}) {
  154. const rawBody = JSON.stringify(body);
  155. return new Promise((resolve, reject) => {
  156. const req = http.request({
  157. host: '127.0.0.1',
  158. port: server.address().port,
  159. path: requestPath,
  160. method: 'POST',
  161. headers: {
  162. Authorization: authorization,
  163. 'Content-Type': 'application/json',
  164. 'Content-Length': String(Buffer.byteLength(rawBody))
  165. }
  166. }, (res) => {
  167. const chunks = [];
  168. res.on('data', (chunk) => chunks.push(chunk));
  169. res.on('end', () => {
  170. const raw = Buffer.concat(chunks).toString('utf8');
  171. resolve({
  172. status: res.statusCode,
  173. headers: res.headers,
  174. json: raw ? JSON.parse(raw) : null
  175. });
  176. });
  177. });
  178. req.once('error', reject);
  179. req.end(rawBody);
  180. });
  181. }