@broberg/mail-identity 0.1.0 → 0.1.1
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/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -71,7 +71,7 @@ function extractAddresses(field) {
|
|
|
71
71
|
let angle = 0;
|
|
72
72
|
const flush = (end) => {
|
|
73
73
|
const a = addressOfPart(field, masked, start, end);
|
|
74
|
-
if (a) out.push(a);
|
|
74
|
+
if (a && a.includes("@")) out.push(a);
|
|
75
75
|
start = end + 1;
|
|
76
76
|
};
|
|
77
77
|
for (let i = 0; i < masked.length; i++) {
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @broberg/mail-identity — is this inbound mail provably from who the From:\n * field claims?\n *\n * Four pure functions, no I/O, no dependencies. They answer two questions and\n * refuse a third:\n *\n * WHAT IS AN ADDRESS? extractAddresses · splitPlusTag · addressesMatch\n * WHAT DOES THE HEADER SAY? readAuthResults\n * WHAT DOES A MATCH MEAN? NOT OURS. That is authorisation, and it stays\n * at the call-site where it can be read.\n *\n * The third line is the design. Moving an authorisation decision into a shared\n * dependency would build something worse than the drift it replaces: every\n * consumer inherits a policy it cannot see, and a change to that policy ships\n * silently as a patch release.\n *\n * ORIGIN: buddy and cardmem independently wrote this same rule, and BOTH had\n * holes in it, found the same day in each codebase separately. The value here is\n * not the code — it is the cases in test/, every one of them an attack that\n * WORKED against a live implementation. Each looks correct until someone builds\n * the counter-example.\n */\n\n/**\n * What the Authentication-Results header says. FOUR outcomes, not three, and\n * not two.\n *\n * pass a dmarc verdict exists and everything present passed\n * fail at least one method's verdicts unambiguously did not pass\n * conflicted one method carries BOTH a passing and a failing verdict\n * no-verdict nothing readable to judge by\n *\n * `conflicted` exists because RFC 8601 Appendix B.6 — \"Service Provided,\n * Multi-tiered Authentication Done\", the standard's own example of NORMAL\n * OPERATION — is SYNTACTICALLY IDENTICAL to a duplicate-injection attack:\n *\n * dkim=pass reason=\"good signature\" header.i=@mail-router.example.net;\n * dkim=fail reason=\"bad signature\" header.i=@newyork.example.com\n *\n * Two signatures, one good, one bad, in one header. Nothing in the header\n * distinguishes that from an attacker appending `dkim=pass`. So this package\n * does not try: it REPORTS the disagreement and the caller decides. Fail-closed\n * is a perfectly good policy — it is just not ours to choose for everyone.\n *\n * `no-verdict` must NEVER collapse into `pass`, and the inversion is why it\n * matters more than it looks: a genuine SELF-SENT mail has no verdict at all\n * (the header is written by the RECEIVING server, and a mail from your own\n * authenticated session never crosses that boundary), while an external forgery\n * HAS one and it FAILS. A gate demanding `pass` rejects precisely the mail the\n * feature exists for.\n */\nexport type AuthVerdict = \"pass\" | \"fail\" | \"conflicted\" | \"no-verdict\";\n\n/** The three methods that say something about the sending domain. */\nexport type AuthMethod = \"spf\" | \"dkim\" | \"dmarc\";\n\nconst METHODS: readonly AuthMethod[] = [\"spf\", \"dkim\", \"dmarc\"];\n\nexport interface AuthResults {\n verdict: AuthVerdict;\n /** Per-method result as read. `pass` only when every verdict for it passed. */\n spf?: string;\n dkim?: string;\n dmarc?: string;\n /** Methods carrying BOTH a passing and a failing verdict. Empty unless `conflicted`. */\n conflicted: readonly AuthMethod[];\n /** Always set, including on `pass` — a caller logging a decision needs the why. */\n reason: string;\n}\n\n/**\n * A view of `s` in which every quoted-string and every (nested) comment — and\n * their delimiters — is replaced by a SPACE, position for position.\n *\n * THIS IS THE WHOLE TRICK, and it is what a regex over the raw string cannot do.\n * Both grammars we parse (RFC 5322 address lists, RFC 8601 auth results) have\n * quoting, and in both a comment is defined to be whitespace. A regex has\n * neither notion, so it sees a legal separator wherever there is a space —\n * INCLUDING the spaces inside a quoted string. Measured against the prior\n * implementation, all three of these came from exactly that:\n *\n * 'Authentication-Results: …; dkim=pass reason=\"relayed dmarc=pass ok\"'\n * → no-verdict became PASS. Text inside a reason string became a verdict.\n *\n * 'Authentication-Results: …; dkim=pass (relayed dmarc=pass ok)'\n * → same, via a comment.\n *\n * 'To: \"x <buddy@broberg.ai>, y\" <attacker@evil.dk>'\n * → split-on-comma cut the quoted display name in half, and the half\n * carried an <addr> that was only ever decoration. A display name\n * contributed an address, which is the single property this package\n * exists to guarantee it cannot.\n *\n * Replacing with a space rather than DELETING is deliberate: deletion joins the\n * neighbours, so `dmar(x)c=pass` would become the verdict `dmarc=pass` out of\n * text that is not one. A comment is whitespace; blanking says exactly that.\n *\n * Unterminated quotes and comments blank the remainder of the string, and are\n * reported as `malformed` so a caller of this helper can refuse to read a\n * PARTIAL result out of a header it could not finish parsing. That distinction\n * is not pedantry — the qualifier values in an auth header (`smtp.mailfrom=`,\n * `header.i=`) come from the message, so an unterminated delimiter there would\n * blank every verdict AFTER it while leaving the earlier ones standing. Erasing\n * a trailing `dmarc=fail` and keeping a leading `spf=pass` is a strictly useful\n * outcome for an attacker, so a header that does not parse yields nothing.\n */\nfunction maskQuotedAndComments(s: string): { masked: string; malformed: boolean } {\n const out = s.split(\"\");\n let quoted = false;\n let comment = 0;\n\n for (let i = 0; i < s.length; i++) {\n const c = s[i]!;\n\n if ((quoted || comment > 0) && c === \"\\\\\" && i + 1 < s.length) {\n out[i] = \" \";\n out[i + 1] = \" \";\n i++;\n continue;\n }\n if (quoted) {\n out[i] = \" \";\n if (c === '\"') quoted = false;\n continue;\n }\n if (comment > 0) {\n if (c === \"(\") comment++;\n else if (c === \")\") comment--;\n out[i] = \" \";\n continue;\n }\n if (c === '\"') {\n quoted = true;\n out[i] = \" \";\n continue;\n }\n if (c === \"(\") {\n comment++;\n out[i] = \" \";\n continue;\n }\n }\n\n return { masked: out.join(\"\"), malformed: quoted || comment > 0 };\n}\n\n/** Lowercase + trim, or null when nothing is left. */\nfunction normalise(s: string): string | null {\n const t = s.trim().toLowerCase();\n return t.length > 0 ? t : null;\n}\n\n/**\n * The address of ONE address-list entry, given the raw field and its mask.\n *\n * Angle brackets are located in the MASKED view, so a `<` inside a quoted\n * display name is simply not there to be found — but the address itself is\n * sliced from the RAW string, so a quoted local part (`<\"odd name\"@example.com>`)\n * survives intact.\n */\nfunction addressOfPart(raw: string, masked: string, from: number, to: number): string | null {\n let open = -1;\n let close = -1;\n let depth = 0;\n\n for (let i = from; i < to; i++) {\n const c = masked[i]!;\n if (c === \"<\") {\n if (depth === 0) open = i;\n depth++;\n } else if (c === \">\" && depth > 0) {\n depth--;\n if (depth === 0) {\n close = i;\n break;\n }\n }\n }\n\n if (open >= 0 && close > open) return normalise(raw.slice(open + 1, close));\n\n // An UNTERMINATED '<' is malformed. Hand back the part as it stands rather\n // than guessing which half was meant to be the address — something that\n // matches nothing beats something that might match the owner.\n if (open >= 0) return normalise(raw.slice(from, to));\n\n // No angle brackets: the address is the bare text, which is the MASKED text —\n // so a comment such as `cb@webhouse.dk (Christian)` contributes nothing.\n return normalise(masked.slice(from, to));\n}\n\n/**\n * Every address in a From/To/Cc field. A DISPLAY NAME NEVER CONTRIBUTES ONE.\n *\n * 'Christian Broberg <cb@webhouse.dk>' → ['cb@webhouse.dk']\n * 'A <a@x.dk>, b@y.dk' → ['a@x.dk', 'b@y.dk']\n * '\"cb@webhouse.dk\" <attacker@evil.dk>' → ['attacker@evil.dk']\n * '\"x <buddy@broberg.ai>, y\" <a@evil.dk>' → ['a@evil.dk']\n * '\"Broberg, Christian\" <cb@webhouse.dk>' → ['cb@webhouse.dk']\n *\n * The first counter-example killed `from.includes(owner)`, which accepted the\n * owner's address written as DISPLAY TEXT — four call-paths reproduced it. The\n * second and third killed `field.split(',')`, which cuts a quoted display name\n * in half before quoting is understood. Both were live; neither was theoretical.\n *\n * Addresses are lowercased. Local parts are formally case-sensitive (RFC 5321),\n * but no provider we send through treats them that way, and NOT normalising\n * would mean `CB@webhouse.dk` fails an owner check that `cb@webhouse.dk` passes\n * — a comparison anyone can sidestep with the shift key.\n */\nexport function extractAddresses(field: string | null | undefined): string[] {\n if (!field) return [];\n // A malformed field is not rejected outright here: blanking already makes the\n // unparseable remainder match nothing, and an address list has no equivalent\n // of the partial-verdict problem — an address either equals a known one or it\n // does not.\n const { masked } = maskQuotedAndComments(field);\n\n const out: string[] = [];\n let start = 0;\n let angle = 0;\n\n const flush = (end: number): void => {\n const a = addressOfPart(field, masked, start, end);\n if (a) out.push(a);\n start = end + 1;\n };\n\n for (let i = 0; i < masked.length; i++) {\n const c = masked[i]!;\n if (c === \"<\") angle++;\n else if (c === \">\" && angle > 0) angle--;\n else if (c === \",\" && angle === 0) flush(i);\n }\n flush(masked.length);\n\n return out;\n}\n\n/**\n * Split a plus-tag off an address.\n *\n * 'buddy+whop@broberg.ai' → { address: 'buddy@broberg.ai', tag: 'whop' }\n * 'buddy@broberg.ai' → { address: 'buddy@broberg.ai', tag: null }\n *\n * TWO RULES CARRY THE SECURITY, and they are the reason this is separate from\n * matching rather than folded into it:\n *\n * 1. THE TAG INFORMS, THE ADDRESS PROVES. An unknown tag must still match. A tag\n * able to REFUSE a match would be authorisation disguised as routing, and a\n * typo (`buddy+whopp@`) would make mail vanish silently. `+admin`, `+deploy`\n * and `+urgent` must never mean anything privileged — anyone can type them.\n *\n * 2. SPLIT AFTER extractAddresses(), NEVER on a raw field. Do it the other way\n * round and you are back in the impersonation hole wearing a new hat: a\n * display name must not contribute an address, and that property lives in the\n * extraction.\n *\n * An address with more than one '@' is malformed and is returned UNTOUCHED:\n * `buddy+x@evil.com@broberg.ai` must never be reshaped into something valid.\n *\n * `tag` is `null`, not `undefined`, so that a fixture written as\n * `{ address: 'x', tag: undefined }` cannot pass a deep-equality check against\n * `{ address: 'x' }`. Two different facts, two distinguishable values.\n */\nexport function splitPlusTag(address: string): { address: string; tag: string | null } {\n const at = address.indexOf(\"@\");\n if (at < 0 || address.indexOf(\"@\", at + 1) !== -1) return { address, tag: null };\n\n const plus = address.indexOf(\"+\");\n if (plus < 0 || plus > at) return { address, tag: null };\n\n // From the FIRST '+': 'buddy+a+b@' has the tag 'a+b', not 'b'.\n const tag = address.slice(plus + 1, at);\n return { address: address.slice(0, plus) + address.slice(at), tag: tag || null };\n}\n\n/**\n * Does any address in `field` EQUAL any address in `known`?\n *\n * Equality on extracted addresses. Never a substring, never a prefix, never a\n * suffix — `cb@webhouse.dk.evil.com` and `\"cb@webhouse.dk\" <x@evil.dk>` and\n * `buddy@broberg.ai.evil.com` are all rejected, and each of the three was\n * accepted by a live implementation this week.\n *\n * Both sides go through the SAME extraction and the SAME plus-tag\n * normalisation, so `known` may be given as bare addresses or as full fields.\n * An empty or unparseable `field` matches nothing.\n */\nexport function addressesMatch(\n field: string | null | undefined,\n known: string | readonly string[],\n): boolean {\n const present = new Set(extractAddresses(field).map((a) => splitPlusTag(a).address));\n if (present.size === 0) return false;\n\n const list = typeof known === \"string\" ? [known] : known;\n return list.some((k) =>\n extractAddresses(k).some((a) => present.has(splitPlusTag(a).address)),\n );\n}\n\n/**\n * Read an Authentication-Results header (RFC 8601). Reports; does not judge.\n *\n * ANCHORING IS AN ALLOWLIST, NOT A DENYLIST. `\\b` was the original, and a dot is\n * a word boundary, so `\\bdmarc=` matched INSIDE `header.dmarc=pass` — a\n * qualified key read as a verdict:\n *\n * 'header.dmarc=pass header.dkim=pass header.spf=pass; dmarc=fail; dkim=fail; spf=fail'\n * → PASS, while every real verdict in it is `fail`.\n *\n * The first fix was a denylist, `(?<![\\w.-])`. Swept over the whole printable\n * character set it left 27 characters open, including a colon — `arc:dmarc=pass`\n * read as a pass. A denylist has to predict every character a future vendor\n * invents. So: a verdict must stand at the start of the header or after a REAL\n * separator (whitespace, `;` or `,` — RFC 8601's own), and anything else in\n * front of it makes it a qualified key rather than a verdict.\n *\n * Quoted strings and comments are blanked before matching (see\n * maskQuotedAndComments) — they are two more places a verdict cannot stand, and\n * both manufactured verdicts in the prior implementation.\n *\n * ALL occurrences are collected, not the first: a header can carry more than one\n * verdict per method, and \"first wins\" lets an injected `dmarc=pass` drown out\n * the real `dmarc=fail`.\n *\n * PRECEDENCE IS SECURITY-BEARING. An unambiguous fail beats a conflict:\n *\n * 'dkim=pass; dkim=fail; dmarc=fail' → fail, NOT conflicted\n *\n * A method where NO verdict passed is stronger evidence than a disagreement\n * somewhere else. Get the order wrong and a genuine, unambiguous rejection is\n * downgraded to \"cannot determine\" — which a lenient caller then lets through.\n */\nexport function readAuthResults(header: string | null | undefined): AuthResults {\n if (!header || !header.trim()) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason: \"no Authentication-Results header — the sender is unproven, which is not the same as disproven\",\n };\n }\n\n const { masked, malformed } = maskQuotedAndComments(header);\n if (malformed) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason:\n \"unterminated quoted string or comment — the header could not be parsed to the end, \" +\n \"and a verdict read out of the readable half would be a verdict an attacker chose the boundary of\",\n };\n }\n\n const found: Partial<Record<AuthMethod, string>> = {};\n const conflicted: AuthMethod[] = [];\n\n for (const method of METHODS) {\n // The optional `/1` is RFC 8601's method-version, and the `\\s*` around it\n // and around `=` is CFWS — comments are already spaces by now. Appendix B.7,\n // the RFC's own \"perfectly legal\" example, is written\n // `dkim (Because I like it) / 1 (One yay) = (wait for it) fail`, and a\n // parser too narrow to read it would report no-verdict on a header that\n // plainly says fail. Rejecting genuine mail is the equally expensive\n // opposite error.\n const re = new RegExp(`(?<=^|[\\\\s;,])${method}\\\\s*(?:/\\\\s*\\\\d+\\\\s*)?=\\\\s*([a-z]+)`, \"gi\");\n const verdicts = [...masked.matchAll(re)].map((m) => m[1]!.toLowerCase());\n if (verdicts.length === 0) continue;\n\n const passed = verdicts.filter((v) => v === \"pass\").length;\n // DISAGREEMENT and UNIFORMLY-NOT-PASSED are two different facts:\n // dmarc=fail a real, unambiguous rejection\n // dkim=pass … dkim=fail a disagreement (RFC B.6 legal, OR injected)\n // Collapsing both into `fail` hides the difference from the caller and puts\n // the policy here, where no consumer can change it. A REPEATED AGREEING\n // verdict is not a conflict — a relay may legitimately repeat itself, and\n // reading that as tampering blocks real mail.\n if (passed > 0 && passed < verdicts.length) conflicted.push(method);\n found[method] = passed === verdicts.length ? \"pass\" : verdicts.find((v) => v !== \"pass\")!;\n }\n\n if (Object.keys(found).length === 0) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason: \"Authentication-Results was present but carried no readable spf/dkim/dmarc verdict\",\n };\n }\n\n const failed = METHODS.filter(\n (m) => found[m] !== undefined && found[m] !== \"pass\" && !conflicted.includes(m),\n );\n if (failed.length > 0) {\n return {\n verdict: \"fail\",\n ...found,\n conflicted,\n reason: `rejected: ${failed.map((m) => `${m}=${found[m]}`).join(\", \")}`,\n };\n }\n\n if (conflicted.length > 0) {\n return {\n verdict: \"conflicted\",\n ...found,\n conflicted,\n reason:\n `contradictory verdicts for ${conflicted.join(\", \")} — the header carries both a pass and a non-pass. ` +\n \"This is legal multi-tiered authentication (RFC 8601 B.6) OR an injected verdict, and the header cannot tell you which. Your call.\",\n };\n }\n\n // DMARC is the one that binds the From: field to what passed. Without it we\n // know SOMETHING authenticated, but not that the claimed sender is who did.\n if (found.dmarc === undefined) {\n return {\n verdict: \"no-verdict\",\n ...found,\n conflicted,\n reason: \"no dmarc verdict — the From: field is not bound to whatever did pass\",\n };\n }\n\n return { verdict: \"pass\", ...found, conflicted, reason: \"spf/dkim/dmarc pass\" };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @broberg/mail-identity — is this inbound mail provably from who the From:\n * field claims?\n *\n * Four pure functions, no I/O, no dependencies. They answer two questions and\n * refuse a third:\n *\n * WHAT IS AN ADDRESS? extractAddresses · splitPlusTag · addressesMatch\n * WHAT DOES THE HEADER SAY? readAuthResults\n * WHAT DOES A MATCH MEAN? NOT OURS. That is authorisation, and it stays\n * at the call-site where it can be read.\n *\n * The third line is the design. Moving an authorisation decision into a shared\n * dependency would build something worse than the drift it replaces: every\n * consumer inherits a policy it cannot see, and a change to that policy ships\n * silently as a patch release.\n *\n * ORIGIN: buddy and cardmem independently wrote this same rule, and BOTH had\n * holes in it, found the same day in each codebase separately. The value here is\n * not the code — it is the cases in test/, every one of them an attack that\n * WORKED against a live implementation. Each looks correct until someone builds\n * the counter-example.\n */\n\n/**\n * What the Authentication-Results header says. FOUR outcomes, not three, and\n * not two.\n *\n * pass a dmarc verdict exists and everything present passed\n * fail at least one method's verdicts unambiguously did not pass\n * conflicted one method carries BOTH a passing and a failing verdict\n * no-verdict nothing readable to judge by\n *\n * `conflicted` exists because RFC 8601 Appendix B.6 — \"Service Provided,\n * Multi-tiered Authentication Done\", the standard's own example of NORMAL\n * OPERATION — is SYNTACTICALLY IDENTICAL to a duplicate-injection attack:\n *\n * dkim=pass reason=\"good signature\" header.i=@mail-router.example.net;\n * dkim=fail reason=\"bad signature\" header.i=@newyork.example.com\n *\n * Two signatures, one good, one bad, in one header. Nothing in the header\n * distinguishes that from an attacker appending `dkim=pass`. So this package\n * does not try: it REPORTS the disagreement and the caller decides. Fail-closed\n * is a perfectly good policy — it is just not ours to choose for everyone.\n *\n * `no-verdict` must NEVER collapse into `pass`, and the inversion is why it\n * matters more than it looks: a genuine SELF-SENT mail has no verdict at all\n * (the header is written by the RECEIVING server, and a mail from your own\n * authenticated session never crosses that boundary), while an external forgery\n * HAS one and it FAILS. A gate demanding `pass` rejects precisely the mail the\n * feature exists for.\n */\nexport type AuthVerdict = \"pass\" | \"fail\" | \"conflicted\" | \"no-verdict\";\n\n/** The three methods that say something about the sending domain. */\nexport type AuthMethod = \"spf\" | \"dkim\" | \"dmarc\";\n\nconst METHODS: readonly AuthMethod[] = [\"spf\", \"dkim\", \"dmarc\"];\n\nexport interface AuthResults {\n verdict: AuthVerdict;\n /** Per-method result as read. `pass` only when every verdict for it passed. */\n spf?: string;\n dkim?: string;\n dmarc?: string;\n /** Methods carrying BOTH a passing and a failing verdict. Empty unless `conflicted`. */\n conflicted: readonly AuthMethod[];\n /** Always set, including on `pass` — a caller logging a decision needs the why. */\n reason: string;\n}\n\n/**\n * A view of `s` in which every quoted-string and every (nested) comment — and\n * their delimiters — is replaced by a SPACE, position for position.\n *\n * THIS IS THE WHOLE TRICK, and it is what a regex over the raw string cannot do.\n * Both grammars we parse (RFC 5322 address lists, RFC 8601 auth results) have\n * quoting, and in both a comment is defined to be whitespace. A regex has\n * neither notion, so it sees a legal separator wherever there is a space —\n * INCLUDING the spaces inside a quoted string. Measured against the prior\n * implementation, all three of these came from exactly that:\n *\n * 'Authentication-Results: …; dkim=pass reason=\"relayed dmarc=pass ok\"'\n * → no-verdict became PASS. Text inside a reason string became a verdict.\n *\n * 'Authentication-Results: …; dkim=pass (relayed dmarc=pass ok)'\n * → same, via a comment.\n *\n * 'To: \"x <buddy@broberg.ai>, y\" <attacker@evil.dk>'\n * → split-on-comma cut the quoted display name in half, and the half\n * carried an <addr> that was only ever decoration. A display name\n * contributed an address, which is the single property this package\n * exists to guarantee it cannot.\n *\n * Replacing with a space rather than DELETING is deliberate: deletion joins the\n * neighbours, so `dmar(x)c=pass` would become the verdict `dmarc=pass` out of\n * text that is not one. A comment is whitespace; blanking says exactly that.\n *\n * Unterminated quotes and comments blank the remainder of the string, and are\n * reported as `malformed` so a caller of this helper can refuse to read a\n * PARTIAL result out of a header it could not finish parsing. That distinction\n * is not pedantry — the qualifier values in an auth header (`smtp.mailfrom=`,\n * `header.i=`) come from the message, so an unterminated delimiter there would\n * blank every verdict AFTER it while leaving the earlier ones standing. Erasing\n * a trailing `dmarc=fail` and keeping a leading `spf=pass` is a strictly useful\n * outcome for an attacker, so a header that does not parse yields nothing.\n */\nfunction maskQuotedAndComments(s: string): { masked: string; malformed: boolean } {\n const out = s.split(\"\");\n let quoted = false;\n let comment = 0;\n\n for (let i = 0; i < s.length; i++) {\n const c = s[i]!;\n\n if ((quoted || comment > 0) && c === \"\\\\\" && i + 1 < s.length) {\n out[i] = \" \";\n out[i + 1] = \" \";\n i++;\n continue;\n }\n if (quoted) {\n out[i] = \" \";\n if (c === '\"') quoted = false;\n continue;\n }\n if (comment > 0) {\n if (c === \"(\") comment++;\n else if (c === \")\") comment--;\n out[i] = \" \";\n continue;\n }\n if (c === '\"') {\n quoted = true;\n out[i] = \" \";\n continue;\n }\n if (c === \"(\") {\n comment++;\n out[i] = \" \";\n continue;\n }\n }\n\n return { masked: out.join(\"\"), malformed: quoted || comment > 0 };\n}\n\n/** Lowercase + trim, or null when nothing is left. */\nfunction normalise(s: string): string | null {\n const t = s.trim().toLowerCase();\n return t.length > 0 ? t : null;\n}\n\n/**\n * The address of ONE address-list entry, given the raw field and its mask.\n *\n * Angle brackets are located in the MASKED view, so a `<` inside a quoted\n * display name is simply not there to be found — but the address itself is\n * sliced from the RAW string, so a quoted local part (`<\"odd name\"@example.com>`)\n * survives intact.\n */\nfunction addressOfPart(raw: string, masked: string, from: number, to: number): string | null {\n let open = -1;\n let close = -1;\n let depth = 0;\n\n for (let i = from; i < to; i++) {\n const c = masked[i]!;\n if (c === \"<\") {\n if (depth === 0) open = i;\n depth++;\n } else if (c === \">\" && depth > 0) {\n depth--;\n if (depth === 0) {\n close = i;\n break;\n }\n }\n }\n\n if (open >= 0 && close > open) return normalise(raw.slice(open + 1, close));\n\n // An UNTERMINATED '<' is malformed. Hand back the part as it stands rather\n // than guessing which half was meant to be the address — something that\n // matches nothing beats something that might match the owner.\n if (open >= 0) return normalise(raw.slice(from, to));\n\n // No angle brackets: the address is the bare text, which is the MASKED text —\n // so a comment such as `cb@webhouse.dk (Christian)` contributes nothing.\n return normalise(masked.slice(from, to));\n}\n\n/**\n * Every address in a From/To/Cc field. A DISPLAY NAME NEVER CONTRIBUTES ONE.\n *\n * 'Christian Broberg <cb@webhouse.dk>' → ['cb@webhouse.dk']\n * 'A <a@x.dk>, b@y.dk' → ['a@x.dk', 'b@y.dk']\n * '\"cb@webhouse.dk\" <attacker@evil.dk>' → ['attacker@evil.dk']\n * '\"x <buddy@broberg.ai>, y\" <a@evil.dk>' → ['a@evil.dk']\n * '\"Broberg, Christian\" <cb@webhouse.dk>' → ['cb@webhouse.dk']\n *\n * The first counter-example killed `from.includes(owner)`, which accepted the\n * owner's address written as DISPLAY TEXT — four call-paths reproduced it. The\n * second and third killed `field.split(',')`, which cuts a quoted display name\n * in half before quoting is understood. Both were live; neither was theoretical.\n *\n * Addresses are lowercased. Local parts are formally case-sensitive (RFC 5321),\n * but no provider we send through treats them that way, and NOT normalising\n * would mean `CB@webhouse.dk` fails an owner check that `cb@webhouse.dk` passes\n * — a comparison anyone can sidestep with the shift key.\n */\nexport function extractAddresses(field: string | null | undefined): string[] {\n if (!field) return [];\n // A malformed field is not rejected outright here: blanking already makes the\n // unparseable remainder match nothing, and an address list has no equivalent\n // of the partial-verdict problem — an address either equals a known one or it\n // does not.\n const { masked } = maskQuotedAndComments(field);\n\n const out: string[] = [];\n let start = 0;\n let angle = 0;\n\n const flush = (end: number): void => {\n const a = addressOfPart(field, masked, start, end);\n // 0.1.1 — a part with no '@' is not an address (buddy, 31/10: `No Address\n // Here` came back as ['no address here']). Text is not promoted to an\n // address because the field happened to contain no brackets.\n if (a && a.includes(\"@\")) out.push(a);\n start = end + 1;\n };\n\n for (let i = 0; i < masked.length; i++) {\n const c = masked[i]!;\n if (c === \"<\") angle++;\n else if (c === \">\" && angle > 0) angle--;\n else if (c === \",\" && angle === 0) flush(i);\n }\n flush(masked.length);\n\n return out;\n}\n\n/**\n * Split a plus-tag off an address.\n *\n * 'buddy+whop@broberg.ai' → { address: 'buddy@broberg.ai', tag: 'whop' }\n * 'buddy@broberg.ai' → { address: 'buddy@broberg.ai', tag: null }\n *\n * TWO RULES CARRY THE SECURITY, and they are the reason this is separate from\n * matching rather than folded into it:\n *\n * 1. THE TAG INFORMS, THE ADDRESS PROVES. An unknown tag must still match. A tag\n * able to REFUSE a match would be authorisation disguised as routing, and a\n * typo (`buddy+whopp@`) would make mail vanish silently. `+admin`, `+deploy`\n * and `+urgent` must never mean anything privileged — anyone can type them.\n *\n * 2. SPLIT AFTER extractAddresses(), NEVER on a raw field. Do it the other way\n * round and you are back in the impersonation hole wearing a new hat: a\n * display name must not contribute an address, and that property lives in the\n * extraction.\n *\n * An address with more than one '@' is malformed and is returned UNTOUCHED:\n * `buddy+x@evil.com@broberg.ai` must never be reshaped into something valid.\n *\n * `tag` is `null`, not `undefined`, so that a fixture written as\n * `{ address: 'x', tag: undefined }` cannot pass a deep-equality check against\n * `{ address: 'x' }`. Two different facts, two distinguishable values.\n */\nexport function splitPlusTag(address: string): { address: string; tag: string | null } {\n const at = address.indexOf(\"@\");\n if (at < 0 || address.indexOf(\"@\", at + 1) !== -1) return { address, tag: null };\n\n const plus = address.indexOf(\"+\");\n if (plus < 0 || plus > at) return { address, tag: null };\n\n // From the FIRST '+': 'buddy+a+b@' has the tag 'a+b', not 'b'.\n const tag = address.slice(plus + 1, at);\n return { address: address.slice(0, plus) + address.slice(at), tag: tag || null };\n}\n\n/**\n * Does any address in `field` EQUAL any address in `known`?\n *\n * Equality on extracted addresses. Never a substring, never a prefix, never a\n * suffix — `cb@webhouse.dk.evil.com` and `\"cb@webhouse.dk\" <x@evil.dk>` and\n * `buddy@broberg.ai.evil.com` are all rejected, and each of the three was\n * accepted by a live implementation this week.\n *\n * Both sides go through the SAME extraction and the SAME plus-tag\n * normalisation, so `known` may be given as bare addresses or as full fields.\n * An empty or unparseable `field` matches nothing.\n */\nexport function addressesMatch(\n field: string | null | undefined,\n known: string | readonly string[],\n): boolean {\n const present = new Set(extractAddresses(field).map((a) => splitPlusTag(a).address));\n if (present.size === 0) return false;\n\n const list = typeof known === \"string\" ? [known] : known;\n return list.some((k) =>\n extractAddresses(k).some((a) => present.has(splitPlusTag(a).address)),\n );\n}\n\n/**\n * Read an Authentication-Results header (RFC 8601). Reports; does not judge.\n *\n * ANCHORING IS AN ALLOWLIST, NOT A DENYLIST. `\\b` was the original, and a dot is\n * a word boundary, so `\\bdmarc=` matched INSIDE `header.dmarc=pass` — a\n * qualified key read as a verdict:\n *\n * 'header.dmarc=pass header.dkim=pass header.spf=pass; dmarc=fail; dkim=fail; spf=fail'\n * → PASS, while every real verdict in it is `fail`.\n *\n * The first fix was a denylist, `(?<![\\w.-])`. Swept over the whole printable\n * character set it left 27 characters open, including a colon — `arc:dmarc=pass`\n * read as a pass. A denylist has to predict every character a future vendor\n * invents. So: a verdict must stand at the start of the header or after a REAL\n * separator (whitespace, `;` or `,` — RFC 8601's own), and anything else in\n * front of it makes it a qualified key rather than a verdict.\n *\n * Quoted strings and comments are blanked before matching (see\n * maskQuotedAndComments) — they are two more places a verdict cannot stand, and\n * both manufactured verdicts in the prior implementation.\n *\n * ALL occurrences are collected, not the first: a header can carry more than one\n * verdict per method, and \"first wins\" lets an injected `dmarc=pass` drown out\n * the real `dmarc=fail`.\n *\n * PRECEDENCE IS SECURITY-BEARING. An unambiguous fail beats a conflict:\n *\n * 'dkim=pass; dkim=fail; dmarc=fail' → fail, NOT conflicted\n *\n * A method where NO verdict passed is stronger evidence than a disagreement\n * somewhere else. Get the order wrong and a genuine, unambiguous rejection is\n * downgraded to \"cannot determine\" — which a lenient caller then lets through.\n */\nexport function readAuthResults(header: string | null | undefined): AuthResults {\n if (!header || !header.trim()) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason: \"no Authentication-Results header — the sender is unproven, which is not the same as disproven\",\n };\n }\n\n const { masked, malformed } = maskQuotedAndComments(header);\n if (malformed) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason:\n \"unterminated quoted string or comment — the header could not be parsed to the end, \" +\n \"and a verdict read out of the readable half would be a verdict an attacker chose the boundary of\",\n };\n }\n\n const found: Partial<Record<AuthMethod, string>> = {};\n const conflicted: AuthMethod[] = [];\n\n for (const method of METHODS) {\n // The optional `/1` is RFC 8601's method-version, and the `\\s*` around it\n // and around `=` is CFWS — comments are already spaces by now. Appendix B.7,\n // the RFC's own \"perfectly legal\" example, is written\n // `dkim (Because I like it) / 1 (One yay) = (wait for it) fail`, and a\n // parser too narrow to read it would report no-verdict on a header that\n // plainly says fail. Rejecting genuine mail is the equally expensive\n // opposite error.\n const re = new RegExp(`(?<=^|[\\\\s;,])${method}\\\\s*(?:/\\\\s*\\\\d+\\\\s*)?=\\\\s*([a-z]+)`, \"gi\");\n const verdicts = [...masked.matchAll(re)].map((m) => m[1]!.toLowerCase());\n if (verdicts.length === 0) continue;\n\n const passed = verdicts.filter((v) => v === \"pass\").length;\n // DISAGREEMENT and UNIFORMLY-NOT-PASSED are two different facts:\n // dmarc=fail a real, unambiguous rejection\n // dkim=pass … dkim=fail a disagreement (RFC B.6 legal, OR injected)\n // Collapsing both into `fail` hides the difference from the caller and puts\n // the policy here, where no consumer can change it. A REPEATED AGREEING\n // verdict is not a conflict — a relay may legitimately repeat itself, and\n // reading that as tampering blocks real mail.\n if (passed > 0 && passed < verdicts.length) conflicted.push(method);\n found[method] = passed === verdicts.length ? \"pass\" : verdicts.find((v) => v !== \"pass\")!;\n }\n\n if (Object.keys(found).length === 0) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason: \"Authentication-Results was present but carried no readable spf/dkim/dmarc verdict\",\n };\n }\n\n const failed = METHODS.filter(\n (m) => found[m] !== undefined && found[m] !== \"pass\" && !conflicted.includes(m),\n );\n if (failed.length > 0) {\n return {\n verdict: \"fail\",\n ...found,\n conflicted,\n reason: `rejected: ${failed.map((m) => `${m}=${found[m]}`).join(\", \")}`,\n };\n }\n\n if (conflicted.length > 0) {\n return {\n verdict: \"conflicted\",\n ...found,\n conflicted,\n reason:\n `contradictory verdicts for ${conflicted.join(\", \")} — the header carries both a pass and a non-pass. ` +\n \"This is legal multi-tiered authentication (RFC 8601 B.6) OR an injected verdict, and the header cannot tell you which. Your call.\",\n };\n }\n\n // DMARC is the one that binds the From: field to what passed. Without it we\n // know SOMETHING authenticated, but not that the claimed sender is who did.\n if (found.dmarc === undefined) {\n return {\n verdict: \"no-verdict\",\n ...found,\n conflicted,\n reason: \"no dmarc verdict — the From: field is not bound to whatever did pass\",\n };\n }\n\n return { verdict: \"pass\", ...found, conflicted, reason: \"spf/dkim/dmarc pass\" };\n}\n"]}
|
package/dist/index.js
CHANGED
|
@@ -69,7 +69,7 @@ function extractAddresses(field) {
|
|
|
69
69
|
let angle = 0;
|
|
70
70
|
const flush = (end) => {
|
|
71
71
|
const a = addressOfPart(field, masked, start, end);
|
|
72
|
-
if (a) out.push(a);
|
|
72
|
+
if (a && a.includes("@")) out.push(a);
|
|
73
73
|
start = end + 1;
|
|
74
74
|
};
|
|
75
75
|
for (let i = 0; i < masked.length; i++) {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";AAyDA,IAAM,OAAA,GAAiC,CAAC,KAAA,EAAO,MAAA,EAAQ,OAAO,CAAA;AAkD9D,SAAS,sBAAsB,CAAA,EAAmD;AAChF,EAAA,MAAM,GAAA,GAAM,CAAA,CAAE,KAAA,CAAM,EAAE,CAAA;AACtB,EAAA,IAAI,MAAA,GAAS,KAAA;AACb,EAAA,IAAI,OAAA,GAAU,CAAA;AAEd,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,CAAA,CAAE,QAAQ,CAAA,EAAA,EAAK;AACjC,IAAA,MAAM,CAAA,GAAI,EAAE,CAAC,CAAA;AAEb,IAAA,IAAA,CAAK,MAAA,IAAU,UAAU,CAAA,KAAM,CAAA,KAAM,QAAQ,CAAA,GAAI,CAAA,GAAI,EAAE,MAAA,EAAQ;AAC7D,MAAA,GAAA,CAAI,CAAC,CAAA,GAAI,GAAA;AACT,MAAA,GAAA,CAAI,CAAA,GAAI,CAAC,CAAA,GAAI,GAAA;AACb,MAAA,CAAA,EAAA;AACA,MAAA;AAAA,IACF;AACA,IAAA,IAAI,MAAA,EAAQ;AACV,MAAA,GAAA,CAAI,CAAC,CAAA,GAAI,GAAA;AACT,MAAA,IAAI,CAAA,KAAM,KAAK,MAAA,GAAS,KAAA;AACxB,MAAA;AAAA,IACF;AACA,IAAA,IAAI,UAAU,CAAA,EAAG;AACf,MAAA,IAAI,MAAM,GAAA,EAAK,OAAA,EAAA;AAAA,WAAA,IACN,MAAM,GAAA,EAAK,OAAA,EAAA;AACpB,MAAA,GAAA,CAAI,CAAC,CAAA,GAAI,GAAA;AACT,MAAA;AAAA,IACF;AACA,IAAA,IAAI,MAAM,GAAA,EAAK;AACb,MAAA,MAAA,GAAS,IAAA;AACT,MAAA,GAAA,CAAI,CAAC,CAAA,GAAI,GAAA;AACT,MAAA;AAAA,IACF;AACA,IAAA,IAAI,MAAM,GAAA,EAAK;AACb,MAAA,OAAA,EAAA;AACA,MAAA,GAAA,CAAI,CAAC,CAAA,GAAI,GAAA;AACT,MAAA;AAAA,IACF;AAAA,EACF;AAEA,EAAA,OAAO,EAAE,QAAQ,GAAA,CAAI,IAAA,CAAK,EAAE,CAAA,EAAG,SAAA,EAAW,MAAA,IAAU,OAAA,GAAU,CAAA,EAAE;AAClE;AAGA,SAAS,UAAU,CAAA,EAA0B;AAC3C,EAAA,MAAM,CAAA,GAAI,CAAA,CAAE,IAAA,EAAK,CAAE,WAAA,EAAY;AAC/B,EAAA,OAAO,CAAA,CAAE,MAAA,GAAS,CAAA,GAAI,CAAA,GAAI,IAAA;AAC5B;AAUA,SAAS,aAAA,CAAc,GAAA,EAAa,MAAA,EAAgB,IAAA,EAAc,EAAA,EAA2B;AAC3F,EAAA,IAAI,IAAA,GAAO,EAAA;AACX,EAAA,IAAI,KAAA,GAAQ,EAAA;AACZ,EAAA,IAAI,KAAA,GAAQ,CAAA;AAEZ,EAAA,KAAA,IAAS,CAAA,GAAI,IAAA,EAAM,CAAA,GAAI,EAAA,EAAI,CAAA,EAAA,EAAK;AAC9B,IAAA,MAAM,CAAA,GAAI,OAAO,CAAC,CAAA;AAClB,IAAA,IAAI,MAAM,GAAA,EAAK;AACb,MAAA,IAAI,KAAA,KAAU,GAAG,IAAA,GAAO,CAAA;AACxB,MAAA,KAAA,EAAA;AAAA,IACF,CAAA,MAAA,IAAW,CAAA,KAAM,GAAA,IAAO,KAAA,GAAQ,CAAA,EAAG;AACjC,MAAA,KAAA,EAAA;AACA,MAAA,IAAI,UAAU,CAAA,EAAG;AACf,QAAA,KAAA,GAAQ,CAAA;AACR,QAAA;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,EAAA,IAAI,IAAA,IAAQ,CAAA,IAAK,KAAA,GAAQ,IAAA,EAAM,OAAO,SAAA,CAAU,GAAA,CAAI,KAAA,CAAM,IAAA,GAAO,CAAA,EAAG,KAAK,CAAC,CAAA;AAK1E,EAAA,IAAI,IAAA,IAAQ,GAAG,OAAO,SAAA,CAAU,IAAI,KAAA,CAAM,IAAA,EAAM,EAAE,CAAC,CAAA;AAInD,EAAA,OAAO,SAAA,CAAU,MAAA,CAAO,KAAA,CAAM,IAAA,EAAM,EAAE,CAAC,CAAA;AACzC;AAqBO,SAAS,iBAAiB,KAAA,EAA4C;AAC3E,EAAA,IAAI,CAAC,KAAA,EAAO,OAAO,EAAC;AAKpB,EAAA,MAAM,EAAE,MAAA,EAAO,GAAI,qBAAA,CAAsB,KAAK,CAAA;AAE9C,EAAA,MAAM,MAAgB,EAAC;AACvB,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,IAAI,KAAA,GAAQ,CAAA;AAEZ,EAAA,MAAM,KAAA,GAAQ,CAAC,GAAA,KAAsB;AACnC,IAAA,MAAM,CAAA,GAAI,aAAA,CAAc,KAAA,EAAO,MAAA,EAAQ,OAAO,GAAG,CAAA;AACjD,IAAA,IAAI,CAAA,EAAG,GAAA,CAAI,IAAA,CAAK,CAAC,CAAA;AACjB,IAAA,KAAA,GAAQ,GAAA,GAAM,CAAA;AAAA,EAChB,CAAA;AAEA,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,MAAA,CAAO,QAAQ,CAAA,EAAA,EAAK;AACtC,IAAA,MAAM,CAAA,GAAI,OAAO,CAAC,CAAA;AAClB,IAAA,IAAI,MAAM,GAAA,EAAK,KAAA,EAAA;AAAA,SAAA,IACN,CAAA,KAAM,GAAA,IAAO,KAAA,GAAQ,CAAA,EAAG,KAAA,EAAA;AAAA,SAAA,IACxB,CAAA,KAAM,GAAA,IAAO,KAAA,KAAU,CAAA,QAAS,CAAC,CAAA;AAAA,EAC5C;AACA,EAAA,KAAA,CAAM,OAAO,MAAM,CAAA;AAEnB,EAAA,OAAO,GAAA;AACT;AA4BO,SAAS,aAAa,OAAA,EAA0D;AACrF,EAAA,MAAM,EAAA,GAAK,OAAA,CAAQ,OAAA,CAAQ,GAAG,CAAA;AAC9B,EAAA,IAAI,EAAA,GAAK,CAAA,IAAK,OAAA,CAAQ,OAAA,CAAQ,GAAA,EAAK,EAAA,GAAK,CAAC,CAAA,KAAM,EAAA,EAAI,OAAO,EAAE,OAAA,EAAS,KAAK,IAAA,EAAK;AAE/E,EAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,OAAA,CAAQ,GAAG,CAAA;AAChC,EAAA,IAAI,IAAA,GAAO,KAAK,IAAA,GAAO,EAAA,SAAW,EAAE,OAAA,EAAS,KAAK,IAAA,EAAK;AAGvD,EAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,KAAA,CAAM,IAAA,GAAO,GAAG,EAAE,CAAA;AACtC,EAAA,OAAO,EAAE,OAAA,EAAS,OAAA,CAAQ,KAAA,CAAM,CAAA,EAAG,IAAI,CAAA,GAAI,OAAA,CAAQ,KAAA,CAAM,EAAE,CAAA,EAAG,GAAA,EAAK,OAAO,IAAA,EAAK;AACjF;AAcO,SAAS,cAAA,CACd,OACA,KAAA,EACS;AACT,EAAA,MAAM,OAAA,GAAU,IAAI,GAAA,CAAI,gBAAA,CAAiB,KAAK,CAAA,CAAE,GAAA,CAAI,CAAC,CAAA,KAAM,YAAA,CAAa,CAAC,CAAA,CAAE,OAAO,CAAC,CAAA;AACnF,EAAA,IAAI,OAAA,CAAQ,IAAA,KAAS,CAAA,EAAG,OAAO,KAAA;AAE/B,EAAA,MAAM,OAAO,OAAO,KAAA,KAAU,QAAA,GAAW,CAAC,KAAK,CAAA,GAAI,KAAA;AACnD,EAAA,OAAO,IAAA,CAAK,IAAA;AAAA,IAAK,CAAC,CAAA,KAChB,gBAAA,CAAiB,CAAC,EAAE,IAAA,CAAK,CAAC,CAAA,KAAM,OAAA,CAAQ,GAAA,CAAI,YAAA,CAAa,CAAC,CAAA,CAAE,OAAO,CAAC;AAAA,GACtE;AACF;AAmCO,SAAS,gBAAgB,MAAA,EAAgD;AAC9E,EAAA,IAAI,CAAC,MAAA,IAAU,CAAC,MAAA,CAAO,MAAK,EAAG;AAC7B,IAAA,OAAO;AAAA,MACL,OAAA,EAAS,YAAA;AAAA,MACT,YAAY,EAAC;AAAA,MACb,MAAA,EAAQ;AAAA,KACV;AAAA,EACF;AAEA,EAAA,MAAM,EAAE,MAAA,EAAQ,SAAA,EAAU,GAAI,sBAAsB,MAAM,CAAA;AAC1D,EAAA,IAAI,SAAA,EAAW;AACb,IAAA,OAAO;AAAA,MACL,OAAA,EAAS,YAAA;AAAA,MACT,YAAY,EAAC;AAAA,MACb,MAAA,EACE;AAAA,KAEJ;AAAA,EACF;AAEA,EAAA,MAAM,QAA6C,EAAC;AACpD,EAAA,MAAM,aAA2B,EAAC;AAElC,EAAA,KAAA,MAAW,UAAU,OAAA,EAAS;AAQ5B,IAAA,MAAM,KAAK,IAAI,MAAA,CAAO,CAAA,cAAA,EAAiB,MAAM,uCAAuC,IAAI,CAAA;AACxF,IAAA,MAAM,QAAA,GAAW,CAAC,GAAG,MAAA,CAAO,SAAS,EAAE,CAAC,CAAA,CAAE,GAAA,CAAI,CAAC,CAAA,KAAM,CAAA,CAAE,CAAC,CAAA,CAAG,aAAa,CAAA;AACxE,IAAA,IAAI,QAAA,CAAS,WAAW,CAAA,EAAG;AAE3B,IAAA,MAAM,SAAS,QAAA,CAAS,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,KAAM,MAAM,CAAA,CAAE,MAAA;AAQpD,IAAA,IAAI,SAAS,CAAA,IAAK,MAAA,GAAS,SAAS,MAAA,EAAQ,UAAA,CAAW,KAAK,MAAM,CAAA;AAClE,IAAA,KAAA,CAAM,MAAM,CAAA,GAAI,MAAA,KAAW,QAAA,CAAS,MAAA,GAAS,MAAA,GAAS,QAAA,CAAS,IAAA,CAAK,CAAC,CAAA,KAAM,CAAA,KAAM,MAAM,CAAA;AAAA,EACzF;AAEA,EAAA,IAAI,MAAA,CAAO,IAAA,CAAK,KAAK,CAAA,CAAE,WAAW,CAAA,EAAG;AACnC,IAAA,OAAO;AAAA,MACL,OAAA,EAAS,YAAA;AAAA,MACT,YAAY,EAAC;AAAA,MACb,MAAA,EAAQ;AAAA,KACV;AAAA,EACF;AAEA,EAAA,MAAM,SAAS,OAAA,CAAQ,MAAA;AAAA,IACrB,CAAC,CAAA,KAAM,KAAA,CAAM,CAAC,CAAA,KAAM,MAAA,IAAa,KAAA,CAAM,CAAC,CAAA,KAAM,MAAA,IAAU,CAAC,UAAA,CAAW,SAAS,CAAC;AAAA,GAChF;AACA,EAAA,IAAI,MAAA,CAAO,SAAS,CAAA,EAAG;AACrB,IAAA,OAAO;AAAA,MACL,OAAA,EAAS,MAAA;AAAA,MACT,GAAG,KAAA;AAAA,MACH,UAAA;AAAA,MACA,QAAQ,CAAA,UAAA,EAAa,MAAA,CAAO,GAAA,CAAI,CAAC,MAAM,CAAA,EAAG,CAAC,CAAA,CAAA,EAAI,KAAA,CAAM,CAAC,CAAC,CAAA,CAAE,CAAA,CAAE,IAAA,CAAK,IAAI,CAAC,CAAA;AAAA,KACvE;AAAA,EACF;AAEA,EAAA,IAAI,UAAA,CAAW,SAAS,CAAA,EAAG;AACzB,IAAA,OAAO;AAAA,MACL,OAAA,EAAS,YAAA;AAAA,MACT,GAAG,KAAA;AAAA,MACH,UAAA;AAAA,MACA,MAAA,EACE,CAAA,2BAAA,EAA8B,UAAA,CAAW,IAAA,CAAK,IAAI,CAAC,CAAA,wLAAA;AAAA,KAEvD;AAAA,EACF;AAIA,EAAA,IAAI,KAAA,CAAM,UAAU,MAAA,EAAW;AAC7B,IAAA,OAAO;AAAA,MACL,OAAA,EAAS,YAAA;AAAA,MACT,GAAG,KAAA;AAAA,MACH,UAAA;AAAA,MACA,MAAA,EAAQ;AAAA,KACV;AAAA,EACF;AAEA,EAAA,OAAO,EAAE,OAAA,EAAS,MAAA,EAAQ,GAAG,KAAA,EAAO,UAAA,EAAY,QAAQ,qBAAA,EAAsB;AAChF","file":"index.js","sourcesContent":["/**\n * @broberg/mail-identity — is this inbound mail provably from who the From:\n * field claims?\n *\n * Four pure functions, no I/O, no dependencies. They answer two questions and\n * refuse a third:\n *\n * WHAT IS AN ADDRESS? extractAddresses · splitPlusTag · addressesMatch\n * WHAT DOES THE HEADER SAY? readAuthResults\n * WHAT DOES A MATCH MEAN? NOT OURS. That is authorisation, and it stays\n * at the call-site where it can be read.\n *\n * The third line is the design. Moving an authorisation decision into a shared\n * dependency would build something worse than the drift it replaces: every\n * consumer inherits a policy it cannot see, and a change to that policy ships\n * silently as a patch release.\n *\n * ORIGIN: buddy and cardmem independently wrote this same rule, and BOTH had\n * holes in it, found the same day in each codebase separately. The value here is\n * not the code — it is the cases in test/, every one of them an attack that\n * WORKED against a live implementation. Each looks correct until someone builds\n * the counter-example.\n */\n\n/**\n * What the Authentication-Results header says. FOUR outcomes, not three, and\n * not two.\n *\n * pass a dmarc verdict exists and everything present passed\n * fail at least one method's verdicts unambiguously did not pass\n * conflicted one method carries BOTH a passing and a failing verdict\n * no-verdict nothing readable to judge by\n *\n * `conflicted` exists because RFC 8601 Appendix B.6 — \"Service Provided,\n * Multi-tiered Authentication Done\", the standard's own example of NORMAL\n * OPERATION — is SYNTACTICALLY IDENTICAL to a duplicate-injection attack:\n *\n * dkim=pass reason=\"good signature\" header.i=@mail-router.example.net;\n * dkim=fail reason=\"bad signature\" header.i=@newyork.example.com\n *\n * Two signatures, one good, one bad, in one header. Nothing in the header\n * distinguishes that from an attacker appending `dkim=pass`. So this package\n * does not try: it REPORTS the disagreement and the caller decides. Fail-closed\n * is a perfectly good policy — it is just not ours to choose for everyone.\n *\n * `no-verdict` must NEVER collapse into `pass`, and the inversion is why it\n * matters more than it looks: a genuine SELF-SENT mail has no verdict at all\n * (the header is written by the RECEIVING server, and a mail from your own\n * authenticated session never crosses that boundary), while an external forgery\n * HAS one and it FAILS. A gate demanding `pass` rejects precisely the mail the\n * feature exists for.\n */\nexport type AuthVerdict = \"pass\" | \"fail\" | \"conflicted\" | \"no-verdict\";\n\n/** The three methods that say something about the sending domain. */\nexport type AuthMethod = \"spf\" | \"dkim\" | \"dmarc\";\n\nconst METHODS: readonly AuthMethod[] = [\"spf\", \"dkim\", \"dmarc\"];\n\nexport interface AuthResults {\n verdict: AuthVerdict;\n /** Per-method result as read. `pass` only when every verdict for it passed. */\n spf?: string;\n dkim?: string;\n dmarc?: string;\n /** Methods carrying BOTH a passing and a failing verdict. Empty unless `conflicted`. */\n conflicted: readonly AuthMethod[];\n /** Always set, including on `pass` — a caller logging a decision needs the why. */\n reason: string;\n}\n\n/**\n * A view of `s` in which every quoted-string and every (nested) comment — and\n * their delimiters — is replaced by a SPACE, position for position.\n *\n * THIS IS THE WHOLE TRICK, and it is what a regex over the raw string cannot do.\n * Both grammars we parse (RFC 5322 address lists, RFC 8601 auth results) have\n * quoting, and in both a comment is defined to be whitespace. A regex has\n * neither notion, so it sees a legal separator wherever there is a space —\n * INCLUDING the spaces inside a quoted string. Measured against the prior\n * implementation, all three of these came from exactly that:\n *\n * 'Authentication-Results: …; dkim=pass reason=\"relayed dmarc=pass ok\"'\n * → no-verdict became PASS. Text inside a reason string became a verdict.\n *\n * 'Authentication-Results: …; dkim=pass (relayed dmarc=pass ok)'\n * → same, via a comment.\n *\n * 'To: \"x <buddy@broberg.ai>, y\" <attacker@evil.dk>'\n * → split-on-comma cut the quoted display name in half, and the half\n * carried an <addr> that was only ever decoration. A display name\n * contributed an address, which is the single property this package\n * exists to guarantee it cannot.\n *\n * Replacing with a space rather than DELETING is deliberate: deletion joins the\n * neighbours, so `dmar(x)c=pass` would become the verdict `dmarc=pass` out of\n * text that is not one. A comment is whitespace; blanking says exactly that.\n *\n * Unterminated quotes and comments blank the remainder of the string, and are\n * reported as `malformed` so a caller of this helper can refuse to read a\n * PARTIAL result out of a header it could not finish parsing. That distinction\n * is not pedantry — the qualifier values in an auth header (`smtp.mailfrom=`,\n * `header.i=`) come from the message, so an unterminated delimiter there would\n * blank every verdict AFTER it while leaving the earlier ones standing. Erasing\n * a trailing `dmarc=fail` and keeping a leading `spf=pass` is a strictly useful\n * outcome for an attacker, so a header that does not parse yields nothing.\n */\nfunction maskQuotedAndComments(s: string): { masked: string; malformed: boolean } {\n const out = s.split(\"\");\n let quoted = false;\n let comment = 0;\n\n for (let i = 0; i < s.length; i++) {\n const c = s[i]!;\n\n if ((quoted || comment > 0) && c === \"\\\\\" && i + 1 < s.length) {\n out[i] = \" \";\n out[i + 1] = \" \";\n i++;\n continue;\n }\n if (quoted) {\n out[i] = \" \";\n if (c === '\"') quoted = false;\n continue;\n }\n if (comment > 0) {\n if (c === \"(\") comment++;\n else if (c === \")\") comment--;\n out[i] = \" \";\n continue;\n }\n if (c === '\"') {\n quoted = true;\n out[i] = \" \";\n continue;\n }\n if (c === \"(\") {\n comment++;\n out[i] = \" \";\n continue;\n }\n }\n\n return { masked: out.join(\"\"), malformed: quoted || comment > 0 };\n}\n\n/** Lowercase + trim, or null when nothing is left. */\nfunction normalise(s: string): string | null {\n const t = s.trim().toLowerCase();\n return t.length > 0 ? t : null;\n}\n\n/**\n * The address of ONE address-list entry, given the raw field and its mask.\n *\n * Angle brackets are located in the MASKED view, so a `<` inside a quoted\n * display name is simply not there to be found — but the address itself is\n * sliced from the RAW string, so a quoted local part (`<\"odd name\"@example.com>`)\n * survives intact.\n */\nfunction addressOfPart(raw: string, masked: string, from: number, to: number): string | null {\n let open = -1;\n let close = -1;\n let depth = 0;\n\n for (let i = from; i < to; i++) {\n const c = masked[i]!;\n if (c === \"<\") {\n if (depth === 0) open = i;\n depth++;\n } else if (c === \">\" && depth > 0) {\n depth--;\n if (depth === 0) {\n close = i;\n break;\n }\n }\n }\n\n if (open >= 0 && close > open) return normalise(raw.slice(open + 1, close));\n\n // An UNTERMINATED '<' is malformed. Hand back the part as it stands rather\n // than guessing which half was meant to be the address — something that\n // matches nothing beats something that might match the owner.\n if (open >= 0) return normalise(raw.slice(from, to));\n\n // No angle brackets: the address is the bare text, which is the MASKED text —\n // so a comment such as `cb@webhouse.dk (Christian)` contributes nothing.\n return normalise(masked.slice(from, to));\n}\n\n/**\n * Every address in a From/To/Cc field. A DISPLAY NAME NEVER CONTRIBUTES ONE.\n *\n * 'Christian Broberg <cb@webhouse.dk>' → ['cb@webhouse.dk']\n * 'A <a@x.dk>, b@y.dk' → ['a@x.dk', 'b@y.dk']\n * '\"cb@webhouse.dk\" <attacker@evil.dk>' → ['attacker@evil.dk']\n * '\"x <buddy@broberg.ai>, y\" <a@evil.dk>' → ['a@evil.dk']\n * '\"Broberg, Christian\" <cb@webhouse.dk>' → ['cb@webhouse.dk']\n *\n * The first counter-example killed `from.includes(owner)`, which accepted the\n * owner's address written as DISPLAY TEXT — four call-paths reproduced it. The\n * second and third killed `field.split(',')`, which cuts a quoted display name\n * in half before quoting is understood. Both were live; neither was theoretical.\n *\n * Addresses are lowercased. Local parts are formally case-sensitive (RFC 5321),\n * but no provider we send through treats them that way, and NOT normalising\n * would mean `CB@webhouse.dk` fails an owner check that `cb@webhouse.dk` passes\n * — a comparison anyone can sidestep with the shift key.\n */\nexport function extractAddresses(field: string | null | undefined): string[] {\n if (!field) return [];\n // A malformed field is not rejected outright here: blanking already makes the\n // unparseable remainder match nothing, and an address list has no equivalent\n // of the partial-verdict problem — an address either equals a known one or it\n // does not.\n const { masked } = maskQuotedAndComments(field);\n\n const out: string[] = [];\n let start = 0;\n let angle = 0;\n\n const flush = (end: number): void => {\n const a = addressOfPart(field, masked, start, end);\n if (a) out.push(a);\n start = end + 1;\n };\n\n for (let i = 0; i < masked.length; i++) {\n const c = masked[i]!;\n if (c === \"<\") angle++;\n else if (c === \">\" && angle > 0) angle--;\n else if (c === \",\" && angle === 0) flush(i);\n }\n flush(masked.length);\n\n return out;\n}\n\n/**\n * Split a plus-tag off an address.\n *\n * 'buddy+whop@broberg.ai' → { address: 'buddy@broberg.ai', tag: 'whop' }\n * 'buddy@broberg.ai' → { address: 'buddy@broberg.ai', tag: null }\n *\n * TWO RULES CARRY THE SECURITY, and they are the reason this is separate from\n * matching rather than folded into it:\n *\n * 1. THE TAG INFORMS, THE ADDRESS PROVES. An unknown tag must still match. A tag\n * able to REFUSE a match would be authorisation disguised as routing, and a\n * typo (`buddy+whopp@`) would make mail vanish silently. `+admin`, `+deploy`\n * and `+urgent` must never mean anything privileged — anyone can type them.\n *\n * 2. SPLIT AFTER extractAddresses(), NEVER on a raw field. Do it the other way\n * round and you are back in the impersonation hole wearing a new hat: a\n * display name must not contribute an address, and that property lives in the\n * extraction.\n *\n * An address with more than one '@' is malformed and is returned UNTOUCHED:\n * `buddy+x@evil.com@broberg.ai` must never be reshaped into something valid.\n *\n * `tag` is `null`, not `undefined`, so that a fixture written as\n * `{ address: 'x', tag: undefined }` cannot pass a deep-equality check against\n * `{ address: 'x' }`. Two different facts, two distinguishable values.\n */\nexport function splitPlusTag(address: string): { address: string; tag: string | null } {\n const at = address.indexOf(\"@\");\n if (at < 0 || address.indexOf(\"@\", at + 1) !== -1) return { address, tag: null };\n\n const plus = address.indexOf(\"+\");\n if (plus < 0 || plus > at) return { address, tag: null };\n\n // From the FIRST '+': 'buddy+a+b@' has the tag 'a+b', not 'b'.\n const tag = address.slice(plus + 1, at);\n return { address: address.slice(0, plus) + address.slice(at), tag: tag || null };\n}\n\n/**\n * Does any address in `field` EQUAL any address in `known`?\n *\n * Equality on extracted addresses. Never a substring, never a prefix, never a\n * suffix — `cb@webhouse.dk.evil.com` and `\"cb@webhouse.dk\" <x@evil.dk>` and\n * `buddy@broberg.ai.evil.com` are all rejected, and each of the three was\n * accepted by a live implementation this week.\n *\n * Both sides go through the SAME extraction and the SAME plus-tag\n * normalisation, so `known` may be given as bare addresses or as full fields.\n * An empty or unparseable `field` matches nothing.\n */\nexport function addressesMatch(\n field: string | null | undefined,\n known: string | readonly string[],\n): boolean {\n const present = new Set(extractAddresses(field).map((a) => splitPlusTag(a).address));\n if (present.size === 0) return false;\n\n const list = typeof known === \"string\" ? [known] : known;\n return list.some((k) =>\n extractAddresses(k).some((a) => present.has(splitPlusTag(a).address)),\n );\n}\n\n/**\n * Read an Authentication-Results header (RFC 8601). Reports; does not judge.\n *\n * ANCHORING IS AN ALLOWLIST, NOT A DENYLIST. `\\b` was the original, and a dot is\n * a word boundary, so `\\bdmarc=` matched INSIDE `header.dmarc=pass` — a\n * qualified key read as a verdict:\n *\n * 'header.dmarc=pass header.dkim=pass header.spf=pass; dmarc=fail; dkim=fail; spf=fail'\n * → PASS, while every real verdict in it is `fail`.\n *\n * The first fix was a denylist, `(?<![\\w.-])`. Swept over the whole printable\n * character set it left 27 characters open, including a colon — `arc:dmarc=pass`\n * read as a pass. A denylist has to predict every character a future vendor\n * invents. So: a verdict must stand at the start of the header or after a REAL\n * separator (whitespace, `;` or `,` — RFC 8601's own), and anything else in\n * front of it makes it a qualified key rather than a verdict.\n *\n * Quoted strings and comments are blanked before matching (see\n * maskQuotedAndComments) — they are two more places a verdict cannot stand, and\n * both manufactured verdicts in the prior implementation.\n *\n * ALL occurrences are collected, not the first: a header can carry more than one\n * verdict per method, and \"first wins\" lets an injected `dmarc=pass` drown out\n * the real `dmarc=fail`.\n *\n * PRECEDENCE IS SECURITY-BEARING. An unambiguous fail beats a conflict:\n *\n * 'dkim=pass; dkim=fail; dmarc=fail' → fail, NOT conflicted\n *\n * A method where NO verdict passed is stronger evidence than a disagreement\n * somewhere else. Get the order wrong and a genuine, unambiguous rejection is\n * downgraded to \"cannot determine\" — which a lenient caller then lets through.\n */\nexport function readAuthResults(header: string | null | undefined): AuthResults {\n if (!header || !header.trim()) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason: \"no Authentication-Results header — the sender is unproven, which is not the same as disproven\",\n };\n }\n\n const { masked, malformed } = maskQuotedAndComments(header);\n if (malformed) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason:\n \"unterminated quoted string or comment — the header could not be parsed to the end, \" +\n \"and a verdict read out of the readable half would be a verdict an attacker chose the boundary of\",\n };\n }\n\n const found: Partial<Record<AuthMethod, string>> = {};\n const conflicted: AuthMethod[] = [];\n\n for (const method of METHODS) {\n // The optional `/1` is RFC 8601's method-version, and the `\\s*` around it\n // and around `=` is CFWS — comments are already spaces by now. Appendix B.7,\n // the RFC's own \"perfectly legal\" example, is written\n // `dkim (Because I like it) / 1 (One yay) = (wait for it) fail`, and a\n // parser too narrow to read it would report no-verdict on a header that\n // plainly says fail. Rejecting genuine mail is the equally expensive\n // opposite error.\n const re = new RegExp(`(?<=^|[\\\\s;,])${method}\\\\s*(?:/\\\\s*\\\\d+\\\\s*)?=\\\\s*([a-z]+)`, \"gi\");\n const verdicts = [...masked.matchAll(re)].map((m) => m[1]!.toLowerCase());\n if (verdicts.length === 0) continue;\n\n const passed = verdicts.filter((v) => v === \"pass\").length;\n // DISAGREEMENT and UNIFORMLY-NOT-PASSED are two different facts:\n // dmarc=fail a real, unambiguous rejection\n // dkim=pass … dkim=fail a disagreement (RFC B.6 legal, OR injected)\n // Collapsing both into `fail` hides the difference from the caller and puts\n // the policy here, where no consumer can change it. A REPEATED AGREEING\n // verdict is not a conflict — a relay may legitimately repeat itself, and\n // reading that as tampering blocks real mail.\n if (passed > 0 && passed < verdicts.length) conflicted.push(method);\n found[method] = passed === verdicts.length ? \"pass\" : verdicts.find((v) => v !== \"pass\")!;\n }\n\n if (Object.keys(found).length === 0) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason: \"Authentication-Results was present but carried no readable spf/dkim/dmarc verdict\",\n };\n }\n\n const failed = METHODS.filter(\n (m) => found[m] !== undefined && found[m] !== \"pass\" && !conflicted.includes(m),\n );\n if (failed.length > 0) {\n return {\n verdict: \"fail\",\n ...found,\n conflicted,\n reason: `rejected: ${failed.map((m) => `${m}=${found[m]}`).join(\", \")}`,\n };\n }\n\n if (conflicted.length > 0) {\n return {\n verdict: \"conflicted\",\n ...found,\n conflicted,\n reason:\n `contradictory verdicts for ${conflicted.join(\", \")} — the header carries both a pass and a non-pass. ` +\n \"This is legal multi-tiered authentication (RFC 8601 B.6) OR an injected verdict, and the header cannot tell you which. Your call.\",\n };\n }\n\n // DMARC is the one that binds the From: field to what passed. Without it we\n // know SOMETHING authenticated, but not that the claimed sender is who did.\n if (found.dmarc === undefined) {\n return {\n verdict: \"no-verdict\",\n ...found,\n conflicted,\n reason: \"no dmarc verdict — the From: field is not bound to whatever did pass\",\n };\n }\n\n return { verdict: \"pass\", ...found, conflicted, reason: \"spf/dkim/dmarc pass\" };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @broberg/mail-identity — is this inbound mail provably from who the From:\n * field claims?\n *\n * Four pure functions, no I/O, no dependencies. They answer two questions and\n * refuse a third:\n *\n * WHAT IS AN ADDRESS? extractAddresses · splitPlusTag · addressesMatch\n * WHAT DOES THE HEADER SAY? readAuthResults\n * WHAT DOES A MATCH MEAN? NOT OURS. That is authorisation, and it stays\n * at the call-site where it can be read.\n *\n * The third line is the design. Moving an authorisation decision into a shared\n * dependency would build something worse than the drift it replaces: every\n * consumer inherits a policy it cannot see, and a change to that policy ships\n * silently as a patch release.\n *\n * ORIGIN: buddy and cardmem independently wrote this same rule, and BOTH had\n * holes in it, found the same day in each codebase separately. The value here is\n * not the code — it is the cases in test/, every one of them an attack that\n * WORKED against a live implementation. Each looks correct until someone builds\n * the counter-example.\n */\n\n/**\n * What the Authentication-Results header says. FOUR outcomes, not three, and\n * not two.\n *\n * pass a dmarc verdict exists and everything present passed\n * fail at least one method's verdicts unambiguously did not pass\n * conflicted one method carries BOTH a passing and a failing verdict\n * no-verdict nothing readable to judge by\n *\n * `conflicted` exists because RFC 8601 Appendix B.6 — \"Service Provided,\n * Multi-tiered Authentication Done\", the standard's own example of NORMAL\n * OPERATION — is SYNTACTICALLY IDENTICAL to a duplicate-injection attack:\n *\n * dkim=pass reason=\"good signature\" header.i=@mail-router.example.net;\n * dkim=fail reason=\"bad signature\" header.i=@newyork.example.com\n *\n * Two signatures, one good, one bad, in one header. Nothing in the header\n * distinguishes that from an attacker appending `dkim=pass`. So this package\n * does not try: it REPORTS the disagreement and the caller decides. Fail-closed\n * is a perfectly good policy — it is just not ours to choose for everyone.\n *\n * `no-verdict` must NEVER collapse into `pass`, and the inversion is why it\n * matters more than it looks: a genuine SELF-SENT mail has no verdict at all\n * (the header is written by the RECEIVING server, and a mail from your own\n * authenticated session never crosses that boundary), while an external forgery\n * HAS one and it FAILS. A gate demanding `pass` rejects precisely the mail the\n * feature exists for.\n */\nexport type AuthVerdict = \"pass\" | \"fail\" | \"conflicted\" | \"no-verdict\";\n\n/** The three methods that say something about the sending domain. */\nexport type AuthMethod = \"spf\" | \"dkim\" | \"dmarc\";\n\nconst METHODS: readonly AuthMethod[] = [\"spf\", \"dkim\", \"dmarc\"];\n\nexport interface AuthResults {\n verdict: AuthVerdict;\n /** Per-method result as read. `pass` only when every verdict for it passed. */\n spf?: string;\n dkim?: string;\n dmarc?: string;\n /** Methods carrying BOTH a passing and a failing verdict. Empty unless `conflicted`. */\n conflicted: readonly AuthMethod[];\n /** Always set, including on `pass` — a caller logging a decision needs the why. */\n reason: string;\n}\n\n/**\n * A view of `s` in which every quoted-string and every (nested) comment — and\n * their delimiters — is replaced by a SPACE, position for position.\n *\n * THIS IS THE WHOLE TRICK, and it is what a regex over the raw string cannot do.\n * Both grammars we parse (RFC 5322 address lists, RFC 8601 auth results) have\n * quoting, and in both a comment is defined to be whitespace. A regex has\n * neither notion, so it sees a legal separator wherever there is a space —\n * INCLUDING the spaces inside a quoted string. Measured against the prior\n * implementation, all three of these came from exactly that:\n *\n * 'Authentication-Results: …; dkim=pass reason=\"relayed dmarc=pass ok\"'\n * → no-verdict became PASS. Text inside a reason string became a verdict.\n *\n * 'Authentication-Results: …; dkim=pass (relayed dmarc=pass ok)'\n * → same, via a comment.\n *\n * 'To: \"x <buddy@broberg.ai>, y\" <attacker@evil.dk>'\n * → split-on-comma cut the quoted display name in half, and the half\n * carried an <addr> that was only ever decoration. A display name\n * contributed an address, which is the single property this package\n * exists to guarantee it cannot.\n *\n * Replacing with a space rather than DELETING is deliberate: deletion joins the\n * neighbours, so `dmar(x)c=pass` would become the verdict `dmarc=pass` out of\n * text that is not one. A comment is whitespace; blanking says exactly that.\n *\n * Unterminated quotes and comments blank the remainder of the string, and are\n * reported as `malformed` so a caller of this helper can refuse to read a\n * PARTIAL result out of a header it could not finish parsing. That distinction\n * is not pedantry — the qualifier values in an auth header (`smtp.mailfrom=`,\n * `header.i=`) come from the message, so an unterminated delimiter there would\n * blank every verdict AFTER it while leaving the earlier ones standing. Erasing\n * a trailing `dmarc=fail` and keeping a leading `spf=pass` is a strictly useful\n * outcome for an attacker, so a header that does not parse yields nothing.\n */\nfunction maskQuotedAndComments(s: string): { masked: string; malformed: boolean } {\n const out = s.split(\"\");\n let quoted = false;\n let comment = 0;\n\n for (let i = 0; i < s.length; i++) {\n const c = s[i]!;\n\n if ((quoted || comment > 0) && c === \"\\\\\" && i + 1 < s.length) {\n out[i] = \" \";\n out[i + 1] = \" \";\n i++;\n continue;\n }\n if (quoted) {\n out[i] = \" \";\n if (c === '\"') quoted = false;\n continue;\n }\n if (comment > 0) {\n if (c === \"(\") comment++;\n else if (c === \")\") comment--;\n out[i] = \" \";\n continue;\n }\n if (c === '\"') {\n quoted = true;\n out[i] = \" \";\n continue;\n }\n if (c === \"(\") {\n comment++;\n out[i] = \" \";\n continue;\n }\n }\n\n return { masked: out.join(\"\"), malformed: quoted || comment > 0 };\n}\n\n/** Lowercase + trim, or null when nothing is left. */\nfunction normalise(s: string): string | null {\n const t = s.trim().toLowerCase();\n return t.length > 0 ? t : null;\n}\n\n/**\n * The address of ONE address-list entry, given the raw field and its mask.\n *\n * Angle brackets are located in the MASKED view, so a `<` inside a quoted\n * display name is simply not there to be found — but the address itself is\n * sliced from the RAW string, so a quoted local part (`<\"odd name\"@example.com>`)\n * survives intact.\n */\nfunction addressOfPart(raw: string, masked: string, from: number, to: number): string | null {\n let open = -1;\n let close = -1;\n let depth = 0;\n\n for (let i = from; i < to; i++) {\n const c = masked[i]!;\n if (c === \"<\") {\n if (depth === 0) open = i;\n depth++;\n } else if (c === \">\" && depth > 0) {\n depth--;\n if (depth === 0) {\n close = i;\n break;\n }\n }\n }\n\n if (open >= 0 && close > open) return normalise(raw.slice(open + 1, close));\n\n // An UNTERMINATED '<' is malformed. Hand back the part as it stands rather\n // than guessing which half was meant to be the address — something that\n // matches nothing beats something that might match the owner.\n if (open >= 0) return normalise(raw.slice(from, to));\n\n // No angle brackets: the address is the bare text, which is the MASKED text —\n // so a comment such as `cb@webhouse.dk (Christian)` contributes nothing.\n return normalise(masked.slice(from, to));\n}\n\n/**\n * Every address in a From/To/Cc field. A DISPLAY NAME NEVER CONTRIBUTES ONE.\n *\n * 'Christian Broberg <cb@webhouse.dk>' → ['cb@webhouse.dk']\n * 'A <a@x.dk>, b@y.dk' → ['a@x.dk', 'b@y.dk']\n * '\"cb@webhouse.dk\" <attacker@evil.dk>' → ['attacker@evil.dk']\n * '\"x <buddy@broberg.ai>, y\" <a@evil.dk>' → ['a@evil.dk']\n * '\"Broberg, Christian\" <cb@webhouse.dk>' → ['cb@webhouse.dk']\n *\n * The first counter-example killed `from.includes(owner)`, which accepted the\n * owner's address written as DISPLAY TEXT — four call-paths reproduced it. The\n * second and third killed `field.split(',')`, which cuts a quoted display name\n * in half before quoting is understood. Both were live; neither was theoretical.\n *\n * Addresses are lowercased. Local parts are formally case-sensitive (RFC 5321),\n * but no provider we send through treats them that way, and NOT normalising\n * would mean `CB@webhouse.dk` fails an owner check that `cb@webhouse.dk` passes\n * — a comparison anyone can sidestep with the shift key.\n */\nexport function extractAddresses(field: string | null | undefined): string[] {\n if (!field) return [];\n // A malformed field is not rejected outright here: blanking already makes the\n // unparseable remainder match nothing, and an address list has no equivalent\n // of the partial-verdict problem — an address either equals a known one or it\n // does not.\n const { masked } = maskQuotedAndComments(field);\n\n const out: string[] = [];\n let start = 0;\n let angle = 0;\n\n const flush = (end: number): void => {\n const a = addressOfPart(field, masked, start, end);\n // 0.1.1 — a part with no '@' is not an address (buddy, 31/10: `No Address\n // Here` came back as ['no address here']). Text is not promoted to an\n // address because the field happened to contain no brackets.\n if (a && a.includes(\"@\")) out.push(a);\n start = end + 1;\n };\n\n for (let i = 0; i < masked.length; i++) {\n const c = masked[i]!;\n if (c === \"<\") angle++;\n else if (c === \">\" && angle > 0) angle--;\n else if (c === \",\" && angle === 0) flush(i);\n }\n flush(masked.length);\n\n return out;\n}\n\n/**\n * Split a plus-tag off an address.\n *\n * 'buddy+whop@broberg.ai' → { address: 'buddy@broberg.ai', tag: 'whop' }\n * 'buddy@broberg.ai' → { address: 'buddy@broberg.ai', tag: null }\n *\n * TWO RULES CARRY THE SECURITY, and they are the reason this is separate from\n * matching rather than folded into it:\n *\n * 1. THE TAG INFORMS, THE ADDRESS PROVES. An unknown tag must still match. A tag\n * able to REFUSE a match would be authorisation disguised as routing, and a\n * typo (`buddy+whopp@`) would make mail vanish silently. `+admin`, `+deploy`\n * and `+urgent` must never mean anything privileged — anyone can type them.\n *\n * 2. SPLIT AFTER extractAddresses(), NEVER on a raw field. Do it the other way\n * round and you are back in the impersonation hole wearing a new hat: a\n * display name must not contribute an address, and that property lives in the\n * extraction.\n *\n * An address with more than one '@' is malformed and is returned UNTOUCHED:\n * `buddy+x@evil.com@broberg.ai` must never be reshaped into something valid.\n *\n * `tag` is `null`, not `undefined`, so that a fixture written as\n * `{ address: 'x', tag: undefined }` cannot pass a deep-equality check against\n * `{ address: 'x' }`. Two different facts, two distinguishable values.\n */\nexport function splitPlusTag(address: string): { address: string; tag: string | null } {\n const at = address.indexOf(\"@\");\n if (at < 0 || address.indexOf(\"@\", at + 1) !== -1) return { address, tag: null };\n\n const plus = address.indexOf(\"+\");\n if (plus < 0 || plus > at) return { address, tag: null };\n\n // From the FIRST '+': 'buddy+a+b@' has the tag 'a+b', not 'b'.\n const tag = address.slice(plus + 1, at);\n return { address: address.slice(0, plus) + address.slice(at), tag: tag || null };\n}\n\n/**\n * Does any address in `field` EQUAL any address in `known`?\n *\n * Equality on extracted addresses. Never a substring, never a prefix, never a\n * suffix — `cb@webhouse.dk.evil.com` and `\"cb@webhouse.dk\" <x@evil.dk>` and\n * `buddy@broberg.ai.evil.com` are all rejected, and each of the three was\n * accepted by a live implementation this week.\n *\n * Both sides go through the SAME extraction and the SAME plus-tag\n * normalisation, so `known` may be given as bare addresses or as full fields.\n * An empty or unparseable `field` matches nothing.\n */\nexport function addressesMatch(\n field: string | null | undefined,\n known: string | readonly string[],\n): boolean {\n const present = new Set(extractAddresses(field).map((a) => splitPlusTag(a).address));\n if (present.size === 0) return false;\n\n const list = typeof known === \"string\" ? [known] : known;\n return list.some((k) =>\n extractAddresses(k).some((a) => present.has(splitPlusTag(a).address)),\n );\n}\n\n/**\n * Read an Authentication-Results header (RFC 8601). Reports; does not judge.\n *\n * ANCHORING IS AN ALLOWLIST, NOT A DENYLIST. `\\b` was the original, and a dot is\n * a word boundary, so `\\bdmarc=` matched INSIDE `header.dmarc=pass` — a\n * qualified key read as a verdict:\n *\n * 'header.dmarc=pass header.dkim=pass header.spf=pass; dmarc=fail; dkim=fail; spf=fail'\n * → PASS, while every real verdict in it is `fail`.\n *\n * The first fix was a denylist, `(?<![\\w.-])`. Swept over the whole printable\n * character set it left 27 characters open, including a colon — `arc:dmarc=pass`\n * read as a pass. A denylist has to predict every character a future vendor\n * invents. So: a verdict must stand at the start of the header or after a REAL\n * separator (whitespace, `;` or `,` — RFC 8601's own), and anything else in\n * front of it makes it a qualified key rather than a verdict.\n *\n * Quoted strings and comments are blanked before matching (see\n * maskQuotedAndComments) — they are two more places a verdict cannot stand, and\n * both manufactured verdicts in the prior implementation.\n *\n * ALL occurrences are collected, not the first: a header can carry more than one\n * verdict per method, and \"first wins\" lets an injected `dmarc=pass` drown out\n * the real `dmarc=fail`.\n *\n * PRECEDENCE IS SECURITY-BEARING. An unambiguous fail beats a conflict:\n *\n * 'dkim=pass; dkim=fail; dmarc=fail' → fail, NOT conflicted\n *\n * A method where NO verdict passed is stronger evidence than a disagreement\n * somewhere else. Get the order wrong and a genuine, unambiguous rejection is\n * downgraded to \"cannot determine\" — which a lenient caller then lets through.\n */\nexport function readAuthResults(header: string | null | undefined): AuthResults {\n if (!header || !header.trim()) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason: \"no Authentication-Results header — the sender is unproven, which is not the same as disproven\",\n };\n }\n\n const { masked, malformed } = maskQuotedAndComments(header);\n if (malformed) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason:\n \"unterminated quoted string or comment — the header could not be parsed to the end, \" +\n \"and a verdict read out of the readable half would be a verdict an attacker chose the boundary of\",\n };\n }\n\n const found: Partial<Record<AuthMethod, string>> = {};\n const conflicted: AuthMethod[] = [];\n\n for (const method of METHODS) {\n // The optional `/1` is RFC 8601's method-version, and the `\\s*` around it\n // and around `=` is CFWS — comments are already spaces by now. Appendix B.7,\n // the RFC's own \"perfectly legal\" example, is written\n // `dkim (Because I like it) / 1 (One yay) = (wait for it) fail`, and a\n // parser too narrow to read it would report no-verdict on a header that\n // plainly says fail. Rejecting genuine mail is the equally expensive\n // opposite error.\n const re = new RegExp(`(?<=^|[\\\\s;,])${method}\\\\s*(?:/\\\\s*\\\\d+\\\\s*)?=\\\\s*([a-z]+)`, \"gi\");\n const verdicts = [...masked.matchAll(re)].map((m) => m[1]!.toLowerCase());\n if (verdicts.length === 0) continue;\n\n const passed = verdicts.filter((v) => v === \"pass\").length;\n // DISAGREEMENT and UNIFORMLY-NOT-PASSED are two different facts:\n // dmarc=fail a real, unambiguous rejection\n // dkim=pass … dkim=fail a disagreement (RFC B.6 legal, OR injected)\n // Collapsing both into `fail` hides the difference from the caller and puts\n // the policy here, where no consumer can change it. A REPEATED AGREEING\n // verdict is not a conflict — a relay may legitimately repeat itself, and\n // reading that as tampering blocks real mail.\n if (passed > 0 && passed < verdicts.length) conflicted.push(method);\n found[method] = passed === verdicts.length ? \"pass\" : verdicts.find((v) => v !== \"pass\")!;\n }\n\n if (Object.keys(found).length === 0) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason: \"Authentication-Results was present but carried no readable spf/dkim/dmarc verdict\",\n };\n }\n\n const failed = METHODS.filter(\n (m) => found[m] !== undefined && found[m] !== \"pass\" && !conflicted.includes(m),\n );\n if (failed.length > 0) {\n return {\n verdict: \"fail\",\n ...found,\n conflicted,\n reason: `rejected: ${failed.map((m) => `${m}=${found[m]}`).join(\", \")}`,\n };\n }\n\n if (conflicted.length > 0) {\n return {\n verdict: \"conflicted\",\n ...found,\n conflicted,\n reason:\n `contradictory verdicts for ${conflicted.join(\", \")} — the header carries both a pass and a non-pass. ` +\n \"This is legal multi-tiered authentication (RFC 8601 B.6) OR an injected verdict, and the header cannot tell you which. Your call.\",\n };\n }\n\n // DMARC is the one that binds the From: field to what passed. Without it we\n // know SOMETHING authenticated, but not that the claimed sender is who did.\n if (found.dmarc === undefined) {\n return {\n verdict: \"no-verdict\",\n ...found,\n conflicted,\n reason: \"no dmarc verdict — the From: field is not bound to whatever did pass\",\n };\n }\n\n return { verdict: \"pass\", ...found, conflicted, reason: \"spf/dkim/dmarc pass\" };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/mail-identity",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Is this inbound mail provably from who the From: field claims? Reads addresses and Authentication-Results the way the grammar does, not the way a regex does. Four pure functions, zero dependencies, no I/O. It owns WHAT AN ADDRESS IS and WHAT THE AUTH HEADER SAYS — never what a match MEANS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|