@bobfrankston/rmfmail 1.2.298 → 1.2.299

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/.commitmsg CHANGED
@@ -1,33 +1,53 @@
1
- The signature in accounts.jsonc never reached compose
2
-
3
- `sig` has been a documented field on AccountConfig for months and three of the
4
- four accounts carried one, yet no signature has ever appeared in a compose
5
- window. The reason is a projection: `MailxService.getAccounts()` builds the
6
- client's account list field by field id/name/email from the DB row, then
7
- label, defaultSend, primary*, identityDomains from the config — and `sig` and
8
- `signature` were simply not on the list. `normalizeAccount()` resolved them
9
- correctly, `openCompose` forwarded `a.sig` faithfully, and compose.ts appended
10
- `acct.sig` exactly as designed; the value was undefined by the time any of that
11
- ran.
12
-
13
- Both fields are now carried, with a comment saying why the enumeration stays an
14
- enumeration rather than becoming `...cfg`: cfg holds the IMAP and SMTP
15
- passwords, which have no business crossing into the WebView. Anything new that
16
- the client needs has to be added in that spot deliberately.
17
-
18
- The signature itself moved to the top level of accounts.jsonc three
19
- byte-identical per-account copies replaced by the one the file format already
20
- supports, which every account without its own `sig` inherits. It is now
21
- `Bob Frankston<br>https://Frankston.com` with `html: true`; the previous text
22
- relied on bare newlines for its line break (HTML collapses those to a space)
23
- and pointed at a relative `href='Frankston.com'`, which resolves against
24
- whatever page the mail client happens to be rendering.
25
-
26
- Two stale comments corrected while here. `AccountSignature.html` was documented
27
- in both mailx-types and compose.ts as "reserved for future use / currently
28
- ignored". It has never been ignored — compose has always branched on it — and
29
- now that a signature actually renders, the difference between escaped text and
30
- raw HTML is the thing an author needs to know.
31
-
32
- Compose only. Nothing in the send path or the daemon appends a signature; the
33
- body that leaves the editor is the body that goes out.
1
+ Laundering needs a second party a redirector alone is not one
2
+
3
+ Google's account-security notice, which is exactly the mail a reader must be
4
+ able to believe, carried a caution banner: "A link hides where it goes: it
5
+ passes through accounts.google.com and ends at myaccount.google.com." Both
6
+ halves of that sentence name Google. Bob, on the message:
7
+ `mailxstore/bobma/e1/e193f2bb….eml`, "this is valid."
8
+
9
+ The check compared `target.hostname === url.hostname` and reported anything
10
+ else. That catches the case it was written for `castanet-sa.fr/redirect.php
11
+ ?newurl=https://evil.example`, someone's abandoned redirect script spending a
12
+ real business's reputation — but the comparison is not what the finding
13
+ claims. Laundering requires a SECOND PARTY, someone whose name is on the
14
+ visible link without their consent. Two ways a link has none, both now checked,
15
+ neither of them a threshold:
16
+
17
+ 1. The redirector and the destination are the same organisation. Equality
18
+ widened to the sub/parent-domain test this file already uses twice, now a
19
+ shared `sameOrg()` deliberately not "the last two labels match", which
20
+ would make paypal.co.uk and evil.co.uk relatives.
21
+
22
+ 2. The redirector IS the sender, proved. accounts.google.com and
23
+ myaccount.google.com are siblings, so rule 1 does not reach them; what
24
+ clears the message is that the receiving server recorded `dmarc=pass
25
+ header.from=accounts.google.com`. The host in the visible link belongs to
26
+ the party that provably sent the mail. Whether that party is trustworthy
27
+ is a different question than the one this check asks.
28
+
29
+ The proof comes from `provedSenderDomain()`, which asks dmarcProof first an
30
+ Authentication-Results header alone is enough, so this works on Gmail-API mail
31
+ with no SpamAssassin headers — and falls back to the DKIM_VALID_AU /
32
+ ALL_TRUSTED proofs SpamAssassin states in its own rule list. It reads the
33
+ topmost Authentication-Results only, which the sender cannot write. No proof
34
+ means the check stays on: an escape a forger could open by typing a domain
35
+ into From would be worse than no check.
36
+
37
+ The mirror rule is NOT safe and is deliberately absent. "The link ENDS at the
38
+ proved sender" would clear `legit-bank.com/r?u=https://evil.com` sent from a
39
+ DMARC-proved evil.com, which is the laundering case in its purest form — the
40
+ reader sees the bank's hostname. Only the visible host may be the sender's.
41
+
42
+ Measured over the 800 most recent messages in the store: redirector-link fires
43
+ 15 times before, 4 after. Cleared: Substack's own link wrapper on Substack
44
+ mail (6), Google's alert, Microsoft Teams, dev.to. Still flagged, correctly by
45
+ this rule's terms: Microsoft's Safe Links wrapper (nam.safelink.emails.azure.net
46
+ -> admin.microsoft.com, 3) and a donation platform bouncing back to the charity
47
+ that mailed. Those are third-party hostnames in front of the reader, and no
48
+ hostname allowlist is going in to quiet them.
49
+
50
+ Four tests: the sibling-subdomain case, the Google alert with its proof and
51
+ again with the proof removed, and a forged accounts.google.com whose server
52
+ recorded the DMARC failure — still flagged, as the escape is the server's
53
+ verdict and never the From line's say-so.
@@ -2226,6 +2226,13 @@ function bare(addr) {
2226
2226
  const m = (addr || "").match(/[^\s<>,;]+@[^\s<>,;]+/);
2227
2227
  return (m ? m[0] : addr || "").toLowerCase().replace(/[.,;]+$/, "");
2228
2228
  }
2229
+ function sameOrg(a, b) {
2230
+ const x = (a || "").toLowerCase();
2231
+ const y = (b || "").toLowerCase();
2232
+ if (!x || !y)
2233
+ return false;
2234
+ return x === y || x.endsWith("." + y) || y.endsWith("." + x);
2235
+ }
2229
2236
  function classifyRule(name, dmarcFailed) {
2230
2237
  if (dmarcFailed && name === "DKIM_INVALID")
2231
2238
  return "forgery";
@@ -2455,7 +2462,17 @@ function hiddenLinkOverlay(bodyHtml) {
2455
2462
  }
2456
2463
  return null;
2457
2464
  }
2458
- function redirectorLink(bodyHtml) {
2465
+ function provedSenderDomain(input) {
2466
+ const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
2467
+ if (!fromDomain)
2468
+ return "";
2469
+ if (dmarcProof(input).pass)
2470
+ return fromDomain;
2471
+ return analyzeServerSpam(input)?.proved ? fromDomain : "";
2472
+ }
2473
+ function redirectorLink(input) {
2474
+ const bodyHtml = input.bodyHtml || "";
2475
+ const senderDomain = provedSenderDomain(input);
2459
2476
  for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
2460
2477
  let url;
2461
2478
  try {
@@ -2465,6 +2482,8 @@ function redirectorLink(bodyHtml) {
2465
2482
  }
2466
2483
  if (!/^https?:$/.test(url.protocol))
2467
2484
  continue;
2485
+ if (senderDomain && sameOrg(url.hostname, senderDomain))
2486
+ continue;
2468
2487
  for (const [, value] of url.searchParams) {
2469
2488
  let target;
2470
2489
  try {
@@ -2474,7 +2493,7 @@ function redirectorLink(bodyHtml) {
2474
2493
  }
2475
2494
  if (!/^https?:$/.test(target.protocol))
2476
2495
  continue;
2477
- if (target.hostname === url.hostname)
2496
+ if (sameOrg(target.hostname, url.hostname))
2478
2497
  continue;
2479
2498
  return {
2480
2499
  id: "redirector-link",
@@ -2493,7 +2512,7 @@ function assessMessageTrust(input) {
2493
2512
  relayAuthMismatch(input),
2494
2513
  zeroWidthObfuscation(input.bodyText || ""),
2495
2514
  hiddenLinkOverlay(input.bodyHtml || ""),
2496
- redirectorLink(input.bodyHtml || "")
2515
+ redirectorLink(input)
2497
2516
  ].filter(Boolean);
2498
2517
  const rank = { danger: 0, caution: 1, info: 2 };
2499
2518
  return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
@@ -2506,9 +2525,7 @@ function relayAuthMismatch(input) {
2506
2525
  for (const name of RELAY_IDENTITY_HEADERS) {
2507
2526
  const stamped = bare(header(input.headerLines, name));
2508
2527
  const stampedDomain = stamped.split("@")[1] || "";
2509
- if (!stampedDomain || stampedDomain === fromDomain)
2510
- continue;
2511
- if (stampedDomain.endsWith("." + fromDomain) || fromDomain.endsWith("." + stampedDomain))
2528
+ if (!stampedDomain || sameOrg(stampedDomain, fromDomain))
2512
2529
  continue;
2513
2530
  return {
2514
2531
  id: "relay-auth-mismatch",