| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- import assert from 'node:assert/strict';
- import { mkdtempSync, writeFileSync } from 'node:fs';
- import http from 'node:http';
- import { tmpdir } from 'node:os';
- import path from 'node:path';
- import { test } from 'node:test';
- import {
- handleInternalWebmailSso,
- isSameOriginRequest,
- loadWebmailSsoConfig
- } from '../src/webmail-sso.js';
- const secret = 'ab'.repeat(32);
- const audience = 'https://mail.us.ss5.xyz';
- const ticket = `mht_${'a'.repeat(43)}`;
- const credential = `mhw_${'b'.repeat(43)}`;
- test('Webmail SSO configuration requires HTTPS and a strong file-backed secret', () => {
- const secretFile = writeSecret(secret);
- const config = loadWebmailSsoConfig({
- WEBMAIL_SSO_URL: `${audience}/?_task=mail`,
- WEBMAIL_SSO_SECRET_FILE: secretFile,
- WEBMAIL_SSO_TICKET_TTL_SECONDS: '45',
- WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS: '3600'
- });
- assert.equal(config.enabled, true);
- assert.equal(config.url, `${audience}/`);
- assert.equal(config.audience, audience);
- assert.equal(config.ticketTtlSeconds, 45);
- assert.equal(config.credentialTtlSeconds, 3600);
- assert.throws(
- () => loadWebmailSsoConfig({ WEBMAIL_SSO_URL: audience }),
- /configured together/
- );
- assert.throws(
- () => loadWebmailSsoConfig({
- WEBMAIL_SSO_URL: 'http://webmail.example.com/',
- WEBMAIL_SSO_SECRET_FILE: secretFile
- }),
- /must use HTTPS/
- );
- assert.throws(
- () => loadWebmailSsoConfig({
- WEBMAIL_SSO_URL: audience,
- WEBMAIL_SSO_SECRET_FILE: writeSecret('short')
- }),
- /64-512 hexadecimal/
- );
- assert.throws(
- () => loadWebmailSsoConfig({
- WEBMAIL_SSO_URL: audience,
- WEBMAIL_SSO_SECRET_FILE: writeSecret('z'.repeat(64))
- }),
- /64-512 hexadecimal/
- );
- });
- test('Webmail ticket creation origin check accepts only the configured or proxied application origin', () => {
- const request = {
- headers: {
- origin: 'https://mail-send.ss5.xyz',
- host: 'app:3000',
- 'x-forwarded-host': 'mail-send.ss5.xyz',
- 'x-forwarded-proto': 'https'
- },
- socket: { encrypted: false }
- };
- assert.equal(isSameOriginRequest(request, 'https://mail-send.ss5.xyz/', { trustProxy: true }), true);
- assert.equal(isSameOriginRequest({ ...request, headers: { ...request.headers, origin: 'https://evil.example' } }, 'https://mail-send.ss5.xyz/', { trustProxy: true }), false);
- assert.equal(isSameOriginRequest({ ...request, headers: { ...request.headers, origin: '' } }, 'https://mail-send.ss5.xyz/', { trustProxy: true }), false);
- });
- test('Webmail internal exchange is authenticated, audience-bound, single purpose, and private', async () => {
- const exchanges = [];
- const revocations = [];
- const config = loadWebmailSsoConfig({
- WEBMAIL_SSO_URL: `${audience}/`,
- WEBMAIL_SSO_SECRET_FILE: writeSecret(secret)
- });
- const server = http.createServer((req, res) => {
- const url = new URL(req.url || '/', 'http://mailhub.internal');
- void handleInternalWebmailSso(req, res, url, config, {
- exchangeWebmailLoginTicket(value, options) {
- exchanges.push({ value, options });
- if (exchanges.length > 1) return null;
- return {
- mailbox: { address: 'shared@example.com' },
- credential,
- expiresAt: '2030-01-01T00:00:00.000Z'
- };
- },
- revokeWebmailCredential(value, options) {
- revocations.push({ value, options });
- return true;
- }
- });
- });
- await listen(server);
- try {
- const unauthorized = await request(server, '/internal/webmail-sso/exchange', {
- authorization: 'Bearer wrong-secret',
- body: { ticket, audience }
- });
- assert.equal(unauthorized.status, 401);
- assert.equal(exchanges.length, 0);
- const wrongAudience = await request(server, '/internal/webmail-sso/exchange', {
- body: { ticket, audience: 'https://other.example' }
- });
- assert.equal(wrongAudience.status, 400);
- assert.equal(exchanges.length, 0);
- const exchanged = await request(server, '/internal/webmail-sso/exchange', {
- body: { ticket, audience }
- });
- assert.equal(exchanged.status, 200);
- assert.equal(exchanged.headers['cache-control'], 'no-store');
- assert.deepEqual(exchanged.json, {
- username: 'shared@example.com',
- credential,
- expiresAt: '2030-01-01T00:00:00.000Z'
- });
- assert.deepEqual(exchanges, [{ value: ticket, options: { audience } }]);
- const replayed = await request(server, '/internal/webmail-sso/exchange', {
- body: { ticket, audience }
- });
- assert.equal(replayed.status, 400);
- assert.equal(JSON.stringify(replayed.json).includes(ticket), false);
- const revoked = await request(server, '/internal/webmail-sso/revoke', {
- body: { credential, audience }
- });
- assert.equal(revoked.status, 200);
- assert.deepEqual(revocations, [{ value: credential, options: { audience } }]);
- } finally {
- await close(server);
- }
- });
- function writeSecret(value) {
- const directory = mkdtempSync(path.join(tmpdir(), 'mailhub-webmail-sso-'));
- const file = path.join(directory, 'secret');
- writeFileSync(file, `${value}\n`, { mode: 0o600 });
- return file;
- }
- function listen(server) {
- server.listen(0, '127.0.0.1');
- return new Promise((resolve, reject) => {
- server.once('listening', resolve);
- server.once('error', reject);
- });
- }
- function close(server) {
- return new Promise((resolve, reject) => {
- server.close((error) => error ? reject(error) : resolve());
- });
- }
- function request(server, requestPath, {
- authorization = `Bearer ${secret}`,
- body = {}
- } = {}) {
- const rawBody = JSON.stringify(body);
- return new Promise((resolve, reject) => {
- const req = http.request({
- host: '127.0.0.1',
- port: server.address().port,
- path: requestPath,
- method: 'POST',
- headers: {
- Authorization: authorization,
- 'Content-Type': 'application/json',
- 'Content-Length': String(Buffer.byteLength(rawBody))
- }
- }, (res) => {
- const chunks = [];
- res.on('data', (chunk) => chunks.push(chunk));
- res.on('end', () => {
- const raw = Buffer.concat(chunks).toString('utf8');
- resolve({
- status: res.statusCode,
- headers: res.headers,
- json: raw ? JSON.parse(raw) : null
- });
- });
- });
- req.once('error', reject);
- req.end(rawBody);
- });
- }
|