@bobfrankston/mailx-types 0.1.78 → 0.1.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/trust.js +60 -7
package/package.json
CHANGED
package/trust.js
CHANGED
|
@@ -56,6 +56,20 @@ function bare(addr) {
|
|
|
56
56
|
const m = (addr || "").match(/[^\s<>,;]+@[^\s<>,;]+/);
|
|
57
57
|
return (m ? m[0] : addr || "").toLowerCase().replace(/[.,;]+$/, "");
|
|
58
58
|
}
|
|
59
|
+
/** Do two hostnames belong to the same organisation?
|
|
60
|
+
*
|
|
61
|
+
* Equality or a sub/parent-domain relation, never "the last two labels
|
|
62
|
+
* match": that would make paypal.co.uk and evil.co.uk relatives, which is
|
|
63
|
+
* the case this whole file exists to catch. A subdomain relationship cannot
|
|
64
|
+
* be forged across organisations, so it is safe in the other direction.
|
|
65
|
+
* Same test displayNameClaimsOtherAddress uses on addresses. */
|
|
66
|
+
function sameOrg(a, b) {
|
|
67
|
+
const x = (a || "").toLowerCase();
|
|
68
|
+
const y = (b || "").toLowerCase();
|
|
69
|
+
if (!x || !y)
|
|
70
|
+
return false;
|
|
71
|
+
return x === y || x.endsWith("." + y) || y.endsWith("." + x);
|
|
72
|
+
}
|
|
59
73
|
/** Rules that say THIS message lies about who sent it. The only class that
|
|
60
74
|
* earns the red banner. `KAM_DMARC_STATUS` is deliberately absent — it is a
|
|
61
75
|
* 0.0-scoring status marker that fired on Mokin, a message the receiving
|
|
@@ -514,6 +528,23 @@ function hiddenLinkOverlay(bodyHtml) {
|
|
|
514
528
|
}
|
|
515
529
|
return null;
|
|
516
530
|
}
|
|
531
|
+
/** The domain the receiving server proved the From line for — "" when nothing
|
|
532
|
+
* did. One place answers "is this sender who they say they are", so the
|
|
533
|
+
* banner, the chip and the link checks can never disagree about it.
|
|
534
|
+
*
|
|
535
|
+
* dmarcProof is asked first because it needs no SpamAssassin: an
|
|
536
|
+
* Authentication-Results header alone is enough, which is what mail arriving
|
|
537
|
+
* through the Gmail API carries. analyzeServerSpam adds the two proofs
|
|
538
|
+
* SpamAssassin states in its own rule list instead (DKIM_VALID_AU,
|
|
539
|
+
* ALL_TRUSTED) and returns null when the server ran no filter at all. */
|
|
540
|
+
function provedSenderDomain(input) {
|
|
541
|
+
const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
|
|
542
|
+
if (!fromDomain)
|
|
543
|
+
return "";
|
|
544
|
+
if (dmarcProof(input).pass)
|
|
545
|
+
return fromDomain;
|
|
546
|
+
return analyzeServerSpam(input)?.proved ? fromDomain : "";
|
|
547
|
+
}
|
|
517
548
|
/**
|
|
518
549
|
* Does a link route through a redirector carrying its real destination in the
|
|
519
550
|
* query string?
|
|
@@ -523,8 +554,30 @@ function hiddenLinkOverlay(bodyHtml) {
|
|
|
523
554
|
* whose redirect script was left open, and the destination appears only
|
|
524
555
|
* percent-encoded inside a parameter. Reporting the ENDPOINT is the point — a
|
|
525
556
|
* reader cannot be expected to URL-decode a query string.
|
|
557
|
+
*
|
|
558
|
+
* Laundering needs a SECOND PARTY — someone whose reputation is being spent
|
|
559
|
+
* without their consent. Two ways a link has none, both checked below, and
|
|
560
|
+
* neither of them a threshold:
|
|
561
|
+
*
|
|
562
|
+
* 1. The redirector and the destination are the same organisation. A site
|
|
563
|
+
* bouncing through its own hostnames is routing, not hiding.
|
|
564
|
+
* 2. The redirector IS the proved sender. Google's account-security notice
|
|
565
|
+
* (2026-09-02, Bob: "this is valid") links through
|
|
566
|
+
* `accounts.google.com/AccountChooser?continue=https://myaccount.google.com/…`
|
|
567
|
+
* — two hostnames that are neither equal nor sub/parent of each other,
|
|
568
|
+
* so rule 1 alone does not save it. But the receiving server recorded
|
|
569
|
+
* `dmarc=pass header.from=accounts.google.com`: the host in the visible
|
|
570
|
+
* link belongs to the party that provably sent the mail, spending its
|
|
571
|
+
* own reputation. That the sender might itself be malicious is a
|
|
572
|
+
* different question than the one this check asks.
|
|
573
|
+
*
|
|
574
|
+
* The proof has to come from the topmost Authentication-Results, which the
|
|
575
|
+
* sender cannot write — otherwise the escape is a switch a forger flips by
|
|
576
|
+
* typing a domain into From. No proof means the check stays on.
|
|
526
577
|
*/
|
|
527
|
-
function redirectorLink(
|
|
578
|
+
function redirectorLink(input) {
|
|
579
|
+
const bodyHtml = input.bodyHtml || "";
|
|
580
|
+
const senderDomain = provedSenderDomain(input);
|
|
528
581
|
for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
|
|
529
582
|
let url;
|
|
530
583
|
try {
|
|
@@ -535,6 +588,8 @@ function redirectorLink(bodyHtml) {
|
|
|
535
588
|
}
|
|
536
589
|
if (!/^https?:$/.test(url.protocol))
|
|
537
590
|
continue;
|
|
591
|
+
if (senderDomain && sameOrg(url.hostname, senderDomain))
|
|
592
|
+
continue; // the sender's own host
|
|
538
593
|
for (const [, value] of url.searchParams) {
|
|
539
594
|
let target;
|
|
540
595
|
try {
|
|
@@ -545,8 +600,8 @@ function redirectorLink(bodyHtml) {
|
|
|
545
600
|
}
|
|
546
601
|
if (!/^https?:$/.test(target.protocol))
|
|
547
602
|
continue;
|
|
548
|
-
if (target.hostname
|
|
549
|
-
continue; // same
|
|
603
|
+
if (sameOrg(target.hostname, url.hostname))
|
|
604
|
+
continue; // same site, not laundering
|
|
550
605
|
return {
|
|
551
606
|
id: "redirector-link",
|
|
552
607
|
severity: "caution",
|
|
@@ -571,7 +626,7 @@ export function assessMessageTrust(input) {
|
|
|
571
626
|
relayAuthMismatch(input),
|
|
572
627
|
zeroWidthObfuscation(input.bodyText || ""),
|
|
573
628
|
hiddenLinkOverlay(input.bodyHtml || ""),
|
|
574
|
-
redirectorLink(input
|
|
629
|
+
redirectorLink(input),
|
|
575
630
|
].filter(Boolean);
|
|
576
631
|
const rank = { danger: 0, caution: 1, info: 2 };
|
|
577
632
|
return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
|
@@ -614,11 +669,9 @@ function relayAuthMismatch(input) {
|
|
|
614
669
|
for (const name of RELAY_IDENTITY_HEADERS) {
|
|
615
670
|
const stamped = bare(header(input.headerLines, name));
|
|
616
671
|
const stampedDomain = stamped.split("@")[1] || "";
|
|
617
|
-
if (!stampedDomain || stampedDomain === fromDomain)
|
|
618
|
-
continue;
|
|
619
672
|
// Sub/parent-domain is the same organisation — mail.example.com
|
|
620
673
|
// sending for example.com is ordinary. Cross-organisation is not.
|
|
621
|
-
if (stampedDomain
|
|
674
|
+
if (!stampedDomain || sameOrg(stampedDomain, fromDomain))
|
|
622
675
|
continue;
|
|
623
676
|
return {
|
|
624
677
|
id: "relay-auth-mismatch",
|