tracking.test.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import assert from 'node:assert/strict';
  2. import { test } from 'node:test';
  3. import {
  4. classifyTrackingSource,
  5. createTrackingToken,
  6. decryptTrackingTarget,
  7. encryptTrackingTarget,
  8. hashTrackingClientIp,
  9. hashTrackingToken,
  10. instrumentHtml,
  11. instrumentRawMime,
  12. normalizeTrackingTarget,
  13. stripRawMimeHeaders,
  14. trackingTargetFingerprint,
  15. trackingReplayKey
  16. } from '../src/tracking.js';
  17. test('generates opaque tokens and stable hashes', () => {
  18. const token = createTrackingToken();
  19. assert.match(token, /^[A-Za-z0-9_-]{43}$/);
  20. assert.match(hashTrackingToken(token), /^[a-f0-9]{64}$/);
  21. assert.equal(hashTrackingToken(token), hashTrackingToken(token));
  22. assert.notEqual(hashTrackingToken(token), hashTrackingToken(createTrackingToken()));
  23. });
  24. test('encrypts destinations with authenticated random nonces', () => {
  25. const target = 'https://example.com/reset?token=secret#account';
  26. const first = encryptTrackingTarget(target, 'tracking-secret');
  27. const second = encryptTrackingTarget(target, 'tracking-secret');
  28. assert.notEqual(first, second);
  29. assert.equal(first.includes('secret'), false);
  30. assert.equal(decryptTrackingTarget(first, 'tracking-secret'), target);
  31. assert.throws(() => decryptTrackingTarget(`${first.slice(0, -1)}x`, 'tracking-secret'));
  32. assert.throws(() => decryptTrackingTarget(first, 'different-secret'));
  33. assert.equal(trackingTargetFingerprint(target, 'tracking-secret'), trackingTargetFingerprint(target, 'tracking-secret'));
  34. assert.notEqual(trackingTargetFingerprint(target, 'tracking-secret'), trackingTargetFingerprint(target, 'different-secret'));
  35. });
  36. test('accepts absolute http destinations and rejects unsafe protocols', () => {
  37. assert.equal(normalizeTrackingTarget('https://Example.com:443/path?q=1#x'), 'https://example.com/path?q=1#x');
  38. assert.equal(normalizeTrackingTarget('http://example.com/a'), 'http://example.com/a');
  39. assert.throws(() => normalizeTrackingTarget('javascript:alert(1)'), /http/i);
  40. assert.throws(() => normalizeTrackingTarget('mailto:user@example.com'), /http/i);
  41. assert.throws(() => normalizeTrackingTarget('/relative'), /absolute/i);
  42. });
  43. test('scopes IP hashes to account message and UTC day', () => {
  44. const input = {
  45. ip: '203.0.113.7',
  46. secret: 'tracking-secret',
  47. userId: 1,
  48. sendEventId: 10,
  49. occurredAt: '2026-07-09T23:59:00.000Z'
  50. };
  51. const hash = hashTrackingClientIp(input);
  52. assert.equal(hash, hashTrackingClientIp(input));
  53. assert.notEqual(hash, hashTrackingClientIp({ ...input, userId: 2 }));
  54. assert.notEqual(hash, hashTrackingClientIp({ ...input, sendEventId: 11 }));
  55. assert.notEqual(hash, hashTrackingClientIp({ ...input, occurredAt: '2026-07-10T00:00:00.000Z' }));
  56. });
  57. test('classifies direct proxy and scanner clients', () => {
  58. assert.equal(classifyTrackingSource('Mozilla/5.0 AppleWebKit/537.36 Chrome/126 Safari/537.36'), 'direct');
  59. assert.equal(classifyTrackingSource('Mozilla/5.0 (via ggpht.com GoogleImageProxy)'), 'proxy');
  60. assert.equal(classifyTrackingSource('Barracuda Sentinel Link Scanner'), 'scanner');
  61. assert.equal(classifyTrackingSource('curl/8.7.1'), 'scanner');
  62. });
  63. test('builds minute-bucket replay keys from event and client context', () => {
  64. const input = {
  65. secret: 'tracking-secret',
  66. sendEventId: 10,
  67. eventType: 'click',
  68. trackingLinkId: 4,
  69. ipHash: 'ip-hash',
  70. userAgent: 'Mozilla/5.0',
  71. occurredAt: '2026-07-09T12:34:05.000Z'
  72. };
  73. const key = trackingReplayKey(input);
  74. assert.equal(key, trackingReplayKey({ ...input, occurredAt: '2026-07-09T12:34:59.999Z' }));
  75. assert.notEqual(key, trackingReplayKey({ ...input, occurredAt: '2026-07-09T12:35:00.000Z' }));
  76. assert.notEqual(key, trackingReplayKey({ ...input, trackingLinkId: 5 }));
  77. });
  78. test('rewrites absolute links and appends exactly one open pixel', () => {
  79. const createdTargets = [];
  80. const result = instrumentHtml(
  81. '<html><body><a href="https://example.com/reset?token=secret">Reset</a><p>Hello</p></body></html>',
  82. {
  83. openPixelUrl: 'https://mail.example/t/o/open-token.gif',
  84. createClickUrl(target) {
  85. createdTargets.push(target);
  86. return 'https://mail.example/t/c/click-token';
  87. }
  88. }
  89. );
  90. assert.deepEqual(createdTargets, ['https://example.com/reset?token=secret']);
  91. assert.match(result.html, /href="https:\/\/mail\.example\/t\/c\/click-token"/);
  92. assert.equal((result.html.match(/data-mailhub-open/g) || []).length, 1);
  93. assert.match(result.html, /src="https:\/\/mail\.example\/t\/o\/open-token\.gif"/);
  94. assert.equal(result.linkCount, 1);
  95. assert.equal(result.pixelAdded, true);
  96. });
  97. test('skips opt-out and non-http links without duplicating an existing pixel', () => {
  98. let calls = 0;
  99. const result = instrumentHtml(
  100. '<a data-mailhub-no-track href="https://example.com/private">Private</a>' +
  101. '<a href="mailto:user@example.com">Mail</a>' +
  102. '<a href="/relative">Relative</a>' +
  103. '<img data-mailhub-open="true" src="https://mail.example/t/o/token.gif">',
  104. {
  105. openPixelUrl: 'https://mail.example/t/o/token.gif',
  106. createClickUrl() {
  107. calls += 1;
  108. return 'https://mail.example/t/c/token';
  109. }
  110. }
  111. );
  112. assert.equal(calls, 0);
  113. assert.equal(result.linkCount, 0);
  114. assert.equal(result.pixelAdded, false);
  115. assert.equal((result.html.match(/data-mailhub-open/g) || []).length, 1);
  116. });
  117. test('rewrites encoded HTML MIME parts and preserves attachments', async () => {
  118. const html = '<html><body><a href="https://example.com/a">A</a></body></html>';
  119. const attachment = Buffer.from('attachment-bytes').toString('base64');
  120. const raw = [
  121. 'From: sender@example.com',
  122. 'To: user@example.net',
  123. 'Subject: Tracked',
  124. 'MIME-Version: 1.0',
  125. 'Content-Type: multipart/mixed; boundary="outer"',
  126. '',
  127. '--outer',
  128. 'Content-Type: text/html; charset=UTF-8',
  129. 'Content-Transfer-Encoding: base64',
  130. '',
  131. Buffer.from(html).toString('base64'),
  132. '--outer',
  133. 'Content-Type: application/octet-stream',
  134. 'Content-Disposition: attachment; filename="file.bin"',
  135. 'Content-Transfer-Encoding: base64',
  136. '',
  137. attachment,
  138. '--outer--',
  139. ''
  140. ].join('\r\n');
  141. const result = await instrumentRawMime(raw, {
  142. openPixelUrl: 'https://mail.example/t/o/open.gif',
  143. createClickUrl: () => 'https://mail.example/t/c/click'
  144. });
  145. assert.equal(result.tracked, true);
  146. assert.equal(result.linkCount, 1);
  147. assert.match(result.rawMessage, /filename="file\.bin"/);
  148. assert.match(result.rawMessage, new RegExp(attachment));
  149. const encodedHtml = result.rawMessage.match(/Content-Type: text\/html[^]*?\r\n\r\n([A-Za-z0-9+/=\r\n]+?)\r\n--outer/i)?.[1] || '';
  150. const decodedHtml = Buffer.from(encodedHtml.replace(/\s+/g, ''), 'base64').toString('utf8');
  151. assert.match(decodedHtml, /mail\.example\/t\/c\/click/);
  152. assert.match(decodedHtml, /data-mailhub-open/);
  153. });
  154. test('skips signed encrypted and existing-DKIM raw messages', async () => {
  155. const messages = [
  156. 'DKIM-Signature: v=1; d=example.com; b=x\r\nContent-Type: text/html\r\n\r\n<a href="https://example.com">A</a>\r\n',
  157. 'Content-Type: multipart/signed; boundary="s"\r\n\r\n--s--\r\n',
  158. 'Content-Type: application/pkcs7-mime\r\n\r\nencrypted\r\n'
  159. ];
  160. for (const raw of messages) {
  161. const result = await instrumentRawMime(raw, {
  162. openPixelUrl: 'https://mail.example/t/o/open.gif',
  163. createClickUrl: () => 'https://mail.example/t/c/click'
  164. });
  165. assert.equal(result.tracked, false);
  166. assert.equal(result.rawMessage, raw);
  167. assert.ok(result.skippedReason);
  168. }
  169. });
  170. test('does not rewrite HTML nested inside signed MIME content', async () => {
  171. const raw = [
  172. 'From: sender@example.com',
  173. 'To: user@example.net',
  174. 'MIME-Version: 1.0',
  175. 'Content-Type: multipart/mixed; boundary="outer"',
  176. '',
  177. '--outer',
  178. 'Content-Type: multipart/signed; boundary="signed"; protocol="application/pgp-signature"',
  179. '',
  180. '--signed',
  181. 'Content-Type: text/html; charset=UTF-8',
  182. '',
  183. '<a href="https://example.com/signed">Signed</a>',
  184. '--signed',
  185. 'Content-Type: application/pgp-signature',
  186. '',
  187. 'signature-bytes',
  188. '--signed--',
  189. '--outer--',
  190. ''
  191. ].join('\r\n');
  192. const result = await instrumentRawMime(raw, {
  193. openPixelUrl: 'https://mail.example/t/o/open.gif',
  194. createClickUrl: () => 'https://mail.example/t/c/click'
  195. });
  196. assert.equal(result.tracked, false);
  197. assert.equal(result.linkCount, 0);
  198. assert.equal(result.pixelAdded, false);
  199. assert.match(result.rawMessage, /https:\/\/example\.com\/signed/);
  200. assert.doesNotMatch(result.rawMessage, /mail\.example\/t\//);
  201. });
  202. test('rewrites non-UTF-8 HTML without corrupting its charset', async () => {
  203. const html = Buffer.from(
  204. '<html><body><p>Ol\xE1</p><a href="https://example.com/a">A</a></body></html>',
  205. 'latin1'
  206. );
  207. const raw = [
  208. 'From: sender@example.com',
  209. 'To: user@example.net',
  210. 'MIME-Version: 1.0',
  211. 'Content-Type: text/html; charset=iso-8859-1',
  212. 'Content-Transfer-Encoding: base64',
  213. '',
  214. html.toString('base64'),
  215. ''
  216. ].join('\r\n');
  217. const result = await instrumentRawMime(raw, {
  218. openPixelUrl: 'https://mail.example/t/o/open.gif',
  219. createClickUrl: () => 'https://mail.example/t/c/click'
  220. });
  221. assert.equal(result.tracked, true);
  222. assert.match(result.rawMessage, /Content-Type: text\/html; charset=utf-8/i);
  223. const encodedHtml = result.rawMessage.split(/\r?\n\r?\n/, 2)[1] || '';
  224. const decodedHtml = Buffer.from(encodedHtml.replace(/\s+/g, ''), 'base64').toString('utf8');
  225. assert.match(decodedHtml, /Ol\u00e1/);
  226. assert.match(decodedHtml, /mail\.example\/t\/c\/click/);
  227. });
  228. test('removes submission control headers without changing the MIME body', async () => {
  229. const raw = [
  230. 'From: sender@example.com',
  231. 'X-MailHub-Track: opens,clicks',
  232. 'Subject: Hello',
  233. 'Content-Type: text/plain; charset=UTF-8',
  234. '',
  235. 'Body stays intact.',
  236. ''
  237. ].join('\r\n');
  238. const stripped = await stripRawMimeHeaders(raw, ['x-mailhub-track']);
  239. assert.doesNotMatch(stripped, /^X-MailHub-Track:/im);
  240. assert.match(stripped, /Body stays intact\.\r\n$/);
  241. const signed = `DKIM-Signature: v=1; d=example.com; b=x\r\nX-MailHub-Track: off\r\n\r\nBody\r\n`;
  242. assert.equal(await stripRawMimeHeaders(signed, ['x-mailhub-track']), signed);
  243. });