@broberg/mail-identity 0.0.0-stage → 0.1.0
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/README.md +231 -2
- package/dist/index.cjs +170 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +164 -0
- package/dist/index.d.ts +164 -0
- package/dist/index.js +165 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -4
package/README.md
CHANGED
|
@@ -1,3 +1,232 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @broberg/mail-identity
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Is this inbound mail **provably** from who the `From:` field claims?
|
|
4
|
+
|
|
5
|
+
Four pure functions, zero dependencies, no I/O. They read addresses and
|
|
6
|
+
`Authentication-Results` **the way the grammar does, not the way a regex does**.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm i @broberg/mail-identity
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { extractAddresses, addressesMatch, splitPlusTag, readAuthResults } from "@broberg/mail-identity";
|
|
14
|
+
|
|
15
|
+
extractAddresses('"cb@webhouse.dk" <attacker@evil.dk>'); // ['attacker@evil.dk']
|
|
16
|
+
addressesMatch(mail.from, ["cb@webhouse.dk"]); // false
|
|
17
|
+
splitPlusTag("buddy+whop@broberg.ai"); // { address: 'buddy@broberg.ai', tag: 'whop' }
|
|
18
|
+
readAuthResults(mail.headers["authentication-results"]); // { verdict: 'pass' | 'fail' | 'conflicted' | 'no-verdict', … }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What it owns, and what it refuses to own
|
|
22
|
+
|
|
23
|
+
| | |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| **What an address is** | `extractAddresses` · `splitPlusTag` · `addressesMatch` |
|
|
26
|
+
| **What the auth header says** | `readAuthResults` |
|
|
27
|
+
| **What a match MEANS** | **not ours** |
|
|
28
|
+
|
|
29
|
+
The third row is the design. "May this mail wake an agent" is authorisation, and
|
|
30
|
+
it stays at your call-site where you can read it. Move it in here and every
|
|
31
|
+
consumer inherits a policy it cannot see, and a change to that policy ships
|
|
32
|
+
silently as a patch release.
|
|
33
|
+
|
|
34
|
+
## Why this package exists
|
|
35
|
+
|
|
36
|
+
Two codebases independently wrote this same rule, and **both had holes in it,
|
|
37
|
+
found the same day, in each codebase separately.** The value here is not the
|
|
38
|
+
code — it is the cases in `test/`, every one of them an attack that **worked**
|
|
39
|
+
against a live implementation. Each looks correct until someone builds the
|
|
40
|
+
counter-example.
|
|
41
|
+
|
|
42
|
+
## API
|
|
43
|
+
|
|
44
|
+
### `extractAddresses(field): string[]`
|
|
45
|
+
|
|
46
|
+
Every address in a `From`/`To`/`Cc` field, lowercased. **A display name never
|
|
47
|
+
contributes one.**
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
extractAddresses('Christian <cb@webhouse.dk>'); // ['cb@webhouse.dk']
|
|
51
|
+
extractAddresses('cb@webhouse.dk <attacker@evil.dk>'); // ['attacker@evil.dk']
|
|
52
|
+
extractAddresses('"x <buddy@broberg.ai>, y" <a@evil.dk>'); // ['a@evil.dk']
|
|
53
|
+
extractAddresses('"Broberg, Christian" <cb@webhouse.dk>'); // ['cb@webhouse.dk']
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The second line killed `from.includes(owner)`, which accepted the owner's
|
|
57
|
+
address written as display **text** — four call-paths reproduced it. The third
|
|
58
|
+
killed `field.split(',')`, which cuts a quoted display name in half *before*
|
|
59
|
+
quoting is understood; end-to-end it produced `isOwnerInstruction: true` for a
|
|
60
|
+
mail from `attacker@evil.dk` addressed to `victim@evil.dk`. The fourth is the
|
|
61
|
+
same bug's benign half — `Broberg, Christian` is just how a directory writes a
|
|
62
|
+
name, and a too-eager split turns it into a junk address.
|
|
63
|
+
|
|
64
|
+
### `addressesMatch(field, known): boolean`
|
|
65
|
+
|
|
66
|
+
Equality on extracted addresses. **Never a substring, prefix or suffix.** Both
|
|
67
|
+
sides go through the same extraction and the same plus-tag normalisation, so
|
|
68
|
+
`known` may be bare addresses or full fields. An empty or unparseable field
|
|
69
|
+
matches nothing.
|
|
70
|
+
|
|
71
|
+
### `splitPlusTag(address): { address, tag }`
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
splitPlusTag("buddy+whop@broberg.ai"); // { address: 'buddy@broberg.ai', tag: 'whop' }
|
|
75
|
+
splitPlusTag("buddy@broberg.ai"); // { address: 'buddy@broberg.ai', tag: null }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**The tag informs; the address proves.** An unknown tag must still match. A tag
|
|
79
|
+
able to *refuse* a match would be authorisation disguised as routing, and a typo
|
|
80
|
+
(`buddy+whopp@`) would make mail vanish silently. `+admin`, `+deploy`, `+urgent`
|
|
81
|
+
must never mean anything privileged — anyone can type them.
|
|
82
|
+
|
|
83
|
+
**Split after `extractAddresses`, never on a raw field.** The other way round
|
|
84
|
+
puts you back in the impersonation hole wearing a new hat.
|
|
85
|
+
|
|
86
|
+
### `readAuthResults(header): AuthResults`
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
type AuthVerdict = "pass" | "fail" | "conflicted" | "no-verdict";
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Four outcomes, not three, and never two.**
|
|
93
|
+
|
|
94
|
+
| outcome | meaning |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| `pass` | a dmarc verdict exists and everything present passed |
|
|
97
|
+
| `fail` | at least one method's verdicts unambiguously did not pass |
|
|
98
|
+
| `conflicted` | one method carries **both** a passing and a failing verdict |
|
|
99
|
+
| `no-verdict` | nothing readable to judge by |
|
|
100
|
+
|
|
101
|
+
Also returned: `spf` / `dkim` / `dmarc` as read, `conflicted` (which methods
|
|
102
|
+
disagreed), and `reason` — always set, including on `pass`, because a caller
|
|
103
|
+
logging a decision needs the why.
|
|
104
|
+
|
|
105
|
+
## Three things that will bite you
|
|
106
|
+
|
|
107
|
+
### 1. A missing verdict is not a failure — and the inversion is the whole point
|
|
108
|
+
|
|
109
|
+
A genuine **self-sent** mail carries **no verdict at all**: the header is written
|
|
110
|
+
by the *receiving* server, and a mail from your own authenticated session never
|
|
111
|
+
crosses that boundary. An external **forgery** of the same `From:` **does** cross
|
|
112
|
+
it, gets a verdict, and that verdict **fails**.
|
|
113
|
+
|
|
114
|
+
So a gate demanding `'pass'` rejects precisely the mail the feature exists for,
|
|
115
|
+
while letting nothing extra in. Never collapse `no-verdict` into either
|
|
116
|
+
neighbour.
|
|
117
|
+
|
|
118
|
+
### 2. The permissive outcome must never be your `default` branch
|
|
119
|
+
|
|
120
|
+
A new outcome silently lands in `default`. If `default` is the accepting one,
|
|
121
|
+
**adding an outcome widens your gate.**
|
|
122
|
+
|
|
123
|
+
This is not hypothetical. The first consumer hit it while adding the fourth
|
|
124
|
+
outcome *they had argued for*: `conflicted` fell into the `no-verdict` branch,
|
|
125
|
+
because that branch was the default — and the `no-verdict` branch accepts the
|
|
126
|
+
owner's own address (see #1). An injected verdict would have become the owner's
|
|
127
|
+
instruction. Yesterday's hole, reopened by their own cleanup, and **invisible to
|
|
128
|
+
every test of the reader.**
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import { readAuthResults, addressesMatch, type AuthVerdict } from "@broberg/mail-identity";
|
|
132
|
+
|
|
133
|
+
type Decision = "accept-owner" | "accept-external" | "reject";
|
|
134
|
+
|
|
135
|
+
function decide(verdict: AuthVerdict, fromOwner: boolean): Decision {
|
|
136
|
+
switch (verdict) {
|
|
137
|
+
case "pass":
|
|
138
|
+
return fromOwner ? "accept-owner" : "accept-external";
|
|
139
|
+
case "no-verdict":
|
|
140
|
+
return fromOwner ? "accept-owner" : "reject"; // permissive — never the default
|
|
141
|
+
case "fail":
|
|
142
|
+
return "reject";
|
|
143
|
+
case "conflicted":
|
|
144
|
+
return "reject"; // YOUR policy, on one findable line
|
|
145
|
+
default: {
|
|
146
|
+
const unhandled: never = verdict; // compile-time: a fifth outcome breaks the build
|
|
147
|
+
void unhandled;
|
|
148
|
+
return "reject"; // runtime: the REJECTING answer
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Both layers, and neither is redundant.** `never` protects only the consumers
|
|
155
|
+
that compile — the input is an untrusted header, and a JavaScript consumer has
|
|
156
|
+
no type at all. A package that defends itself with `never` alone defends half its
|
|
157
|
+
consumers.
|
|
158
|
+
|
|
159
|
+
**And put the guard on the DECISION, not on the reader.** Asserting that
|
|
160
|
+
`readAuthResults` returns `'conflicted'` proves nothing about what you do with
|
|
161
|
+
it. The test that matters asserts the resulting *authorisation*. Ours is in
|
|
162
|
+
`test/consumer-trap.test.ts` — the snippet above, executable.
|
|
163
|
+
|
|
164
|
+
### 3. `conflicted` is not "probably an attack"
|
|
165
|
+
|
|
166
|
+
RFC 8601 **Appendix B.6**, *"Service Provided, Multi-tiered Authentication
|
|
167
|
+
Done"* — the standard's own example of **normal operation**:
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
dkim=pass reason="good signature" header.i=@mail-router.example.net;
|
|
171
|
+
dkim=fail reason="bad signature" header.i=@newyork.example.com
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Two signatures, one good, one bad, in one header. **Syntactically identical to a
|
|
175
|
+
duplicate-injection attack**, and not distinguishable from one by reading the
|
|
176
|
+
header. So this package does not try: it reports the disagreement and you decide.
|
|
177
|
+
|
|
178
|
+
Fail-closed is a perfectly good policy. It is just not ours to choose for
|
|
179
|
+
everyone — and if you pick it, know that you are condemning an RFC-documented
|
|
180
|
+
legal case, which is a trade, not a free win.
|
|
181
|
+
|
|
182
|
+
## Notes from the corpus
|
|
183
|
+
|
|
184
|
+
- **The anchor is an allowlist.** A verdict must stand at the start of the header
|
|
185
|
+
or after a real separator (whitespace, `;`, `,` — RFC 8601's own). `\b` was the
|
|
186
|
+
original and a dot is a word boundary, so `\bdmarc=` matched inside
|
|
187
|
+
`header.dmarc=pass`. The denylist that replaced it left 27 printable characters
|
|
188
|
+
open, including a colon. The test sweeps the **entire** printable character set,
|
|
189
|
+
because a hand-written list of 9 found 3 of the 27.
|
|
190
|
+
- **Quoted strings and comments are blanked before matching.** They are two more
|
|
191
|
+
places a verdict cannot stand, and both manufactured verdicts in the prior
|
|
192
|
+
implementation: `dkim=pass reason="relayed dmarc=pass ok"` turned `no-verdict`
|
|
193
|
+
into `pass`. A space inside a quoted string is a legal separator to a regex and
|
|
194
|
+
no separator at all to the grammar. **That one sentence explains every parsing
|
|
195
|
+
bug this package was built to end**, and it is why the answer is a tokeniser
|
|
196
|
+
rather than another regex patch.
|
|
197
|
+
- **All verdicts are read, never the first.** "First wins" lets an injected
|
|
198
|
+
`dmarc=pass` drown out the real `dmarc=fail`.
|
|
199
|
+
- **An unambiguous fail beats a conflict.** A method where *no* verdict passed is
|
|
200
|
+
stronger evidence than a disagreement elsewhere. Backwards, and a genuine
|
|
201
|
+
rejection is downgraded to "cannot determine" — which a lenient consumer lets
|
|
202
|
+
through.
|
|
203
|
+
- **A header that does not parse to the end yields nothing.** An unterminated
|
|
204
|
+
quote or comment blanks every verdict after it while leaving earlier ones
|
|
205
|
+
standing; erasing a trailing `dmarc=fail` and keeping a leading `spf=pass` is
|
|
206
|
+
strictly useful to an attacker.
|
|
207
|
+
- **Read the topmost `Authentication-Results` only.** RFC 8601 B.5 shows two,
|
|
208
|
+
written by two MTAs, disagreeing. Concatenate every instance and you are asking
|
|
209
|
+
two machines one question and getting the disagreement you asked for.
|
|
210
|
+
- **The RFC cannot prove the accept path.** `dmarc=` appears **zero** times in RFC
|
|
211
|
+
8601 — the three "DMARC" hits are all bibliography. Since `pass` requires a
|
|
212
|
+
dmarc verdict (it is the only method that binds `From:` to what passed), every
|
|
213
|
+
RFC example yields `no-verdict`, `fail` or `conflicted`. The RFC is the best
|
|
214
|
+
corpus in existence for the *parser* and contributes **no positive controls**.
|
|
215
|
+
That is pinned as a number in `test/rfc-8601-examples.test.ts`, so it breaks if
|
|
216
|
+
anyone adds a fixture believing otherwise.
|
|
217
|
+
|
|
218
|
+
## Testing
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
pnpm test # the suite
|
|
222
|
+
node test/mutations.mjs # revert each security decision, one at a time
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The mutation harness is the part worth copying. A green suite proves nothing on
|
|
226
|
+
its own; it checks that every reverted decision reddens a **different,
|
|
227
|
+
non-overlapping** set of tests. A mutation that reddens everything only proves
|
|
228
|
+
the suite runs, and one that reddens nothing means the decision is untested. It
|
|
229
|
+
found a real gap in this suite: two different defects reddened identical sets, so
|
|
230
|
+
a reader would have been sent to the wrong line.
|
|
231
|
+
|
|
232
|
+
MIT · part of the [broberg.ai](https://discovery.broberg.ai) shared inventory.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
var METHODS = ["spf", "dkim", "dmarc"];
|
|
5
|
+
function maskQuotedAndComments(s) {
|
|
6
|
+
const out = s.split("");
|
|
7
|
+
let quoted = false;
|
|
8
|
+
let comment = 0;
|
|
9
|
+
for (let i = 0; i < s.length; i++) {
|
|
10
|
+
const c = s[i];
|
|
11
|
+
if ((quoted || comment > 0) && c === "\\" && i + 1 < s.length) {
|
|
12
|
+
out[i] = " ";
|
|
13
|
+
out[i + 1] = " ";
|
|
14
|
+
i++;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (quoted) {
|
|
18
|
+
out[i] = " ";
|
|
19
|
+
if (c === '"') quoted = false;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (comment > 0) {
|
|
23
|
+
if (c === "(") comment++;
|
|
24
|
+
else if (c === ")") comment--;
|
|
25
|
+
out[i] = " ";
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (c === '"') {
|
|
29
|
+
quoted = true;
|
|
30
|
+
out[i] = " ";
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (c === "(") {
|
|
34
|
+
comment++;
|
|
35
|
+
out[i] = " ";
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { masked: out.join(""), malformed: quoted || comment > 0 };
|
|
40
|
+
}
|
|
41
|
+
function normalise(s) {
|
|
42
|
+
const t = s.trim().toLowerCase();
|
|
43
|
+
return t.length > 0 ? t : null;
|
|
44
|
+
}
|
|
45
|
+
function addressOfPart(raw, masked, from, to) {
|
|
46
|
+
let open = -1;
|
|
47
|
+
let close = -1;
|
|
48
|
+
let depth = 0;
|
|
49
|
+
for (let i = from; i < to; i++) {
|
|
50
|
+
const c = masked[i];
|
|
51
|
+
if (c === "<") {
|
|
52
|
+
if (depth === 0) open = i;
|
|
53
|
+
depth++;
|
|
54
|
+
} else if (c === ">" && depth > 0) {
|
|
55
|
+
depth--;
|
|
56
|
+
if (depth === 0) {
|
|
57
|
+
close = i;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (open >= 0 && close > open) return normalise(raw.slice(open + 1, close));
|
|
63
|
+
if (open >= 0) return normalise(raw.slice(from, to));
|
|
64
|
+
return normalise(masked.slice(from, to));
|
|
65
|
+
}
|
|
66
|
+
function extractAddresses(field) {
|
|
67
|
+
if (!field) return [];
|
|
68
|
+
const { masked } = maskQuotedAndComments(field);
|
|
69
|
+
const out = [];
|
|
70
|
+
let start = 0;
|
|
71
|
+
let angle = 0;
|
|
72
|
+
const flush = (end) => {
|
|
73
|
+
const a = addressOfPart(field, masked, start, end);
|
|
74
|
+
if (a) out.push(a);
|
|
75
|
+
start = end + 1;
|
|
76
|
+
};
|
|
77
|
+
for (let i = 0; i < masked.length; i++) {
|
|
78
|
+
const c = masked[i];
|
|
79
|
+
if (c === "<") angle++;
|
|
80
|
+
else if (c === ">" && angle > 0) angle--;
|
|
81
|
+
else if (c === "," && angle === 0) flush(i);
|
|
82
|
+
}
|
|
83
|
+
flush(masked.length);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function splitPlusTag(address) {
|
|
87
|
+
const at = address.indexOf("@");
|
|
88
|
+
if (at < 0 || address.indexOf("@", at + 1) !== -1) return { address, tag: null };
|
|
89
|
+
const plus = address.indexOf("+");
|
|
90
|
+
if (plus < 0 || plus > at) return { address, tag: null };
|
|
91
|
+
const tag = address.slice(plus + 1, at);
|
|
92
|
+
return { address: address.slice(0, plus) + address.slice(at), tag: tag || null };
|
|
93
|
+
}
|
|
94
|
+
function addressesMatch(field, known) {
|
|
95
|
+
const present = new Set(extractAddresses(field).map((a) => splitPlusTag(a).address));
|
|
96
|
+
if (present.size === 0) return false;
|
|
97
|
+
const list = typeof known === "string" ? [known] : known;
|
|
98
|
+
return list.some(
|
|
99
|
+
(k) => extractAddresses(k).some((a) => present.has(splitPlusTag(a).address))
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
function readAuthResults(header) {
|
|
103
|
+
if (!header || !header.trim()) {
|
|
104
|
+
return {
|
|
105
|
+
verdict: "no-verdict",
|
|
106
|
+
conflicted: [],
|
|
107
|
+
reason: "no Authentication-Results header \u2014 the sender is unproven, which is not the same as disproven"
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const { masked, malformed } = maskQuotedAndComments(header);
|
|
111
|
+
if (malformed) {
|
|
112
|
+
return {
|
|
113
|
+
verdict: "no-verdict",
|
|
114
|
+
conflicted: [],
|
|
115
|
+
reason: "unterminated quoted string or comment \u2014 the header could not be parsed to the end, and a verdict read out of the readable half would be a verdict an attacker chose the boundary of"
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const found = {};
|
|
119
|
+
const conflicted = [];
|
|
120
|
+
for (const method of METHODS) {
|
|
121
|
+
const re = new RegExp(`(?<=^|[\\s;,])${method}\\s*(?:/\\s*\\d+\\s*)?=\\s*([a-z]+)`, "gi");
|
|
122
|
+
const verdicts = [...masked.matchAll(re)].map((m) => m[1].toLowerCase());
|
|
123
|
+
if (verdicts.length === 0) continue;
|
|
124
|
+
const passed = verdicts.filter((v) => v === "pass").length;
|
|
125
|
+
if (passed > 0 && passed < verdicts.length) conflicted.push(method);
|
|
126
|
+
found[method] = passed === verdicts.length ? "pass" : verdicts.find((v) => v !== "pass");
|
|
127
|
+
}
|
|
128
|
+
if (Object.keys(found).length === 0) {
|
|
129
|
+
return {
|
|
130
|
+
verdict: "no-verdict",
|
|
131
|
+
conflicted: [],
|
|
132
|
+
reason: "Authentication-Results was present but carried no readable spf/dkim/dmarc verdict"
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const failed = METHODS.filter(
|
|
136
|
+
(m) => found[m] !== void 0 && found[m] !== "pass" && !conflicted.includes(m)
|
|
137
|
+
);
|
|
138
|
+
if (failed.length > 0) {
|
|
139
|
+
return {
|
|
140
|
+
verdict: "fail",
|
|
141
|
+
...found,
|
|
142
|
+
conflicted,
|
|
143
|
+
reason: `rejected: ${failed.map((m) => `${m}=${found[m]}`).join(", ")}`
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (conflicted.length > 0) {
|
|
147
|
+
return {
|
|
148
|
+
verdict: "conflicted",
|
|
149
|
+
...found,
|
|
150
|
+
conflicted,
|
|
151
|
+
reason: `contradictory verdicts for ${conflicted.join(", ")} \u2014 the header carries both a pass and a non-pass. This is legal multi-tiered authentication (RFC 8601 B.6) OR an injected verdict, and the header cannot tell you which. Your call.`
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (found.dmarc === void 0) {
|
|
155
|
+
return {
|
|
156
|
+
verdict: "no-verdict",
|
|
157
|
+
...found,
|
|
158
|
+
conflicted,
|
|
159
|
+
reason: "no dmarc verdict \u2014 the From: field is not bound to whatever did pass"
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return { verdict: "pass", ...found, conflicted, reason: "spf/dkim/dmarc pass" };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
exports.addressesMatch = addressesMatch;
|
|
166
|
+
exports.extractAddresses = extractAddresses;
|
|
167
|
+
exports.readAuthResults = readAuthResults;
|
|
168
|
+
exports.splitPlusTag = splitPlusTag;
|
|
169
|
+
//# sourceMappingURL=index.cjs.map
|
|
170
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @broberg/mail-identity — is this inbound mail provably from who the From:
|
|
3
|
+
* field claims?
|
|
4
|
+
*
|
|
5
|
+
* Four pure functions, no I/O, no dependencies. They answer two questions and
|
|
6
|
+
* refuse a third:
|
|
7
|
+
*
|
|
8
|
+
* WHAT IS AN ADDRESS? extractAddresses · splitPlusTag · addressesMatch
|
|
9
|
+
* WHAT DOES THE HEADER SAY? readAuthResults
|
|
10
|
+
* WHAT DOES A MATCH MEAN? NOT OURS. That is authorisation, and it stays
|
|
11
|
+
* at the call-site where it can be read.
|
|
12
|
+
*
|
|
13
|
+
* The third line is the design. Moving an authorisation decision into a shared
|
|
14
|
+
* dependency would build something worse than the drift it replaces: every
|
|
15
|
+
* consumer inherits a policy it cannot see, and a change to that policy ships
|
|
16
|
+
* silently as a patch release.
|
|
17
|
+
*
|
|
18
|
+
* ORIGIN: buddy and cardmem independently wrote this same rule, and BOTH had
|
|
19
|
+
* holes in it, found the same day in each codebase separately. The value here is
|
|
20
|
+
* not the code — it is the cases in test/, every one of them an attack that
|
|
21
|
+
* WORKED against a live implementation. Each looks correct until someone builds
|
|
22
|
+
* the counter-example.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* What the Authentication-Results header says. FOUR outcomes, not three, and
|
|
26
|
+
* not two.
|
|
27
|
+
*
|
|
28
|
+
* pass a dmarc verdict exists and everything present passed
|
|
29
|
+
* fail at least one method's verdicts unambiguously did not pass
|
|
30
|
+
* conflicted one method carries BOTH a passing and a failing verdict
|
|
31
|
+
* no-verdict nothing readable to judge by
|
|
32
|
+
*
|
|
33
|
+
* `conflicted` exists because RFC 8601 Appendix B.6 — "Service Provided,
|
|
34
|
+
* Multi-tiered Authentication Done", the standard's own example of NORMAL
|
|
35
|
+
* OPERATION — is SYNTACTICALLY IDENTICAL to a duplicate-injection attack:
|
|
36
|
+
*
|
|
37
|
+
* dkim=pass reason="good signature" header.i=@mail-router.example.net;
|
|
38
|
+
* dkim=fail reason="bad signature" header.i=@newyork.example.com
|
|
39
|
+
*
|
|
40
|
+
* Two signatures, one good, one bad, in one header. Nothing in the header
|
|
41
|
+
* distinguishes that from an attacker appending `dkim=pass`. So this package
|
|
42
|
+
* does not try: it REPORTS the disagreement and the caller decides. Fail-closed
|
|
43
|
+
* is a perfectly good policy — it is just not ours to choose for everyone.
|
|
44
|
+
*
|
|
45
|
+
* `no-verdict` must NEVER collapse into `pass`, and the inversion is why it
|
|
46
|
+
* matters more than it looks: a genuine SELF-SENT mail has no verdict at all
|
|
47
|
+
* (the header is written by the RECEIVING server, and a mail from your own
|
|
48
|
+
* authenticated session never crosses that boundary), while an external forgery
|
|
49
|
+
* HAS one and it FAILS. A gate demanding `pass` rejects precisely the mail the
|
|
50
|
+
* feature exists for.
|
|
51
|
+
*/
|
|
52
|
+
type AuthVerdict = "pass" | "fail" | "conflicted" | "no-verdict";
|
|
53
|
+
/** The three methods that say something about the sending domain. */
|
|
54
|
+
type AuthMethod = "spf" | "dkim" | "dmarc";
|
|
55
|
+
interface AuthResults {
|
|
56
|
+
verdict: AuthVerdict;
|
|
57
|
+
/** Per-method result as read. `pass` only when every verdict for it passed. */
|
|
58
|
+
spf?: string;
|
|
59
|
+
dkim?: string;
|
|
60
|
+
dmarc?: string;
|
|
61
|
+
/** Methods carrying BOTH a passing and a failing verdict. Empty unless `conflicted`. */
|
|
62
|
+
conflicted: readonly AuthMethod[];
|
|
63
|
+
/** Always set, including on `pass` — a caller logging a decision needs the why. */
|
|
64
|
+
reason: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Every address in a From/To/Cc field. A DISPLAY NAME NEVER CONTRIBUTES ONE.
|
|
68
|
+
*
|
|
69
|
+
* 'Christian Broberg <cb@webhouse.dk>' → ['cb@webhouse.dk']
|
|
70
|
+
* 'A <a@x.dk>, b@y.dk' → ['a@x.dk', 'b@y.dk']
|
|
71
|
+
* '"cb@webhouse.dk" <attacker@evil.dk>' → ['attacker@evil.dk']
|
|
72
|
+
* '"x <buddy@broberg.ai>, y" <a@evil.dk>' → ['a@evil.dk']
|
|
73
|
+
* '"Broberg, Christian" <cb@webhouse.dk>' → ['cb@webhouse.dk']
|
|
74
|
+
*
|
|
75
|
+
* The first counter-example killed `from.includes(owner)`, which accepted the
|
|
76
|
+
* owner's address written as DISPLAY TEXT — four call-paths reproduced it. The
|
|
77
|
+
* second and third killed `field.split(',')`, which cuts a quoted display name
|
|
78
|
+
* in half before quoting is understood. Both were live; neither was theoretical.
|
|
79
|
+
*
|
|
80
|
+
* Addresses are lowercased. Local parts are formally case-sensitive (RFC 5321),
|
|
81
|
+
* but no provider we send through treats them that way, and NOT normalising
|
|
82
|
+
* would mean `CB@webhouse.dk` fails an owner check that `cb@webhouse.dk` passes
|
|
83
|
+
* — a comparison anyone can sidestep with the shift key.
|
|
84
|
+
*/
|
|
85
|
+
declare function extractAddresses(field: string | null | undefined): string[];
|
|
86
|
+
/**
|
|
87
|
+
* Split a plus-tag off an address.
|
|
88
|
+
*
|
|
89
|
+
* 'buddy+whop@broberg.ai' → { address: 'buddy@broberg.ai', tag: 'whop' }
|
|
90
|
+
* 'buddy@broberg.ai' → { address: 'buddy@broberg.ai', tag: null }
|
|
91
|
+
*
|
|
92
|
+
* TWO RULES CARRY THE SECURITY, and they are the reason this is separate from
|
|
93
|
+
* matching rather than folded into it:
|
|
94
|
+
*
|
|
95
|
+
* 1. THE TAG INFORMS, THE ADDRESS PROVES. An unknown tag must still match. A tag
|
|
96
|
+
* able to REFUSE a match would be authorisation disguised as routing, and a
|
|
97
|
+
* typo (`buddy+whopp@`) would make mail vanish silently. `+admin`, `+deploy`
|
|
98
|
+
* and `+urgent` must never mean anything privileged — anyone can type them.
|
|
99
|
+
*
|
|
100
|
+
* 2. SPLIT AFTER extractAddresses(), NEVER on a raw field. Do it the other way
|
|
101
|
+
* round and you are back in the impersonation hole wearing a new hat: a
|
|
102
|
+
* display name must not contribute an address, and that property lives in the
|
|
103
|
+
* extraction.
|
|
104
|
+
*
|
|
105
|
+
* An address with more than one '@' is malformed and is returned UNTOUCHED:
|
|
106
|
+
* `buddy+x@evil.com@broberg.ai` must never be reshaped into something valid.
|
|
107
|
+
*
|
|
108
|
+
* `tag` is `null`, not `undefined`, so that a fixture written as
|
|
109
|
+
* `{ address: 'x', tag: undefined }` cannot pass a deep-equality check against
|
|
110
|
+
* `{ address: 'x' }`. Two different facts, two distinguishable values.
|
|
111
|
+
*/
|
|
112
|
+
declare function splitPlusTag(address: string): {
|
|
113
|
+
address: string;
|
|
114
|
+
tag: string | null;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Does any address in `field` EQUAL any address in `known`?
|
|
118
|
+
*
|
|
119
|
+
* Equality on extracted addresses. Never a substring, never a prefix, never a
|
|
120
|
+
* suffix — `cb@webhouse.dk.evil.com` and `"cb@webhouse.dk" <x@evil.dk>` and
|
|
121
|
+
* `buddy@broberg.ai.evil.com` are all rejected, and each of the three was
|
|
122
|
+
* accepted by a live implementation this week.
|
|
123
|
+
*
|
|
124
|
+
* Both sides go through the SAME extraction and the SAME plus-tag
|
|
125
|
+
* normalisation, so `known` may be given as bare addresses or as full fields.
|
|
126
|
+
* An empty or unparseable `field` matches nothing.
|
|
127
|
+
*/
|
|
128
|
+
declare function addressesMatch(field: string | null | undefined, known: string | readonly string[]): boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Read an Authentication-Results header (RFC 8601). Reports; does not judge.
|
|
131
|
+
*
|
|
132
|
+
* ANCHORING IS AN ALLOWLIST, NOT A DENYLIST. `\b` was the original, and a dot is
|
|
133
|
+
* a word boundary, so `\bdmarc=` matched INSIDE `header.dmarc=pass` — a
|
|
134
|
+
* qualified key read as a verdict:
|
|
135
|
+
*
|
|
136
|
+
* 'header.dmarc=pass header.dkim=pass header.spf=pass; dmarc=fail; dkim=fail; spf=fail'
|
|
137
|
+
* → PASS, while every real verdict in it is `fail`.
|
|
138
|
+
*
|
|
139
|
+
* The first fix was a denylist, `(?<![\w.-])`. Swept over the whole printable
|
|
140
|
+
* character set it left 27 characters open, including a colon — `arc:dmarc=pass`
|
|
141
|
+
* read as a pass. A denylist has to predict every character a future vendor
|
|
142
|
+
* invents. So: a verdict must stand at the start of the header or after a REAL
|
|
143
|
+
* separator (whitespace, `;` or `,` — RFC 8601's own), and anything else in
|
|
144
|
+
* front of it makes it a qualified key rather than a verdict.
|
|
145
|
+
*
|
|
146
|
+
* Quoted strings and comments are blanked before matching (see
|
|
147
|
+
* maskQuotedAndComments) — they are two more places a verdict cannot stand, and
|
|
148
|
+
* both manufactured verdicts in the prior implementation.
|
|
149
|
+
*
|
|
150
|
+
* ALL occurrences are collected, not the first: a header can carry more than one
|
|
151
|
+
* verdict per method, and "first wins" lets an injected `dmarc=pass` drown out
|
|
152
|
+
* the real `dmarc=fail`.
|
|
153
|
+
*
|
|
154
|
+
* PRECEDENCE IS SECURITY-BEARING. An unambiguous fail beats a conflict:
|
|
155
|
+
*
|
|
156
|
+
* 'dkim=pass; dkim=fail; dmarc=fail' → fail, NOT conflicted
|
|
157
|
+
*
|
|
158
|
+
* A method where NO verdict passed is stronger evidence than a disagreement
|
|
159
|
+
* somewhere else. Get the order wrong and a genuine, unambiguous rejection is
|
|
160
|
+
* downgraded to "cannot determine" — which a lenient caller then lets through.
|
|
161
|
+
*/
|
|
162
|
+
declare function readAuthResults(header: string | null | undefined): AuthResults;
|
|
163
|
+
|
|
164
|
+
export { type AuthMethod, type AuthResults, type AuthVerdict, addressesMatch, extractAddresses, readAuthResults, splitPlusTag };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @broberg/mail-identity — is this inbound mail provably from who the From:
|
|
3
|
+
* field claims?
|
|
4
|
+
*
|
|
5
|
+
* Four pure functions, no I/O, no dependencies. They answer two questions and
|
|
6
|
+
* refuse a third:
|
|
7
|
+
*
|
|
8
|
+
* WHAT IS AN ADDRESS? extractAddresses · splitPlusTag · addressesMatch
|
|
9
|
+
* WHAT DOES THE HEADER SAY? readAuthResults
|
|
10
|
+
* WHAT DOES A MATCH MEAN? NOT OURS. That is authorisation, and it stays
|
|
11
|
+
* at the call-site where it can be read.
|
|
12
|
+
*
|
|
13
|
+
* The third line is the design. Moving an authorisation decision into a shared
|
|
14
|
+
* dependency would build something worse than the drift it replaces: every
|
|
15
|
+
* consumer inherits a policy it cannot see, and a change to that policy ships
|
|
16
|
+
* silently as a patch release.
|
|
17
|
+
*
|
|
18
|
+
* ORIGIN: buddy and cardmem independently wrote this same rule, and BOTH had
|
|
19
|
+
* holes in it, found the same day in each codebase separately. The value here is
|
|
20
|
+
* not the code — it is the cases in test/, every one of them an attack that
|
|
21
|
+
* WORKED against a live implementation. Each looks correct until someone builds
|
|
22
|
+
* the counter-example.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* What the Authentication-Results header says. FOUR outcomes, not three, and
|
|
26
|
+
* not two.
|
|
27
|
+
*
|
|
28
|
+
* pass a dmarc verdict exists and everything present passed
|
|
29
|
+
* fail at least one method's verdicts unambiguously did not pass
|
|
30
|
+
* conflicted one method carries BOTH a passing and a failing verdict
|
|
31
|
+
* no-verdict nothing readable to judge by
|
|
32
|
+
*
|
|
33
|
+
* `conflicted` exists because RFC 8601 Appendix B.6 — "Service Provided,
|
|
34
|
+
* Multi-tiered Authentication Done", the standard's own example of NORMAL
|
|
35
|
+
* OPERATION — is SYNTACTICALLY IDENTICAL to a duplicate-injection attack:
|
|
36
|
+
*
|
|
37
|
+
* dkim=pass reason="good signature" header.i=@mail-router.example.net;
|
|
38
|
+
* dkim=fail reason="bad signature" header.i=@newyork.example.com
|
|
39
|
+
*
|
|
40
|
+
* Two signatures, one good, one bad, in one header. Nothing in the header
|
|
41
|
+
* distinguishes that from an attacker appending `dkim=pass`. So this package
|
|
42
|
+
* does not try: it REPORTS the disagreement and the caller decides. Fail-closed
|
|
43
|
+
* is a perfectly good policy — it is just not ours to choose for everyone.
|
|
44
|
+
*
|
|
45
|
+
* `no-verdict` must NEVER collapse into `pass`, and the inversion is why it
|
|
46
|
+
* matters more than it looks: a genuine SELF-SENT mail has no verdict at all
|
|
47
|
+
* (the header is written by the RECEIVING server, and a mail from your own
|
|
48
|
+
* authenticated session never crosses that boundary), while an external forgery
|
|
49
|
+
* HAS one and it FAILS. A gate demanding `pass` rejects precisely the mail the
|
|
50
|
+
* feature exists for.
|
|
51
|
+
*/
|
|
52
|
+
type AuthVerdict = "pass" | "fail" | "conflicted" | "no-verdict";
|
|
53
|
+
/** The three methods that say something about the sending domain. */
|
|
54
|
+
type AuthMethod = "spf" | "dkim" | "dmarc";
|
|
55
|
+
interface AuthResults {
|
|
56
|
+
verdict: AuthVerdict;
|
|
57
|
+
/** Per-method result as read. `pass` only when every verdict for it passed. */
|
|
58
|
+
spf?: string;
|
|
59
|
+
dkim?: string;
|
|
60
|
+
dmarc?: string;
|
|
61
|
+
/** Methods carrying BOTH a passing and a failing verdict. Empty unless `conflicted`. */
|
|
62
|
+
conflicted: readonly AuthMethod[];
|
|
63
|
+
/** Always set, including on `pass` — a caller logging a decision needs the why. */
|
|
64
|
+
reason: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Every address in a From/To/Cc field. A DISPLAY NAME NEVER CONTRIBUTES ONE.
|
|
68
|
+
*
|
|
69
|
+
* 'Christian Broberg <cb@webhouse.dk>' → ['cb@webhouse.dk']
|
|
70
|
+
* 'A <a@x.dk>, b@y.dk' → ['a@x.dk', 'b@y.dk']
|
|
71
|
+
* '"cb@webhouse.dk" <attacker@evil.dk>' → ['attacker@evil.dk']
|
|
72
|
+
* '"x <buddy@broberg.ai>, y" <a@evil.dk>' → ['a@evil.dk']
|
|
73
|
+
* '"Broberg, Christian" <cb@webhouse.dk>' → ['cb@webhouse.dk']
|
|
74
|
+
*
|
|
75
|
+
* The first counter-example killed `from.includes(owner)`, which accepted the
|
|
76
|
+
* owner's address written as DISPLAY TEXT — four call-paths reproduced it. The
|
|
77
|
+
* second and third killed `field.split(',')`, which cuts a quoted display name
|
|
78
|
+
* in half before quoting is understood. Both were live; neither was theoretical.
|
|
79
|
+
*
|
|
80
|
+
* Addresses are lowercased. Local parts are formally case-sensitive (RFC 5321),
|
|
81
|
+
* but no provider we send through treats them that way, and NOT normalising
|
|
82
|
+
* would mean `CB@webhouse.dk` fails an owner check that `cb@webhouse.dk` passes
|
|
83
|
+
* — a comparison anyone can sidestep with the shift key.
|
|
84
|
+
*/
|
|
85
|
+
declare function extractAddresses(field: string | null | undefined): string[];
|
|
86
|
+
/**
|
|
87
|
+
* Split a plus-tag off an address.
|
|
88
|
+
*
|
|
89
|
+
* 'buddy+whop@broberg.ai' → { address: 'buddy@broberg.ai', tag: 'whop' }
|
|
90
|
+
* 'buddy@broberg.ai' → { address: 'buddy@broberg.ai', tag: null }
|
|
91
|
+
*
|
|
92
|
+
* TWO RULES CARRY THE SECURITY, and they are the reason this is separate from
|
|
93
|
+
* matching rather than folded into it:
|
|
94
|
+
*
|
|
95
|
+
* 1. THE TAG INFORMS, THE ADDRESS PROVES. An unknown tag must still match. A tag
|
|
96
|
+
* able to REFUSE a match would be authorisation disguised as routing, and a
|
|
97
|
+
* typo (`buddy+whopp@`) would make mail vanish silently. `+admin`, `+deploy`
|
|
98
|
+
* and `+urgent` must never mean anything privileged — anyone can type them.
|
|
99
|
+
*
|
|
100
|
+
* 2. SPLIT AFTER extractAddresses(), NEVER on a raw field. Do it the other way
|
|
101
|
+
* round and you are back in the impersonation hole wearing a new hat: a
|
|
102
|
+
* display name must not contribute an address, and that property lives in the
|
|
103
|
+
* extraction.
|
|
104
|
+
*
|
|
105
|
+
* An address with more than one '@' is malformed and is returned UNTOUCHED:
|
|
106
|
+
* `buddy+x@evil.com@broberg.ai` must never be reshaped into something valid.
|
|
107
|
+
*
|
|
108
|
+
* `tag` is `null`, not `undefined`, so that a fixture written as
|
|
109
|
+
* `{ address: 'x', tag: undefined }` cannot pass a deep-equality check against
|
|
110
|
+
* `{ address: 'x' }`. Two different facts, two distinguishable values.
|
|
111
|
+
*/
|
|
112
|
+
declare function splitPlusTag(address: string): {
|
|
113
|
+
address: string;
|
|
114
|
+
tag: string | null;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Does any address in `field` EQUAL any address in `known`?
|
|
118
|
+
*
|
|
119
|
+
* Equality on extracted addresses. Never a substring, never a prefix, never a
|
|
120
|
+
* suffix — `cb@webhouse.dk.evil.com` and `"cb@webhouse.dk" <x@evil.dk>` and
|
|
121
|
+
* `buddy@broberg.ai.evil.com` are all rejected, and each of the three was
|
|
122
|
+
* accepted by a live implementation this week.
|
|
123
|
+
*
|
|
124
|
+
* Both sides go through the SAME extraction and the SAME plus-tag
|
|
125
|
+
* normalisation, so `known` may be given as bare addresses or as full fields.
|
|
126
|
+
* An empty or unparseable `field` matches nothing.
|
|
127
|
+
*/
|
|
128
|
+
declare function addressesMatch(field: string | null | undefined, known: string | readonly string[]): boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Read an Authentication-Results header (RFC 8601). Reports; does not judge.
|
|
131
|
+
*
|
|
132
|
+
* ANCHORING IS AN ALLOWLIST, NOT A DENYLIST. `\b` was the original, and a dot is
|
|
133
|
+
* a word boundary, so `\bdmarc=` matched INSIDE `header.dmarc=pass` — a
|
|
134
|
+
* qualified key read as a verdict:
|
|
135
|
+
*
|
|
136
|
+
* 'header.dmarc=pass header.dkim=pass header.spf=pass; dmarc=fail; dkim=fail; spf=fail'
|
|
137
|
+
* → PASS, while every real verdict in it is `fail`.
|
|
138
|
+
*
|
|
139
|
+
* The first fix was a denylist, `(?<![\w.-])`. Swept over the whole printable
|
|
140
|
+
* character set it left 27 characters open, including a colon — `arc:dmarc=pass`
|
|
141
|
+
* read as a pass. A denylist has to predict every character a future vendor
|
|
142
|
+
* invents. So: a verdict must stand at the start of the header or after a REAL
|
|
143
|
+
* separator (whitespace, `;` or `,` — RFC 8601's own), and anything else in
|
|
144
|
+
* front of it makes it a qualified key rather than a verdict.
|
|
145
|
+
*
|
|
146
|
+
* Quoted strings and comments are blanked before matching (see
|
|
147
|
+
* maskQuotedAndComments) — they are two more places a verdict cannot stand, and
|
|
148
|
+
* both manufactured verdicts in the prior implementation.
|
|
149
|
+
*
|
|
150
|
+
* ALL occurrences are collected, not the first: a header can carry more than one
|
|
151
|
+
* verdict per method, and "first wins" lets an injected `dmarc=pass` drown out
|
|
152
|
+
* the real `dmarc=fail`.
|
|
153
|
+
*
|
|
154
|
+
* PRECEDENCE IS SECURITY-BEARING. An unambiguous fail beats a conflict:
|
|
155
|
+
*
|
|
156
|
+
* 'dkim=pass; dkim=fail; dmarc=fail' → fail, NOT conflicted
|
|
157
|
+
*
|
|
158
|
+
* A method where NO verdict passed is stronger evidence than a disagreement
|
|
159
|
+
* somewhere else. Get the order wrong and a genuine, unambiguous rejection is
|
|
160
|
+
* downgraded to "cannot determine" — which a lenient caller then lets through.
|
|
161
|
+
*/
|
|
162
|
+
declare function readAuthResults(header: string | null | undefined): AuthResults;
|
|
163
|
+
|
|
164
|
+
export { type AuthMethod, type AuthResults, type AuthVerdict, addressesMatch, extractAddresses, readAuthResults, splitPlusTag };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var METHODS = ["spf", "dkim", "dmarc"];
|
|
3
|
+
function maskQuotedAndComments(s) {
|
|
4
|
+
const out = s.split("");
|
|
5
|
+
let quoted = false;
|
|
6
|
+
let comment = 0;
|
|
7
|
+
for (let i = 0; i < s.length; i++) {
|
|
8
|
+
const c = s[i];
|
|
9
|
+
if ((quoted || comment > 0) && c === "\\" && i + 1 < s.length) {
|
|
10
|
+
out[i] = " ";
|
|
11
|
+
out[i + 1] = " ";
|
|
12
|
+
i++;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (quoted) {
|
|
16
|
+
out[i] = " ";
|
|
17
|
+
if (c === '"') quoted = false;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (comment > 0) {
|
|
21
|
+
if (c === "(") comment++;
|
|
22
|
+
else if (c === ")") comment--;
|
|
23
|
+
out[i] = " ";
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (c === '"') {
|
|
27
|
+
quoted = true;
|
|
28
|
+
out[i] = " ";
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (c === "(") {
|
|
32
|
+
comment++;
|
|
33
|
+
out[i] = " ";
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { masked: out.join(""), malformed: quoted || comment > 0 };
|
|
38
|
+
}
|
|
39
|
+
function normalise(s) {
|
|
40
|
+
const t = s.trim().toLowerCase();
|
|
41
|
+
return t.length > 0 ? t : null;
|
|
42
|
+
}
|
|
43
|
+
function addressOfPart(raw, masked, from, to) {
|
|
44
|
+
let open = -1;
|
|
45
|
+
let close = -1;
|
|
46
|
+
let depth = 0;
|
|
47
|
+
for (let i = from; i < to; i++) {
|
|
48
|
+
const c = masked[i];
|
|
49
|
+
if (c === "<") {
|
|
50
|
+
if (depth === 0) open = i;
|
|
51
|
+
depth++;
|
|
52
|
+
} else if (c === ">" && depth > 0) {
|
|
53
|
+
depth--;
|
|
54
|
+
if (depth === 0) {
|
|
55
|
+
close = i;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (open >= 0 && close > open) return normalise(raw.slice(open + 1, close));
|
|
61
|
+
if (open >= 0) return normalise(raw.slice(from, to));
|
|
62
|
+
return normalise(masked.slice(from, to));
|
|
63
|
+
}
|
|
64
|
+
function extractAddresses(field) {
|
|
65
|
+
if (!field) return [];
|
|
66
|
+
const { masked } = maskQuotedAndComments(field);
|
|
67
|
+
const out = [];
|
|
68
|
+
let start = 0;
|
|
69
|
+
let angle = 0;
|
|
70
|
+
const flush = (end) => {
|
|
71
|
+
const a = addressOfPart(field, masked, start, end);
|
|
72
|
+
if (a) out.push(a);
|
|
73
|
+
start = end + 1;
|
|
74
|
+
};
|
|
75
|
+
for (let i = 0; i < masked.length; i++) {
|
|
76
|
+
const c = masked[i];
|
|
77
|
+
if (c === "<") angle++;
|
|
78
|
+
else if (c === ">" && angle > 0) angle--;
|
|
79
|
+
else if (c === "," && angle === 0) flush(i);
|
|
80
|
+
}
|
|
81
|
+
flush(masked.length);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
function splitPlusTag(address) {
|
|
85
|
+
const at = address.indexOf("@");
|
|
86
|
+
if (at < 0 || address.indexOf("@", at + 1) !== -1) return { address, tag: null };
|
|
87
|
+
const plus = address.indexOf("+");
|
|
88
|
+
if (plus < 0 || plus > at) return { address, tag: null };
|
|
89
|
+
const tag = address.slice(plus + 1, at);
|
|
90
|
+
return { address: address.slice(0, plus) + address.slice(at), tag: tag || null };
|
|
91
|
+
}
|
|
92
|
+
function addressesMatch(field, known) {
|
|
93
|
+
const present = new Set(extractAddresses(field).map((a) => splitPlusTag(a).address));
|
|
94
|
+
if (present.size === 0) return false;
|
|
95
|
+
const list = typeof known === "string" ? [known] : known;
|
|
96
|
+
return list.some(
|
|
97
|
+
(k) => extractAddresses(k).some((a) => present.has(splitPlusTag(a).address))
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
function readAuthResults(header) {
|
|
101
|
+
if (!header || !header.trim()) {
|
|
102
|
+
return {
|
|
103
|
+
verdict: "no-verdict",
|
|
104
|
+
conflicted: [],
|
|
105
|
+
reason: "no Authentication-Results header \u2014 the sender is unproven, which is not the same as disproven"
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const { masked, malformed } = maskQuotedAndComments(header);
|
|
109
|
+
if (malformed) {
|
|
110
|
+
return {
|
|
111
|
+
verdict: "no-verdict",
|
|
112
|
+
conflicted: [],
|
|
113
|
+
reason: "unterminated quoted string or comment \u2014 the header could not be parsed to the end, and a verdict read out of the readable half would be a verdict an attacker chose the boundary of"
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const found = {};
|
|
117
|
+
const conflicted = [];
|
|
118
|
+
for (const method of METHODS) {
|
|
119
|
+
const re = new RegExp(`(?<=^|[\\s;,])${method}\\s*(?:/\\s*\\d+\\s*)?=\\s*([a-z]+)`, "gi");
|
|
120
|
+
const verdicts = [...masked.matchAll(re)].map((m) => m[1].toLowerCase());
|
|
121
|
+
if (verdicts.length === 0) continue;
|
|
122
|
+
const passed = verdicts.filter((v) => v === "pass").length;
|
|
123
|
+
if (passed > 0 && passed < verdicts.length) conflicted.push(method);
|
|
124
|
+
found[method] = passed === verdicts.length ? "pass" : verdicts.find((v) => v !== "pass");
|
|
125
|
+
}
|
|
126
|
+
if (Object.keys(found).length === 0) {
|
|
127
|
+
return {
|
|
128
|
+
verdict: "no-verdict",
|
|
129
|
+
conflicted: [],
|
|
130
|
+
reason: "Authentication-Results was present but carried no readable spf/dkim/dmarc verdict"
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const failed = METHODS.filter(
|
|
134
|
+
(m) => found[m] !== void 0 && found[m] !== "pass" && !conflicted.includes(m)
|
|
135
|
+
);
|
|
136
|
+
if (failed.length > 0) {
|
|
137
|
+
return {
|
|
138
|
+
verdict: "fail",
|
|
139
|
+
...found,
|
|
140
|
+
conflicted,
|
|
141
|
+
reason: `rejected: ${failed.map((m) => `${m}=${found[m]}`).join(", ")}`
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (conflicted.length > 0) {
|
|
145
|
+
return {
|
|
146
|
+
verdict: "conflicted",
|
|
147
|
+
...found,
|
|
148
|
+
conflicted,
|
|
149
|
+
reason: `contradictory verdicts for ${conflicted.join(", ")} \u2014 the header carries both a pass and a non-pass. This is legal multi-tiered authentication (RFC 8601 B.6) OR an injected verdict, and the header cannot tell you which. Your call.`
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (found.dmarc === void 0) {
|
|
153
|
+
return {
|
|
154
|
+
verdict: "no-verdict",
|
|
155
|
+
...found,
|
|
156
|
+
conflicted,
|
|
157
|
+
reason: "no dmarc verdict \u2014 the From: field is not bound to whatever did pass"
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return { verdict: "pass", ...found, conflicted, reason: "spf/dkim/dmarc pass" };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export { addressesMatch, extractAddresses, readAuthResults, splitPlusTag };
|
|
164
|
+
//# sourceMappingURL=index.js.map
|
|
165
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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 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"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/mail-identity",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"main": "./dist/index.cjs",
|
|
13
|
+
"module": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"require": "./dist/index.cjs"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsup",
|
|
25
|
+
"test": "vitest run --reporter=default --reporter=json --outputFile.json=.vitest-report/results.json && node ../../scripts/mutations-if-changed.mjs test/mutations.mjs",
|
|
26
|
+
"typecheck": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"tsup": "^8.3.0",
|
|
30
|
+
"typescript": "^5.6.0",
|
|
31
|
+
"vitest": "^2.1.0"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"email",
|
|
35
|
+
"mail",
|
|
36
|
+
"inbound",
|
|
37
|
+
"sender",
|
|
38
|
+
"identity",
|
|
39
|
+
"spoofing",
|
|
40
|
+
"impersonation",
|
|
41
|
+
"authentication-results",
|
|
42
|
+
"dmarc",
|
|
43
|
+
"dkim",
|
|
44
|
+
"spf",
|
|
45
|
+
"rfc8601",
|
|
46
|
+
"rfc5322",
|
|
47
|
+
"plus-addressing",
|
|
48
|
+
"broberg"
|
|
49
|
+
],
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "https://github.com/broberg-ai/components",
|
|
53
|
+
"directory": "packages/mail-identity"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
}
|
|
58
|
+
}
|