server-tracking.test.js 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import assert from 'node:assert/strict';
  2. import { spawn } from 'node:child_process';
  3. import { mkdtempSync } from 'node:fs';
  4. import net from 'node:net';
  5. import { tmpdir } from 'node:os';
  6. import path from 'node:path';
  7. import { test } from 'node:test';
  8. import {
  9. createDomain,
  10. createSendEvent,
  11. createTrackingLink,
  12. createUser,
  13. getSendEvent,
  14. initDatabase
  15. } from '../src/db.js';
  16. import {
  17. createTrackingToken,
  18. encryptTrackingTarget,
  19. hashTrackingToken
  20. } from '../src/tracking.js';
  21. test('serves neutral open pixels and only records valid GET requests', async () => {
  22. const fixture = await trackingServerFixture();
  23. try {
  24. const head = await fetch(`${fixture.baseUrl}/t/o/${fixture.openToken}.gif`, {
  25. method: 'HEAD',
  26. headers: { 'User-Agent': 'Mozilla/5.0' }
  27. });
  28. assert.equal(head.status, 200);
  29. assert.match(head.headers.get('content-type') || '', /image\/gif/i);
  30. assert.match(head.headers.get('cache-control') || '', /no-store/i);
  31. assert.equal(getSendEvent(fixture.userId, fixture.eventId).tracking.events.length, 0);
  32. const open = await fetch(`${fixture.baseUrl}/t/o/${fixture.openToken}.gif`, {
  33. headers: {
  34. 'User-Agent': 'Mozilla/5.0',
  35. 'X-Forwarded-For': '203.0.113.7'
  36. }
  37. });
  38. assert.equal(open.status, 200);
  39. assert.match(open.headers.get('content-type') || '', /image\/gif/i);
  40. assert.ok((await open.arrayBuffer()).byteLength > 0);
  41. const detail = getSendEvent(fixture.userId, fixture.eventId);
  42. assert.equal(detail.tracking.summary.totalOpens, 1);
  43. assert.equal(detail.tracking.events[0].source, 'direct');
  44. const unknown = await fetch(`${fixture.baseUrl}/t/o/${createTrackingToken()}.gif`);
  45. assert.equal(unknown.status, 200);
  46. assert.equal(await unknown.arrayBuffer().then((value) => value.byteLength), 42);
  47. const malformed = await fetch(`${fixture.baseUrl}/t/o/bad.gif`, { redirect: 'manual' });
  48. assert.equal(malformed.status, 200);
  49. assert.match(malformed.headers.get('content-type') || '', /image\/gif/i);
  50. assert.equal(malformed.headers.get('location'), null);
  51. } finally {
  52. fixture.child.kill('SIGTERM');
  53. await waitForExit(fixture.child, 1000);
  54. }
  55. });
  56. test('redirects opaque click tokens and rejects unknown or unreadable targets', async () => {
  57. const fixture = await trackingServerFixture();
  58. try {
  59. const click = await fetch(`${fixture.baseUrl}/t/c/${fixture.clickToken}`, {
  60. redirect: 'manual',
  61. headers: { 'User-Agent': 'Mozilla/5.0' }
  62. });
  63. assert.equal(click.status, 302);
  64. assert.equal(click.headers.get('location'), fixture.target);
  65. assert.match(click.headers.get('cache-control') || '', /no-store/i);
  66. assert.equal(click.headers.get('referrer-policy'), 'no-referrer');
  67. const detail = getSendEvent(fixture.userId, fixture.eventId);
  68. assert.equal(detail.tracking.summary.totalClicks, 1);
  69. assert.equal(detail.tracking.events.find((event) => event.eventType === 'click').targetOrigin, 'https://example.net');
  70. const unknown = await fetch(`${fixture.baseUrl}/t/c/${createTrackingToken()}`, { redirect: 'manual' });
  71. assert.equal(unknown.status, 404);
  72. assert.equal(unknown.headers.get('location'), null);
  73. const malformed = await fetch(`${fixture.baseUrl}/t/c/bad?u=https://attacker.example`, { redirect: 'manual' });
  74. assert.equal(malformed.status, 404);
  75. assert.equal(malformed.headers.get('location'), null);
  76. const unreadable = await fetch(`${fixture.baseUrl}/t/c/${fixture.unreadableClickToken}`, { redirect: 'manual' });
  77. assert.equal(unreadable.status, 410);
  78. assert.equal(unreadable.headers.get('location'), null);
  79. } finally {
  80. fixture.child.kill('SIGTERM');
  81. await waitForExit(fixture.child, 1000);
  82. }
  83. });
  84. async function trackingServerFixture() {
  85. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-tracking-server-'));
  86. initDatabase(dataDir, 'session-secret');
  87. const user = createUser({ username: `tracking-${Date.now()}`, email: `tracking-${Date.now()}@example.com`, password: 'password123' });
  88. const domain = createDomain(user.id, domainFixture(`tracking-${Date.now()}.example`));
  89. const openToken = createTrackingToken();
  90. const eventId = createSendEvent({
  91. userId: user.id,
  92. domainId: domain.id,
  93. sender: `sender@${domain.domain}`,
  94. recipients: ['reader@example.net'],
  95. subject: 'Tracking endpoint',
  96. status: 'sent',
  97. trackingToken: openToken,
  98. trackingOpens: true,
  99. trackingClicks: true
  100. });
  101. const target = 'https://example.net/reset?token=private#account';
  102. const clickToken = createTrackingToken();
  103. createTrackingLink(user.id, eventId, {
  104. token: clickToken,
  105. targetCiphertext: encryptTrackingTarget(target, 'tracking-secret'),
  106. targetFingerprint: hashTrackingToken(target),
  107. targetOrigin: 'https://example.net'
  108. });
  109. const unreadableClickToken = createTrackingToken();
  110. createTrackingLink(user.id, eventId, {
  111. token: unreadableClickToken,
  112. targetCiphertext: encryptTrackingTarget('https://example.net/old', 'old-secret'),
  113. targetFingerprint: hashTrackingToken('https://example.net/old'),
  114. targetOrigin: 'https://example.net'
  115. });
  116. const port = await freePort();
  117. const child = spawn(process.execPath, ['src/server.js'], {
  118. cwd: process.cwd(),
  119. env: {
  120. ...process.env,
  121. PORT: String(port),
  122. DATA_DIR: dataDir,
  123. ADMIN_PASSWORD: 'password123',
  124. SESSION_SECRET: 'session-secret',
  125. TRACKING_SECRET: 'tracking-secret',
  126. TRUST_PROXY: 'true',
  127. SUBMISSION_ENABLED: 'false',
  128. WEBHOOK_WORKER_ENABLED: '0',
  129. DNS_AUTO_CHECK_ENABLED: 'false',
  130. DELIVERY_TRACKING_ENABLED: 'false'
  131. },
  132. stdio: ['ignore', 'pipe', 'pipe']
  133. });
  134. await waitForOutput(child, 'MailHub listening');
  135. return {
  136. child,
  137. baseUrl: `http://127.0.0.1:${port}`,
  138. userId: user.id,
  139. eventId,
  140. openToken,
  141. clickToken,
  142. unreadableClickToken,
  143. target
  144. };
  145. }
  146. function domainFixture(domain) {
  147. return {
  148. domain,
  149. selector: 'mh',
  150. verificationToken: 'verify',
  151. dkimPublic: 'public',
  152. dkimPrivate: 'private',
  153. senderHost: `mail.${domain}`,
  154. sendingIp: '192.0.2.10',
  155. spfExtra: '',
  156. dmarcPolicy: 'none',
  157. dmarcRua: ''
  158. };
  159. }
  160. function freePort() {
  161. return new Promise((resolve, reject) => {
  162. const server = net.createServer();
  163. server.listen(0, '127.0.0.1', () => {
  164. const { port } = server.address();
  165. server.close((error) => (error ? reject(error) : resolve(port)));
  166. });
  167. server.on('error', reject);
  168. });
  169. }
  170. function waitForOutput(child, value, timeoutMs = 8000) {
  171. return new Promise((resolve, reject) => {
  172. let buffer = '';
  173. const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${value}\n${buffer}`)), timeoutMs);
  174. const onData = (chunk) => {
  175. buffer += String(chunk);
  176. if (!buffer.includes(value)) return;
  177. clearTimeout(timer);
  178. child.stdout?.off('data', onData);
  179. child.stderr?.off('data', onData);
  180. resolve();
  181. };
  182. child.stdout?.on('data', onData);
  183. child.stderr?.on('data', onData);
  184. });
  185. }
  186. function waitForExit(child, timeoutMs) {
  187. return new Promise((resolve) => {
  188. if (child.exitCode != null) return resolve(true);
  189. const timer = setTimeout(() => resolve(false), timeoutMs);
  190. child.once('exit', () => {
  191. clearTimeout(timer);
  192. resolve(true);
  193. });
  194. });
  195. }