| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208 |
- import assert from 'node:assert/strict';
- import { spawn } from 'node:child_process';
- import { mkdtempSync } from 'node:fs';
- import net from 'node:net';
- import { tmpdir } from 'node:os';
- import path from 'node:path';
- import { test } from 'node:test';
- import {
- createDomain,
- createSendEvent,
- createTrackingLink,
- createUser,
- getSendEvent,
- initDatabase
- } from '../src/db.js';
- import {
- createTrackingToken,
- encryptTrackingTarget,
- hashTrackingToken
- } from '../src/tracking.js';
- test('serves neutral open pixels and only records valid GET requests', async () => {
- const fixture = await trackingServerFixture();
- try {
- const head = await fetch(`${fixture.baseUrl}/t/o/${fixture.openToken}.gif`, {
- method: 'HEAD',
- headers: { 'User-Agent': 'Mozilla/5.0' }
- });
- assert.equal(head.status, 200);
- assert.match(head.headers.get('content-type') || '', /image\/gif/i);
- assert.match(head.headers.get('cache-control') || '', /no-store/i);
- assert.equal(getSendEvent(fixture.userId, fixture.eventId).tracking.events.length, 0);
- const open = await fetch(`${fixture.baseUrl}/t/o/${fixture.openToken}.gif`, {
- headers: {
- 'User-Agent': 'Mozilla/5.0',
- 'X-Forwarded-For': '203.0.113.7'
- }
- });
- assert.equal(open.status, 200);
- assert.match(open.headers.get('content-type') || '', /image\/gif/i);
- assert.ok((await open.arrayBuffer()).byteLength > 0);
- const detail = getSendEvent(fixture.userId, fixture.eventId);
- assert.equal(detail.tracking.summary.totalOpens, 1);
- assert.equal(detail.tracking.events[0].source, 'direct');
- const unknown = await fetch(`${fixture.baseUrl}/t/o/${createTrackingToken()}.gif`);
- assert.equal(unknown.status, 200);
- assert.equal(await unknown.arrayBuffer().then((value) => value.byteLength), 42);
- const malformed = await fetch(`${fixture.baseUrl}/t/o/bad.gif`, { redirect: 'manual' });
- assert.equal(malformed.status, 200);
- assert.match(malformed.headers.get('content-type') || '', /image\/gif/i);
- assert.equal(malformed.headers.get('location'), null);
- } finally {
- fixture.child.kill('SIGTERM');
- await waitForExit(fixture.child, 1000);
- }
- });
- test('redirects opaque click tokens and rejects unknown or unreadable targets', async () => {
- const fixture = await trackingServerFixture();
- try {
- const click = await fetch(`${fixture.baseUrl}/t/c/${fixture.clickToken}`, {
- redirect: 'manual',
- headers: { 'User-Agent': 'Mozilla/5.0' }
- });
- assert.equal(click.status, 302);
- assert.equal(click.headers.get('location'), fixture.target);
- assert.match(click.headers.get('cache-control') || '', /no-store/i);
- assert.equal(click.headers.get('referrer-policy'), 'no-referrer');
- const detail = getSendEvent(fixture.userId, fixture.eventId);
- assert.equal(detail.tracking.summary.totalClicks, 1);
- assert.equal(detail.tracking.events.find((event) => event.eventType === 'click').targetOrigin, 'https://example.net');
- const unknown = await fetch(`${fixture.baseUrl}/t/c/${createTrackingToken()}`, { redirect: 'manual' });
- assert.equal(unknown.status, 404);
- assert.equal(unknown.headers.get('location'), null);
- const malformed = await fetch(`${fixture.baseUrl}/t/c/bad?u=https://attacker.example`, { redirect: 'manual' });
- assert.equal(malformed.status, 404);
- assert.equal(malformed.headers.get('location'), null);
- const unreadable = await fetch(`${fixture.baseUrl}/t/c/${fixture.unreadableClickToken}`, { redirect: 'manual' });
- assert.equal(unreadable.status, 410);
- assert.equal(unreadable.headers.get('location'), null);
- } finally {
- fixture.child.kill('SIGTERM');
- await waitForExit(fixture.child, 1000);
- }
- });
- async function trackingServerFixture() {
- const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-tracking-server-'));
- initDatabase(dataDir, 'session-secret');
- const user = createUser({ username: `tracking-${Date.now()}`, email: `tracking-${Date.now()}@example.com`, password: 'password123' });
- const domain = createDomain(user.id, domainFixture(`tracking-${Date.now()}.example`));
- const openToken = createTrackingToken();
- const eventId = createSendEvent({
- userId: user.id,
- domainId: domain.id,
- sender: `sender@${domain.domain}`,
- recipients: ['reader@example.net'],
- subject: 'Tracking endpoint',
- status: 'sent',
- trackingToken: openToken,
- trackingOpens: true,
- trackingClicks: true
- });
- const target = 'https://example.net/reset?token=private#account';
- const clickToken = createTrackingToken();
- createTrackingLink(user.id, eventId, {
- token: clickToken,
- targetCiphertext: encryptTrackingTarget(target, 'tracking-secret'),
- targetFingerprint: hashTrackingToken(target),
- targetOrigin: 'https://example.net'
- });
- const unreadableClickToken = createTrackingToken();
- createTrackingLink(user.id, eventId, {
- token: unreadableClickToken,
- targetCiphertext: encryptTrackingTarget('https://example.net/old', 'old-secret'),
- targetFingerprint: hashTrackingToken('https://example.net/old'),
- targetOrigin: 'https://example.net'
- });
- const port = await freePort();
- const child = spawn(process.execPath, ['src/server.js'], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- PORT: String(port),
- DATA_DIR: dataDir,
- ADMIN_PASSWORD: 'password123',
- SESSION_SECRET: 'session-secret',
- TRACKING_SECRET: 'tracking-secret',
- TRUST_PROXY: 'true',
- SUBMISSION_ENABLED: 'false',
- WEBHOOK_WORKER_ENABLED: '0',
- DNS_AUTO_CHECK_ENABLED: 'false',
- DELIVERY_TRACKING_ENABLED: 'false'
- },
- stdio: ['ignore', 'pipe', 'pipe']
- });
- await waitForOutput(child, 'MailHub listening');
- return {
- child,
- baseUrl: `http://127.0.0.1:${port}`,
- userId: user.id,
- eventId,
- openToken,
- clickToken,
- unreadableClickToken,
- target
- };
- }
- function domainFixture(domain) {
- return {
- domain,
- selector: 'mh',
- verificationToken: 'verify',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: `mail.${domain}`,
- sendingIp: '192.0.2.10',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- };
- }
- function freePort() {
- return new Promise((resolve, reject) => {
- const server = net.createServer();
- server.listen(0, '127.0.0.1', () => {
- const { port } = server.address();
- server.close((error) => (error ? reject(error) : resolve(port)));
- });
- server.on('error', reject);
- });
- }
- function waitForOutput(child, value, timeoutMs = 8000) {
- return new Promise((resolve, reject) => {
- let buffer = '';
- const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${value}\n${buffer}`)), timeoutMs);
- const onData = (chunk) => {
- buffer += String(chunk);
- if (!buffer.includes(value)) return;
- clearTimeout(timer);
- child.stdout?.off('data', onData);
- child.stderr?.off('data', onData);
- resolve();
- };
- child.stdout?.on('data', onData);
- child.stderr?.on('data', onData);
- });
- }
- function waitForExit(child, timeoutMs) {
- return new Promise((resolve) => {
- if (child.exitCode != null) return resolve(true);
- const timer = setTimeout(() => resolve(false), timeoutMs);
- child.once('exit', () => {
- clearTimeout(timer);
- resolve(true);
- });
- });
- }
|