Skip to content

Commit a63eee1

Browse files
Bekacruclaude
andauthored
Merge commit from fork
`defaultNormalizer` validated the email on the ASCII `@` before any Unicode normalization. A character that is a homoglyph of `@` — e.g. U+FF20 FULLWIDTH COMMERCIAL AT — passed the single-`@` check, but can be canonicalized to an ASCII `@` by a downstream address parser (NFKC), splitting the address into multiple recipients (CWE-180: validate before canonicalize). Apply `String.prototype.normalize("NFKC")` before validation so any such homoglyph becomes a real `@` and is rejected by the existing checks. Behaviour on legitimate addresses is unchanged. Adds regression tests covering U+FF20 and U+FE6B. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dab3cfb commit a63eee1

2 files changed

Lines changed: 57 additions & 2 deletions

File tree

packages/core/src/lib/actions/signin/send-token.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,16 @@ export async function sendToken(
9191
}
9292
}
9393

94-
function defaultNormalizer(email?: string) {
94+
export function defaultNormalizer(email?: string) {
9595
if (!email) throw new Error("Missing email from request body.")
9696

97-
const trimmedEmail = email.toLowerCase().trim()
97+
// Apply Unicode NFKC normalization *before* validation. Without this, a
98+
// character that is a homoglyph of `@` (e.g. U+FF20 FULLWIDTH COMMERCIAL AT)
99+
// passes the single-`@` check below, but can later be canonicalized to an
100+
// ASCII `@` by a downstream address parser, splitting the address into
101+
// multiple recipients. Normalizing first ensures any such homoglyph is
102+
// turned into a real `@` and rejected by the checks below.
103+
const trimmedEmail = email.normalize("NFKC").toLowerCase().trim()
98104

99105
// Reject email addresses with quotes to prevent address parser confusion
100106
// This prevents attacks like "attacker@evil.com"@victim.com
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, it, expect } from "vitest"
2+
import { defaultNormalizer } from "../src/lib/actions/signin/send-token"
3+
4+
describe("defaultNormalizer", () => {
5+
it("normalizes a valid email", () => {
6+
expect(defaultNormalizer("Foo@Example.com")).toBe("foo@example.com")
7+
expect(defaultNormalizer(" foo@example.com ")).toBe("foo@example.com")
8+
})
9+
10+
it("keeps the first domain when the domain part is comma-separated", () => {
11+
expect(defaultNormalizer("foo@example.com,evil.com")).toBe(
12+
"foo@example.com"
13+
)
14+
})
15+
16+
it("throws on a missing email", () => {
17+
expect(() => defaultNormalizer(undefined)).toThrow()
18+
expect(() => defaultNormalizer("")).toThrow()
19+
})
20+
21+
it("rejects quotes (address parser confusion)", () => {
22+
expect(() => defaultNormalizer('"foo@evil.com"@example.com')).toThrow(
23+
"Invalid email address format."
24+
)
25+
})
26+
27+
it("rejects multiple ASCII @ symbols", () => {
28+
expect(() => defaultNormalizer("foo@evil.com@example.com")).toThrow(
29+
"Invalid email address format."
30+
)
31+
})
32+
33+
// Regression test for GHSA-7rqj-j65f-68wh:
34+
// U+FF20 (FULLWIDTH COMMERCIAL AT) is a homoglyph of `@` that normalizes to
35+
// ASCII `@` under NFKC. It must be canonicalized before validation so the
36+
// address cannot smuggle a second `@` past the single-`@` check.
37+
it("rejects a Unicode homoglyph of @ (U+FF20)", () => {
38+
expect(() =>
39+
defaultNormalizer("attacker@evil.com@victim.company.com")
40+
).toThrow("Invalid email address format.")
41+
})
42+
43+
it("rejects other fullwidth/homoglyph @ characters", () => {
44+
// U+FE6B SMALL COMMERCIAL AT also normalizes to `@` under NFKC.
45+
expect(() =>
46+
defaultNormalizer("attacker@evil.com﹫victim.company.com")
47+
).toThrow("Invalid email address format.")
48+
})
49+
})

0 commit comments

Comments
 (0)