1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'MtaStsAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const POLICY_MAX_BYTES = 65536;
9
10
11
12
13
14
15
16
17function isPrivateIPv4(ip) {
18 const parts = ip.split('.').map(Number);
19 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
20 const [a, b] = parts;
21 return a === 10
22 || (a === 172 && b >= 16 && b <= 31)
23 || (a === 192 && b === 168)
24 || a === 127
25 || a === 0
26 || (a === 169 && b === 254);
27}
28
29function isPrivateIPv6(ip) {
30 const normalized = ip.toLowerCase();
31 return normalized === '::1'
32 || normalized.startsWith('fc')
33 || normalized.startsWith('fd')
34 || normalized.startsWith('fe80:');
35}
36
37export async function normalizeAndValidateUrl(rawUrl) {
38 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
39 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
40 throw new Error('Only HTTP and HTTPS URLs are supported');
41 }
42 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
43 const url = new URL(withScheme);
44 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
45 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
46
47
48 const bareHost = url.hostname.replace(/^\[|\]$/g, '');
49 const literalType = net.isIP(bareHost);
50 if (literalType === 4 && isPrivateIPv4(bareHost)) throw new Error('Private IPv4 targets are blocked');
51 if (literalType === 6 && isPrivateIPv6(bareHost)) throw new Error('Private IPv6 targets are blocked');
52
53 const records = literalType ? [{ address: bareHost, family: literalType }] : await dns.lookup(bareHost, { all: true });
54 for (const record of records) {
55 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
56 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
57 }
58 return url;
59}
60
61function clampInteger(value, fallback, min, max) {
62 const parsed = Number(value);
63 if (!Number.isFinite(parsed)) return fallback;
64 return Math.min(Math.max(Math.trunc(parsed), min), max);
65}
66
67function joinTxt(records) {
68 return records.map((r) => (Array.isArray(r) ? r.join('') : r));
69}
70
71async function resolveTxt(hostname, timeoutSeconds) {
72 try {
73 const result = await Promise.race([
74 dns.resolveTxt(hostname),
75 new Promise((_, reject) => setTimeout(() => reject(new Error(`DNS timeout for ${hostname}`)), timeoutSeconds * 1000)),
76 ]);
77 return joinTxt(result);
78 } catch {
79 return [];
80 }
81}
82
83async function resolveMx(hostname, timeoutSeconds) {
84 try {
85 const result = await Promise.race([
86 dns.resolveMx(hostname),
87 new Promise((_, reject) => setTimeout(() => reject(new Error(`DNS timeout for ${hostname}`)), timeoutSeconds * 1000)),
88 ]);
89
90 return result.sort((a, b) => a.priority - b.priority).map((r) => r.exchange.toLowerCase());
91 } catch {
92 return [];
93 }
94}
95
96
97
98
99
100
101async function fetchPolicyFile(policyUrl, timeoutSeconds) {
102 await normalizeAndValidateUrl(policyUrl);
103 const controller = new AbortController();
104 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
105 try {
106 const response = await fetch(policyUrl, {
107 redirect: 'manual',
108 signal: controller.signal,
109 headers: {
110 'user-agent': USER_AGENT,
111 accept: 'text/plain, */*;q=0.1',
112 },
113 });
114
115 if ([301, 302, 303, 307, 308].includes(response.status)) {
116
117 return {
118 fetched: false, status: response.status, https: policyUrl.startsWith('https://'),
119 contentType: response.headers.get('content-type'), rawPolicy: null,
120 error: `Policy file returned redirect (${response.status}); MTA-STS policy must be served directly at the well-known path`,
121 };
122 }
123
124 if (!response.ok) {
125 return {
126 fetched: false, status: response.status, https: policyUrl.startsWith('https://'),
127 contentType: response.headers.get('content-type'), rawPolicy: null,
128 error: `Policy file returned HTTP ${response.status}`,
129 };
130 }
131
132
133 const reader = response.body.getReader();
134 const chunks = [];
135 let totalBytes = 0;
136 let tooLarge = false;
137
138 while (true) {
139 const { done, value } = await reader.read();
140 if (done) break;
141 totalBytes += value.length;
142 if (totalBytes > POLICY_MAX_BYTES) { tooLarge = true; break; }
143 chunks.push(value);
144 }
145 await reader.cancel();
146 const rawPolicy = new TextDecoder('utf-8').decode(Buffer.concat(chunks));
147
148 return {
149 fetched: true, status: response.status, https: policyUrl.startsWith('https://'),
150 contentType: response.headers.get('content-type'), rawPolicy,
151 error: tooLarge ? `Policy file exceeded ${POLICY_MAX_BYTES} byte cap` : null,
152 };
153 } catch (error) {
154 return {
155 fetched: false, status: null, https: policyUrl.startsWith('https://'),
156 contentType: null, rawPolicy: null, error: error.message,
157 };
158 } finally {
159 clearTimeout(timeout);
160 }
161}
162
163
164
165
166
167
168export function parseMtaStsDnsRecord(txtRecords) {
169 const record = txtRecords.find((r) => /^v=STSv1/i.test(r));
170 if (!record) {
171 return { found: false, record: null, id: null, issues: ['No MTA-STS DNS record found at _mta-sts.<domain>'] };
172 }
173 const tags = {};
174 record.split(';').forEach((tag) => {
175 const idx = tag.indexOf('=');
176 if (idx > 0) tags[tag.slice(0, idx).trim().toLowerCase()] = tag.slice(idx + 1).trim();
177 });
178 const issues = [];
179 if (!tags.v) issues.push('MTA-STS record missing v= tag');
180 if (!tags.id) issues.push('MTA-STS record missing id= tag');
181 return { found: true, record, id: tags.id || null, issues };
182}
183
184
185
186
187
188
189
190export function parsePolicyFile(rawPolicy) {
191 if (!rawPolicy || typeof rawPolicy !== 'string') {
192 return { parsed: false, version: null, mode: null, mxPatterns: [], maxAge: null, issues: ['Policy file is empty or not text'] };
193 }
194 const issues = [];
195 const lines = rawPolicy.split(/\r?\n/);
196 const kv = {};
197 let currentKey = null;
198
199 for (const line of lines) {
200
201 if (/^\s/.test(line) && currentKey) {
202 kv[currentKey] += ` ${line.trim()}`;
203 continue;
204 }
205 const colonIdx = line.indexOf(':');
206 if (colonIdx <= 0) { currentKey = null; continue; }
207 const key = line.slice(0, colonIdx).trim().toLowerCase();
208 const value = line.slice(colonIdx + 1).trim();
209 kv[key] = value;
210 currentKey = key;
211 }
212
213 const version = kv.version || null;
214 const mode = kv.mode || null;
215
216 const mxRaw = (kv.mx || '').trim();
217 const mxPatterns = mxRaw ? mxRaw.split(/\s+/).filter(Boolean) : [];
218 const maxAgeRaw = kv.max_age != null ? kv.max_age : null;
219 const maxAge = maxAgeRaw != null ? parseInt(maxAgeRaw, 10) : null;
220
221 if (!version) issues.push('Policy file missing version: key');
222 else if (version !== 'STSv1') issues.push(`Policy file version is "${version}", expected "STSv1"`);
223 if (!mode) issues.push('Policy file missing mode: key');
224 else if (!['enforce', 'testing', 'none'].includes(mode)) issues.push(`Policy file mode is "${mode}", expected enforce, testing, or none`);
225 if (mxPatterns.length === 0) issues.push('Policy file missing mx: key or has no MX patterns');
226 if (maxAge == null || Number.isNaN(maxAge)) issues.push('Policy file missing or invalid max_age: key');
227 else if (maxAge < 0) issues.push('Policy file max_age is negative');
228 else if (maxAge > 31557600) issues.push('Policy file max_age exceeds 1 year (31557600 seconds)');
229
230 return { parsed: true, version, mode, mxPatterns, maxAge, issues };
231}
232
233
234
235
236
237
238export function matchMxAgainstPolicy(mxRecords, mxPatterns) {
239 if (!mxPatterns.length) {
240 return { matched: [], unmatched: [], allMatch: false, issues: ['No MX patterns in policy to match against'] };
241 }
242 const matched = [];
243 const unmatched = [];
244 const issues = [];
245
246 for (const mx of mxRecords) {
247 const isMatch = mxPatterns.some((pattern) => matchPattern(mx, pattern));
248 if (isMatch) matched.push(mx);
249 else unmatched.push(mx);
250 }
251
252 if (unmatched.length > 0) {
253 issues.push(`MX records not covered by policy mx patterns: ${unmatched.join(', ')}`);
254 }
255 if (mxRecords.length === 0) {
256 issues.push('No MX records found for domain; cannot verify policy coverage');
257 }
258
259 return { matched, unmatched, allMatch: mxRecords.length > 0 && unmatched.length === 0, issues };
260}
261
262function matchPattern(hostname, pattern) {
263 if (pattern.startsWith('*.')) {
264 const suffix = pattern.slice(2).toLowerCase();
265 const host = hostname.toLowerCase();
266
267 return host === suffix || host.endsWith(`.${suffix}`);
268 }
269 return hostname.toLowerCase() === pattern.toLowerCase();
270}
271
272
273
274
275
276
277export function parseTlsRptRecord(txtRecords) {
278 const record = txtRecords.find((r) => /^v=TLSRPTv1/i.test(r));
279 if (!record) {
280 return { found: false, record: null, version: null, rua: null, ruaSchemes: [], issues: ['No TLS-RPT record found at _smtp._tls.<domain>'] };
281 }
282 const tags = {};
283 record.split(';').forEach((tag) => {
284 const idx = tag.indexOf('=');
285 if (idx > 0) tags[tag.slice(0, idx).trim().toLowerCase()] = tag.slice(idx + 1).trim();
286 });
287 const issues = [];
288 const rua = tags.rua || null;
289 const ruaSchemes = rua ? rua.split(',').map((r) => r.trim().split(':')[0]).filter(Boolean) : [];
290
291 if (!tags.v) issues.push('TLS-RPT record missing v= tag');
292 else if (tags.v.toLowerCase() !== 'tlsrptv1') issues.push(`TLS-RPT version is "${tags.v}", expected "TLSRPTv1"`);
293 if (!rua) issues.push('TLS-RPT record missing rua= (report destination)');
294 else {
295
296 for (const dest of rua.split(',').map((r) => r.trim()).filter(Boolean)) {
297 if (!/^https?:\/\//i.test(dest) && !/^mailto:/i.test(dest)) {
298 issues.push(`TLS-RPT rua destination "${dest}" uses unsupported scheme (expected mailto: or https:)`);
299 }
300 }
301 }
302
303 return { found: true, record, version: tags.v || null, rua, ruaSchemes, issues };
304}
305
306
307
308
309
310export async function auditMtaSts(input) {
311 const timeoutSeconds = clampInteger(input.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS, 3, 30);
312
313 let domain;
314 try {
315 const target = await normalizeAndValidateUrl(input.startUrl);
316 domain = target.hostname;
317 } catch (err) {
318 return {
319 inputUrl: input.startUrl,
320 domain: null,
321 checkedAt: new Date().toISOString(),
322 mtaSts: { recordFound: false, dnsRecord: null, recordId: null, policyFileUrl: null, policyFileFetched: false, policyFileStatus: null, policyFileHttps: false, policyContentType: null, rawPolicy: null, version: null, mode: null, mxPatterns: [], maxAge: null, mxRecords: [], mxMatch: { matched: [], unmatched: [], allMatch: false, issues: [] }, issues: [] },
323 tlsRpt: { recordFound: false, dnsRecord: null, version: null, rua: null, ruaSchemes: [], issues: [] },
324 score: 0, grade: 'F', issues: [err.message], recommendations: [], error: err.message,
325 };
326 }
327
328
329 const mtaStsDnsTxt = await resolveTxt(`_mta-sts.${domain}`, timeoutSeconds);
330 const mtaStsDns = parseMtaStsDnsRecord(mtaStsDnsTxt);
331
332
333 const policyFileUrl = `https://mta-sts.${domain}/.well-known/mta-sts.txt`;
334 let policyResult = { fetched: false, status: null, https: true, contentType: null, rawPolicy: null, error: null };
335 let parsedPolicy = { parsed: false, version: null, mode: null, mxPatterns: [], maxAge: null, issues: [] };
336
337 if (mtaStsDns.found) {
338 policyResult = await fetchPolicyFile(policyFileUrl, timeoutSeconds);
339 if (policyResult.fetched && policyResult.rawPolicy) {
340 parsedPolicy = parsePolicyFile(policyResult.rawPolicy);
341 } else if (policyResult.error) {
342 parsedPolicy.issues.push(policyResult.error);
343 }
344 }
345
346
347 const mxRecords = await resolveMx(domain, timeoutSeconds);
348 const mxMatch = matchMxAgainstPolicy(mxRecords, parsedPolicy.mxPatterns);
349
350
351 const tlsRptTxt = await resolveTxt(`_smtp._tls.${domain}`, timeoutSeconds);
352 const tlsRpt = parseTlsRptRecord(tlsRptTxt);
353
354
355 let value = 100;
356 const issues = [];
357 const recommendations = [];
358
359
360 if (!mtaStsDns.found) {
361 value -= 40;
362 issues.push('No MTA-STS DNS record found at _mta-sts.<domain>');
363 recommendations.push('Publish an MTA-STS DNS TXT record at _mta-sts.<domain> with "v=STSv1; id=<timestamp>" to signal that an MTA-STS policy exists.');
364 } else {
365 issues.push(...mtaStsDns.issues);
366 if (!mtaStsDns.id) value -= 5;
367 }
368
369 if (!policyResult.fetched) {
370 value -= 25;
371 issues.push('MTA-STS policy file not fetchable at https://mta-sts.<domain>/.well-known/mta-sts.txt');
372 recommendations.push('Host the MTA-STS policy file at https://mta-sts.<domain>/.well-known/mta-sts.txt over HTTPS with a valid certificate.');
373 } else {
374 issues.push(...parsedPolicy.issues);
375 if (parsedPolicy.mode === 'none') {
376 value -= 15;
377 issues.push('MTA-STS policy mode is "none" — no enforcement');
378 recommendations.push('Set mode: enforce in the MTA-STS policy file once testing is complete.');
379 } else if (parsedPolicy.mode === 'testing') {
380 value -= 5;
381 recommendations.push('Upgrade MTA-STS policy from mode: testing to mode: enforce once confident no legitimate mail is blocked.');
382 }
383 if (!parsedPolicy.version) value -= 5;
384 if (parsedPolicy.mxPatterns.length === 0) value -= 10;
385 if (parsedPolicy.maxAge == null || Number.isNaN(parsedPolicy.maxAge)) value -= 5;
386 else if (parsedPolicy.maxAge > 31557600) value -= 3;
387 }
388
389
390 if (mxRecords.length > 0 && !mxMatch.allMatch) {
391 value -= 15;
392 recommendations.push('Update the MTA-STS policy mx: patterns to cover all MX records, or remove orphaned MX records.');
393 }
394 if (mxRecords.length === 0) {
395 value -= 10;
396 issues.push('No MX records found for domain');
397 }
398
399
400 if (!tlsRpt.found) {
401 value -= 15;
402 issues.push('No TLS-RPT record found at _smtp._tls.<domain>');
403 recommendations.push('Publish a TLS-RPT TXT record at _smtp._tls.<domain> with "v=TLSRPTv1; rua=mailto:tls-reports@<domain>" to receive failure reports.');
404 } else {
405 issues.push(...tlsRpt.issues);
406 if (!tlsRpt.rua) value -= 5;
407 }
408
409 const bounded = Math.max(0, value);
410 const grade = bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F';
411
412 const mtaSts = {
413 dnsRecord: mtaStsDns.record,
414 recordFound: mtaStsDns.found,
415 recordId: mtaStsDns.id,
416 policyFileUrl: mtaStsDns.found ? policyFileUrl : null,
417 policyFileFetched: policyResult.fetched,
418 policyFileStatus: policyResult.status,
419 policyFileHttps: policyResult.https,
420 policyContentType: policyResult.contentType,
421 rawPolicy: policyResult.rawPolicy,
422 version: parsedPolicy.version,
423 mode: parsedPolicy.mode,
424 mxPatterns: parsedPolicy.mxPatterns,
425 maxAge: parsedPolicy.maxAge,
426 mxRecords,
427 mxMatch,
428 issues: [...mtaStsDns.issues, ...parsedPolicy.issues, ...mxMatch.issues],
429 };
430
431 return {
432 inputUrl: input.startUrl,
433 domain,
434 checkedAt: new Date().toISOString(),
435 mtaSts,
436 tlsRpt: {
437 dnsRecord: tlsRpt.record,
438 recordFound: tlsRpt.found,
439 version: tlsRpt.version,
440 rua: tlsRpt.rua,
441 ruaSchemes: tlsRpt.ruaSchemes,
442 issues: tlsRpt.issues,
443 },
444 score: bounded,
445 grade,
446 issues,
447 recommendations,
448 error: null,
449 };
450}
451
452const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
453
454if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
455 await Actor.init();
456 try {
457 const result = await auditMtaSts(await Actor.getInput() || {});
458 await Actor.pushData(result);
459 await Actor.setValue('OUTPUT', result);
460 Actor.log.info('MTA-STS & TLS-RPT audit complete', { domain: result.domain, score: result.score, grade: result.grade });
461 } finally {
462 await Actor.exit();
463 }
464}