homographic_spoofing 0.1.2 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bf792d72d7ce23860e995427315130b5604dffb20473017e18419ab281413bbd
4
- data.tar.gz: 5950f5c07494fc92f0bc437f7d3479444d36ba59c7333577ce41ce2c90961259
3
+ metadata.gz: e62f8de9bed530122d993c518081b18c90ff10a28b6a1890daa2181beff984aa
4
+ data.tar.gz: efee11ffe57495378d48a1ed545ff6fc9d51189168df8766edf5a09779e27692
5
5
  SHA512:
6
- metadata.gz: 65d280b71972085510132dfe3f6c0c1e0ddfac067cf5e8cffe9b0681ab6999263c792fc16009d9c1e1279f38160ce506583a73e5f4b13bff26635b54fccffe5c
7
- data.tar.gz: 51f1a8442291be6a92a8e22e32e7c196bd946f456755f745cdbe4df4c7ce18780b5716582d431a17b5913321772313c56723c4fa98fec301e88a7e81f8546fdc
6
+ metadata.gz: 0e00f735b4ebcaae22a5c9f85ce5701da7184b44c474015e2f9ca4af661e426cb61e743ee6879de997a01dc41a869a100f3e92b2d702001686eb6716e9d2e893
7
+ data.tar.gz: bc297a9087a1fcc45f10c4c570c4675932733f3be961ea445796da6a44bfa4f344bbc8d3585492829fd394a7dcbab94ff327ade354acc3746ab35d2acb3214fa
data/CHANGELOG.md ADDED
@@ -0,0 +1,56 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.0] - 2026-10-20
11
+
12
+ ### Changed
13
+
14
+ - Sanitize uppercase and mixed-case confusable IDN domains.
15
+ - Scan each domain label independently for script confusables.
16
+ - Anchor allowed-TLD matching to whole TLD labels.
17
+ - Disallow the characters Chromium removes as quotation-mark and letter look-alikes: U+0138 (kra), U+02BB (turned comma), U+02BC (apostrophe) and U+02EC (voicing).
18
+ - Renamed `MOZILLA_DISALLOWED_CHARACTERS` to `DISALLOWED_CHARACTERS`, since the list now combines Mozilla's and Chromium's removals.
19
+ - Apply the mixed-script, mixed-digit, digit and script-specific rules to each subdomain label separately rather than to the whole subdomain chain.
20
+ - Flag a label that is only a CJK ideograph that looks like Latin or punctuation, such as `一` in `a.一.example.com`.
21
+ - Sanitize an email address only within the part each detection came from, so a benign mailbox that equals a spoofed domain label is left intact.
22
+ - Check the mailbox and each route domain of an address with an obsolete route separately instead of flagging the route itself.
23
+
24
+ ### Security
25
+
26
+ - Pin GitHub Actions to commit SHAs and add an actionlint + zizmor audit job.
27
+
28
+ ## [0.1.2] - 2025-07-22
29
+
30
+ ### Added
31
+
32
+ - `csv` gem dependency (extracted from the standard library in Ruby 3.4).
33
+
34
+ ### Changed
35
+
36
+ - Regenerated the allowed-IDN-character and digit tables.
37
+ - Renamed `MOZZILLA_DISALLOWED_CHARACTERS` to `MOZILLA_DISALLOWED_CHARACTERS`.
38
+
39
+ ## [0.1.1] - 2024-06-20
40
+
41
+ ### Added
42
+
43
+ - `homepage_uri` and `source_code_uri` gem metadata.
44
+
45
+ ## [0.1.0] - 2024-06-20
46
+
47
+ ### Added
48
+
49
+ - Initial release: detect and sanitize homographic spoofing attacks in URLs and
50
+ email addresses.
51
+
52
+ [Unreleased]: https://github.com/basecamp/homographic_spoofing/compare/v0.2.0...HEAD
53
+ [0.2.0]: https://github.com/basecamp/homographic_spoofing/compare/v0.1.2...v0.2.0
54
+ [0.1.2]: https://github.com/basecamp/homographic_spoofing/compare/v0.1.1...v0.1.2
55
+ [0.1.1]: https://github.com/basecamp/homographic_spoofing/compare/v0.1.0...v0.1.1
56
+ [0.1.0]: https://github.com/basecamp/homographic_spoofing/releases/tag/v0.1.0
@@ -1,2 +1,10 @@
1
- class HomographicSpoofing::Detector::Detection < Struct.new(:reason, :label)
1
+ # `span` is the [offset, length] of the address component the detection came
2
+ # from, in characters, within the field — so the sanitizer punycodes the
3
+ # offending label only inside that component and never rewrites a matching label
4
+ # in a sibling component (a local part equal to a spoofed domain label, say). It
5
+ # is derived from the parsed address, not by re-scanning the raw string for an
6
+ # "@", which trailing CFWS comments or a display name can carry spuriously. It
7
+ # is nil for detectors that run against a standalone string (a bare IDN or
8
+ # quoted string), where the whole field is the one component.
9
+ class HomographicSpoofing::Detector::Detection < Struct.new(:reason, :label, :span)
2
10
  end
@@ -17,10 +17,12 @@ class HomographicSpoofing::Detector::EmailAddress
17
17
 
18
18
  def detections
19
19
  mail_address = mail_address_wrap(email_address)
20
+ spans = component_spans(mail_address)
20
21
  [].tap do |result|
21
- result.concat detector_for(part: mail_address.name, type: "quoted_string").detections if mail_address.name
22
- result.concat detector_for(part: mail_address.local, type: "local").detections if mail_address.local
23
- result.concat detector_for(part: mail_address.domain, type: "idn").detections if mail_address.domain
22
+ result.concat detections_for(text: mail_address.name, type: "quoted_string", span: spans[:name])
23
+ result.concat detections_for(text: mailbox(mail_address), type: "local", span: spans[:local])
24
+ result.concat detections_for(text: mail_address.domain, type: "idn", span: spans[:domain])
25
+ route_domains.each { |domain, span| result.concat detections_for(text: domain, type: "idn", span:) }
24
26
  end
25
27
  rescue Mail::Field::FieldError
26
28
  # Do not analyse invalid email addresses.
@@ -30,10 +32,385 @@ class HomographicSpoofing::Detector::EmailAddress
30
32
  private
31
33
  attr_reader :email_address
32
34
 
33
- def detector_for(type:, part:)
34
- "HomographicSpoofing::Detector::#{type.camelize}".constantize.new(part)
35
+ # Tag each detection with the [offset, length] of the component it came from
36
+ # so the sanitizer scopes the punycode replacement to that component alone —
37
+ # a domain-label spoof never rewrites a benign local part that merely equals
38
+ # the same string.
39
+ def detections_for(text:, type:, span:)
40
+ if text
41
+ detector_for(type:, text:).detections.each { |detection| detection.span = span }
42
+ else
43
+ []
44
+ end
35
45
  end
36
46
 
47
+ def detector_for(type:, text:)
48
+ "HomographicSpoofing::Detector::#{type.camelize}".constantize.new(text)
49
+ end
50
+
51
+ # Derive each component's [offset, length] span from parser token boundaries,
52
+ # not by searching the raw field for the stripped parts. The addr-spec built
53
+ # from the parser's *raw* local and domain — which keep the CFWS the stripped
54
+ # forms drop — is by construction a contiguous substring of the field, so its
55
+ # offset pins the mailbox and host spans exactly even when a comment or stray
56
+ # whitespace sits inside the address. The offset is taken inside the structural
57
+ # angle-address when there is one, so a display name or comment that repeats
58
+ # the addr-spec cannot divert the recipient's replacement onto itself. Only the
59
+ # display name — never the recipient — is still located by text (outside any
60
+ # comment), since a mislocated name only misplaces a cosmetic fix. When the
61
+ # field is not a raw string (a Mail::Address handed straight to a detection
62
+ # query) there is nothing to sanitize, so spans are left nil.
63
+ def component_spans(mail_address)
64
+ local, domain, name = mail_address.local, mail_address.domain, mail_address.name
65
+ return {} unless email_address.is_a?(String)
66
+
67
+ raw_local, raw_domain = raw_addr_spec_parts
68
+ addr = "#{raw_local}@#{raw_domain}" if raw_local && raw_domain
69
+ at = addr_offset(addr) if addr
70
+
71
+ if at
72
+ { name: (name_span(name, avoid: [ at, addr.length ]) if name),
73
+ local: (local_span(at, raw_local) if local),
74
+ domain: ([ at + raw_local.length + 1, raw_domain.length ] if domain) }
75
+ else
76
+ structural_spans(local, domain, name)
77
+ end
78
+ end
79
+
80
+ # The raw local and domain of the addr-spec as they appear in the field, CFWS
81
+ # included, from the address parser — or nils when it cannot supply them.
82
+ def raw_addr_spec_parts
83
+ [ parsed_address&.local, parsed_address&.domain ]
84
+ end
85
+
86
+ def parsed_address
87
+ return @parsed_address if defined?(@parsed_address)
88
+ @parsed_address = Mail::Parsers::AddressListsParser.parse(email_address).addresses.first
89
+ rescue StandardError
90
+ @parsed_address = nil
91
+ end
92
+
93
+ # The mailbox. Mail::Address#local keeps an obsolete route ("@relay:" in
94
+ # "<@relay:local@domain>") in front of it; the route is a list of domains,
95
+ # not part of the mailbox, so it is set aside here and its domains are
96
+ # checked as domains (see #route_domains). Each part is then punycoded on its
97
+ # own, and the route's "@", "," and ":" stay as they are.
98
+ #
99
+ # Comments are set aside too: they are not part of the mailbox, and the
100
+ # parser may take the display name from one ("<local(Name)@host>"), so the
101
+ # name's replacement must not change the text the mailbox is matched by.
102
+ def mailbox(mail_address)
103
+ local, route = mail_address.local, parsed_address&.obs_domain_list
104
+ local = local.delete_prefix(route) if route.present? && local
105
+ without_comments(local)&.strip
106
+ end
107
+
108
+ # The text with its comments removed, honouring nesting, quoted strings and
109
+ # backslash escapes, in one pass.
110
+ def without_comments(text)
111
+ return text unless text&.include?("(")
112
+
113
+ bare, depth, quoted, escaped = +"", 0, false, false
114
+ text.each_char do |char|
115
+ commented = depth > 0
116
+ if escaped
117
+ escaped = false
118
+ elsif char == "\\" && (commented || quoted)
119
+ escaped = true
120
+ elsif commented
121
+ depth += 1 if char == "("
122
+ depth -= 1 if char == ")"
123
+ elsif quoted
124
+ quoted = false if char == '"'
125
+ elsif char == '"'
126
+ quoted = true
127
+ elsif char == "("
128
+ depth, commented = 1, true
129
+ end
130
+ bare << char unless commented
131
+ end
132
+ bare
133
+ end
134
+
135
+ # Each domain of an obsolete route, with its span in the field: the route
136
+ # sits inside the structural angle-address, before the addr-spec.
137
+ def route_domains
138
+ route = parsed_address&.obs_domain_list
139
+ return [] unless email_address.is_a?(String) && route.present?
140
+
141
+ lt, gt = angle_address
142
+ start = structural_index(route, from: lt + 1) if lt
143
+ return [] unless start && start + route.length <= gt
144
+
145
+ route_entries(route).filter_map do |domain, offset, length|
146
+ [ domain, [ start + offset, length ] ] if domain.present? && !domain.start_with?("[")
147
+ end
148
+ end
149
+
150
+ # Split a route ("@a.example, @b.example:") into its domains in one pass, on
151
+ # each "," or ":" outside comments and domain literals ("@[IPv6:2001:db8::1]"
152
+ # is one entry; a literal is no DNS name, so it is never checked as one).
153
+ # Each entry's span starts after its "@";
154
+ # its domain is checked with comments and whitespace set aside (the obsolete
155
+ # syntax allows both between labels), while the sanitizer, which also sets
156
+ # them aside per label, rewrites only the labels within the span.
157
+ def route_entries(route)
158
+ entries = []
159
+ bare, at, entry_start, depth, escaped, literal = +"", nil, 0, 0, false, false
160
+
161
+ route.each_char.with_index do |char, index|
162
+ commented = depth > 0
163
+ if escaped
164
+ escaped = false
165
+ elsif (commented || literal) && char == "\\"
166
+ escaped = true
167
+ elsif commented
168
+ depth += 1 if char == "("
169
+ depth -= 1 if char == ")"
170
+ elsif literal
171
+ literal = false if char == "]"
172
+ elsif char == "("
173
+ depth, commented = 1, true
174
+ elsif char == "["
175
+ literal = true
176
+ elsif char == "," || char == ":"
177
+ entries << [ bare.gsub(/\s/, ""), at || entry_start, index - (at || entry_start) ]
178
+ bare, at, entry_start = +"", nil, index + 1
179
+ next
180
+ elsif char == "@" && at.nil?
181
+ at = index + 1
182
+ next
183
+ end
184
+ bare << char unless commented || at.nil?
185
+ end
186
+
187
+ entries << [ bare.gsub(/\s/, ""), at || entry_start, route.length - (at || entry_start) ]
188
+ end
189
+
190
+ def local_span(at, raw_local)
191
+ [ at, raw_local.length ]
192
+ end
193
+
194
+ # The display name's span. When the name the parser reports is not in the
195
+ # field as written (it unescapes quoted pairs, so "\\x" reads as "x"), there
196
+ # is no text to rewrite safely: its span is empty, so the detection is still
197
+ # reported but the field is left as is rather than rewritten across an
198
+ # escape or onto the mailbox or host.
199
+ def name_span(name, avoid: nil)
200
+ locate(name, avoid:) || [ 0, 0 ]
201
+ end
202
+
203
+ # The addr-spec's offset in the field. When the field has a structural
204
+ # angle-address — its "<" outside every quoted string and comment, since a
205
+ # quoted display name or a comment may itself spell out "<local@domain>" — the
206
+ # addr-spec is searched for inside it rather than matched as "<addr>", because
207
+ # an obsolete route ("<@relay:local@domain>") may precede it; a copy of the
208
+ # addr-spec anywhere else in the field is never the recipient. Without an
209
+ # angle-address, the first bare occurrence outside any comment.
210
+ def addr_offset(addr)
211
+ lt, gt = angle_address
212
+ if lt
213
+ at = structural_index(addr, from: lt + 1)
214
+ at if at && at + addr.length <= gt
215
+ else
216
+ structural_index(addr)
217
+ end
218
+ end
219
+
220
+ # The offsets of the "<" and ">" of the structural angle-address, or nil when
221
+ # the field has none.
222
+ def angle_address
223
+ lt = structural_index("<")
224
+ gt = structural_index(">", from: lt) if lt
225
+ [ lt, gt ] if lt && gt
226
+ end
227
+
228
+ # The first occurrence of `needle` at or after `from` that starts outside
229
+ # every quoted string, domain literal and comment in the field.
230
+ def structural_index(needle, from: 0)
231
+ enclosed, commented, _unsearchable = enclosures
232
+ while (at = index_in_field(needle, from))
233
+ return at unless enclosed[at] || commented[at]
234
+ from = at + 1
235
+ end
236
+ nil
237
+ end
238
+
239
+ # Which character offsets of the field sit inside a quoted string or a domain
240
+ # literal, and which inside a (nestable) comment. Each opening delimiter is
241
+ # outside its own enclosure, so a needle may begin with one ('"john"@host');
242
+ # the closing delimiter is inside. Backslash escapes are honored in all
243
+ # three, and inside a comment neither quotes nor literals have structure —
244
+ # only nesting parentheses do. The third table marks what a replacement must
245
+ # never include: quoted pairs, and every parenthesis inside a comment (a
246
+ # nested comment's delimiters and the closing one).
247
+ def enclosures
248
+ @enclosures ||= begin
249
+ length = email_address.length
250
+ enclosed, commented, unsearchable = Array.new(length, false), Array.new(length, false), Array.new(length, false)
251
+ closer, depth, escaped = nil, 0, false
252
+ email_address.each_char.with_index do |char, i|
253
+ enclosed[i], commented[i] = !closer.nil?, depth > 0
254
+ if escaped
255
+ escaped = false
256
+ unsearchable[i] = true
257
+ elsif char == "\\" && (closer || depth > 0)
258
+ escaped = true
259
+ unsearchable[i] = true
260
+ elsif closer
261
+ closer = nil if char == closer
262
+ elsif depth > 0
263
+ depth += 1 if char == "("
264
+ depth -= 1 if char == ")"
265
+ unsearchable[i] = true if char == "(" || char == ")"
266
+ elsif char == '"'
267
+ closer = '"'
268
+ elsif char == "["
269
+ closer = "]"
270
+ elsif char == "("
271
+ depth = 1
272
+ end
273
+ end
274
+ [ enclosed, commented, unsearchable ]
275
+ end
276
+ end
277
+
278
+ # Fallback when the parser yields no raw addr-spec: bound the components by the
279
+ # structural delimiters instead. The addr-spec sits between the angle brackets
280
+ # when present (split at its last "@", past any obsolete route), otherwise it
281
+ # is the whole field, split at the first "@".
282
+ def structural_spans(local, domain, name)
283
+ lt, gt = angle_address
284
+ at = lt ? last_structural_at(lt, gt) : structural_index("@")
285
+
286
+ if lt && at && at < gt
287
+ { name: (name_span(name, avoid: [ lt, gt - lt + 1 ]) if name),
288
+ local: ([ lt + 1, at - lt - 1 ] if local),
289
+ domain: ([ at + 1, gt - at - 1 ] if domain) }
290
+ elsif at
291
+ { name: (name_span(name) if name),
292
+ local: ([ 0, at ] if local),
293
+ domain: (bare_domain_span(at, domain) if domain) }
294
+ else
295
+ {}
296
+ end
297
+ end
298
+
299
+ # Everything after the "@" — unless the domain the parser reports carries an
300
+ # "@" itself, which it does when it folds the mailbox into the domain
301
+ # ("а(comment).b@(note)safe.com"). The boundary between the parts is then
302
+ # unknown, so the domain spans the whole field, as every detection did
303
+ # before spans existed.
304
+ def bare_domain_span(at, domain)
305
+ [ at + 1, char_count - at - 1 ] unless domain.include?("@")
306
+ end
307
+
308
+ # The addr-spec's "@" inside the angle-address: the last structural one, since
309
+ # an obsolete route ("<@relay:local@domain>") puts its own before it.
310
+ def last_structural_at(lt, gt)
311
+ last = nil
312
+ while (at = structural_index("@", from: (last || lt) + 1)) && at < gt
313
+ last = at
314
+ end
315
+ last
316
+ end
317
+
318
+ # The first occurrence of `text` in the field at or after `from` that lies
319
+ # wholly outside every comment and outside `avoid` (the addr-spec, when
320
+ # locating a display name that shares a spelling with the mailbox or host);
321
+ # leading CFWS may repeat a display name. Failing that, the first occurrence
322
+ # wholly inside one comment: the parser takes a display name from a trailing
323
+ # comment ("user@host (Name)"), which its raw domain token also carries, and
324
+ # a comment is never the mailbox or host even when it sits inside `avoid`.
325
+ # An occurrence that crosses a comment's edge is neither, so it never sends
326
+ # a display name's replacement into the mailbox.
327
+ def locate(text, from: 0, avoid: nil)
328
+ outside = comment_runs(commented: false).flat_map { |range| outside_of(range, avoid) }
329
+ search_ranges(text, outside, from:) || search_ranges(text, comment_runs(commented: true), from:)
330
+ end
331
+
332
+ # The maximal [start, finish) runs of characters inside (or outside)
333
+ # comments, without the parentheses that open and close each comment or any
334
+ # comment nested in it, and without quoted pairs ("\\(" and the like), so a
335
+ # display name's replacement can never move a comment's edge or separate an
336
+ # escape from what it escapes.
337
+ # The parser unescapes quoted pairs in the name it reports, so a name that
338
+ # carries one is not in the field as written anyway.
339
+ def comment_runs(commented:)
340
+ @comment_runs ||= begin
341
+ char_to_byte, _byte_to_char = offset_tables
342
+ delimiter = ->(index) { field_bytes.getbyte(char_to_byte[index]) }
343
+ _enclosed, inside_comment, unsearchable = enclosures
344
+ keys = inside_comment.each_with_index.map { |inside, index| unsearchable[index] ? :unsearchable : inside }
345
+ keys.each_with_index.chunk_while { |(a, _), (b, _)| a == b }.filter_map do |run|
346
+ inside, start, finish = run.first.first, run.first.last, run.last.last + 1
347
+ next if inside == :unsearchable
348
+
349
+ finish -= 1 if !inside && finish < char_to_byte.length - 1 && delimiter.(finish - 1) == "(".ord
350
+ [ inside, start, finish ]
351
+ end
352
+ end
353
+ @comment_runs.filter_map { |inside, start, finish| [ start, finish ] if inside == commented && start < finish }
354
+ end
355
+
356
+ def outside_of((start, finish), avoid)
357
+ return [ [ start, finish ] ] unless avoid
358
+
359
+ [ [ start, [ finish, avoid[0] ].min ], [ [ start, avoid[0] + avoid[1] ].max, finish ] ].reject { |a, b| a >= b }
360
+ end
361
+
362
+ # The first occurrence of `text` wholly inside one of `ranges`, searching
363
+ # each range once, so the cost stays linear however often `text` overlaps
364
+ # itself.
365
+ def search_ranges(text, ranges, from:)
366
+ char_to_byte, byte_to_char = offset_tables
367
+ needle, length = text.b, text.length
368
+ ranges.each do |start, finish|
369
+ start = [ start, from ].max
370
+ next if finish - start < length
371
+
372
+ at = field_bytes.byteslice(char_to_byte[start]...char_to_byte[finish]).index(needle)
373
+ return [ byte_to_char[char_to_byte[start] + at], length ] if at
374
+ end
375
+ nil
376
+ end
377
+
378
+ # The first occurrence of `needle` at or after character offset `from`.
379
+ # String#index converts a character offset by walking a multibyte string
380
+ # from its start on every call, so a loop of searches over a long field turns
381
+ # quadratic. Search the field's bytes instead, where an offset costs nothing
382
+ # and a UTF-8 match always starts on a character boundary, and map between
383
+ # bytes and characters through tables built once.
384
+ def index_in_field(needle, from)
385
+ char_to_byte, byte_to_char = offset_tables
386
+ return nil if from >= char_to_byte.length
387
+
388
+ at = field_bytes.index(needle.b, char_to_byte[from])
389
+ byte_to_char[at] if at
390
+ end
391
+
392
+ def char_count
393
+ offset_tables.first.length - 1
394
+ end
395
+
396
+ def field_bytes
397
+ @field_bytes ||= email_address.b
398
+ end
399
+
400
+ def offset_tables
401
+ @offset_tables ||= begin
402
+ char_to_byte, byte_to_char = [], []
403
+ email_address.each_char.with_index do |char, index|
404
+ char_to_byte << byte_to_char.length
405
+ char.bytesize.times { byte_to_char << index }
406
+ end
407
+ char_to_byte << byte_to_char.length
408
+ byte_to_char << email_address.length
409
+ [ char_to_byte, byte_to_char ]
410
+ end
411
+ end
412
+
413
+
37
414
  def mail_address_wrap(email_address)
38
415
  email_address.is_a?(Mail::Address) ? email_address : Mail::Address.new(email_address)
39
416
  end
@@ -16,6 +16,7 @@ class HomographicSpoofing::Detector::Idn
16
16
  end
17
17
 
18
18
  def initialize(domain)
19
+ @original_domain = domain
19
20
  @domain = domain.downcase
20
21
  end
21
22
 
@@ -25,15 +26,42 @@ class HomographicSpoofing::Detector::Idn
25
26
 
26
27
  def detections
27
28
  rules.select(&:attack_detected?).map do |rule|
28
- HomographicSpoofing::Detector::Detection.new(rule.reason, rule.label)
29
+ HomographicSpoofing::Detector::Detection.new(rule.reason, original_case(rule.label, rule.occurrence))
29
30
  end
30
31
  rescue PublicSuffix::Error
31
32
  # Invalid IDN is a spoof.
32
- [ HomographicSpoofing::Detector::Detection.new("invalid_domain", domain) ]
33
+ [ HomographicSpoofing::Detector::Detection.new("invalid_domain", original_domain) ]
33
34
  end
34
35
 
35
36
  private
36
- attr_reader :domain
37
+ attr_reader :domain, :original_domain
38
+
39
+ # Detection runs on the lowercased domain, so labels come back lowercased.
40
+ # Recover the original-cased spelling of the label at its own position, so
41
+ # the sanitizer can substitute by exact match instead of regexp case folding,
42
+ # which both over-matches (folds unrelated ASCII, e.g. ſ/s) and under-matches
43
+ # (misses case pairs folding omits, e.g. Ⱥ/ⱥ). A label repeated in the domain
44
+ # resolves to the casing of its own occurrence, counted left to right.
45
+ def original_case(label, occurrence = 0)
46
+ original_labels.fetch(label, [])[occurrence] || label
47
+ end
48
+
49
+ # Every whole label of the raw domain, in its original casing, grouped by its
50
+ # lowercased form in left-to-right order. Labels are bounded by ".", never by
51
+ # a match inside a longer sibling ("з" inside "магаЗин"), and keep any
52
+ # whitespace inside them as PublicSuffix does; only the whitespace it strips
53
+ # from the ends of the domain is set aside. Built in one pass, so resolving
54
+ # every detection stays linear in the domain's length.
55
+ def original_labels
56
+ @original_labels ||= begin
57
+ originals = original_domain.split(".", -1)
58
+ originals[0] = originals[0].lstrip if originals.any?
59
+ originals[-1] = originals[-1].rstrip if originals.any?
60
+ originals.each_with_object({}) do |original, labels|
61
+ (labels[original.downcase] ||= []) << original
62
+ end
63
+ end
64
+ end
37
65
 
38
66
  def rules
39
67
  @rules ||= contexts.flat_map { |ctx| rules_for(ctx) }
@@ -55,8 +83,29 @@ class HomographicSpoofing::Detector::Idn
55
83
  end
56
84
 
57
85
  def contexts
58
- [ public_suffix.sld, public_suffix.trd ].compact.map do |label|
59
- HomographicSpoofing::Detector::Rule::Idn::Context.new(label: label, tld: public_suffix.tld)
86
+ tld, tld_allowances = public_suffix.tld, {}
87
+ labels.map do |label, occurrence|
88
+ HomographicSpoofing::Detector::Rule::Idn::Context.new(label:, tld:, occurrence:, tld_allowances:)
89
+ end
90
+ end
91
+
92
+ # `trd` is the full subdomain chain ("a.b" in a.b.example.com). Split it on
93
+ # the same dot the renderer draws so each rule sees one real label rather
94
+ # than a chain. The mixed-script, confusable and digit rules are per-label:
95
+ # a combined chain both hides attacks (a benign sibling dilutes an
96
+ # all-look-alike label out of detection) and invents them (two single-script
97
+ # sibling labels look "mixed" together though each is safe on its own).
98
+ #
99
+ # Labels are kept in left-to-right domain order and paired with the index of
100
+ # their occurrence among identical labels, so a repeated label recovers the
101
+ # casing of its own position (see #original_case).
102
+ def labels
103
+ ordered = [ *public_suffix.trd&.split("."), public_suffix.sld ].compact.reject(&:empty?)
104
+ seen = Hash.new(0)
105
+ ordered.map do |label|
106
+ occurrence = seen[label]
107
+ seen[label] += 1
108
+ [ label, occurrence ]
60
109
  end
61
110
  end
62
111
 
@@ -1,5 +1,5 @@
1
1
  class HomographicSpoofing::Detector::Rule::Base
2
- delegate :scripts, :label, :label_set, to: :@context
2
+ delegate :scripts, :label, :label_set, :occurrence, to: :@context
3
3
 
4
4
  def initialize(context)
5
5
  @context = context
@@ -1,8 +1,12 @@
1
1
  class HomographicSpoofing::Detector::Rule::Context
2
- attr_reader :label
2
+ attr_reader :label, :occurrence
3
3
 
4
- def initialize(label:)
4
+ # `occurrence` disambiguates labels that repeat within a domain: it is the
5
+ # zero-based index of this label among identical labels, in left-to-right
6
+ # order, so the original casing of the right occurrence can be recovered.
7
+ def initialize(label:, occurrence: 0)
5
8
  @label = label
9
+ @occurrence = occurrence
6
10
  end
7
11
 
8
12
  SCRIPT_COMMON = "Common"
@@ -1,15 +1,22 @@
1
1
  # 3. and 4. of Google Chrome IDN policy See https://util.unicode.org/UnicodeJsps/list-unicodeset.jsp?a=%5B%3AIdentifierStatus%3DAllowed%3A&abb=on&g=&i=
2
2
  class HomographicSpoofing::Detector::Rule::DisallowedCharacters < HomographicSpoofing::Detector::Rule::Base
3
3
  class << self
4
- # See http://kb.mozillazine.org/Network.IDN.blacklist_chars
5
- MOZILLA_DISALLOWED_CHARACTERS = Set[
4
+ # Characters removed from the allowed set. Combines Mozilla's blocklist
5
+ # (http://kb.mozillazine.org/Network.IDN.blacklist_chars) with the
6
+ # removals in Chromium's IDNSpoofChecker::SetAllowedUnicodeSet
7
+ # (components/url_formatter/spoof_checks/idn_spoof_checker.cc).
8
+ DISALLOWED_CHARACTERS = Set[
6
9
  "\u0020", # Space
7
10
  "\u00a0", # No-break space
8
11
  "\u00bc", # Vulgar fraction one quarter
9
12
  "\u00bd", # Vulgar fraction one half
10
13
  "\u00be", # Vulgar fraction three quarters
14
+ "\u0138", # Latin small letter kra (Chromium)
11
15
  "\u01c3", # Latin letter retroflex click
16
+ "\u02bb", # Modifier letter turned comma (Chromium)
17
+ "\u02bc", # Modifier letter apostrophe (Chromium)
12
18
  "\u02d0", # Modifier letter triangular colon
19
+ "\u02ec", # Modifier letter voicing (Chromium)
13
20
  "\u0337", # Combining short solidus overlay
14
21
  "\u0338", # Combining long solidus overlay
15
22
  "\u0589", # Armenian full stop
@@ -124,7 +131,7 @@ class HomographicSpoofing::Detector::Rule::DisallowedCharacters < HomographicSpo
124
131
  ]
125
132
 
126
133
  def allowed_chars_set
127
- @@allowed_chars_set ||= (read_allowed_idn_chars.chars.to_set - MOZILLA_DISALLOWED_CHARACTERS)
134
+ @@allowed_chars_set ||= (read_allowed_idn_chars.chars.to_set - DISALLOWED_CHARACTERS)
128
135
  end
129
136
 
130
137
  private
@@ -1,3 +1,3 @@
1
1
  class HomographicSpoofing::Detector::Rule::Idn::Base < HomographicSpoofing::Detector::Rule::Base
2
- delegate :tld, to: :@context
2
+ delegate :tld, :tld_allowances, to: :@context
3
3
  end
@@ -1,8 +1,14 @@
1
1
  class HomographicSpoofing::Detector::Rule::Idn::Context < HomographicSpoofing::Detector::Rule::Context
2
- attr_reader :tld
2
+ attr_reader :tld, :tld_allowances
3
3
 
4
- def initialize(label:, tld:)
4
+ # `tld_allowances` is shared by every label of one domain, so a decision that
5
+ # depends only on the TLD is made once per domain rather than once per label.
6
+ # Under a wildcard suffix such as "*.mm" the TLD carries a label the sender
7
+ # chose, and scanning it again for every label would cost the label count
8
+ # times its length.
9
+ def initialize(label:, tld:, occurrence: 0, tld_allowances: {})
5
10
  @tld = tld
6
- super(label:)
11
+ @tld_allowances = tld_allowances
12
+ super(label:, occurrence:)
7
13
  end
8
14
  end
@@ -16,6 +16,13 @@ class HomographicSpoofing::Detector::Rule::Idn::DangerousPattern < HomographicSp
16
16
  [\u30ce\u30f3\u30bd\u30be\u4e36\u4e40\u4e41\u4e3f]
17
17
  [^\p{kana}\p{hira}\p{hani}]/x,
18
18
 
19
+ /# Rules see one label at a time, so a label edge counts as a non-Japanese
20
+ # side too: the dot the renderer draws there is not Japanese either. Beside
21
+ # an edge, "ー" (U+30FC, Common to Unicode's Script property but Japanese in
22
+ # use) counts as Japanese, so words like "ノート" and "コーン" stay clear.
23
+ \A[\u30ce\u30f3\u30bd\u30be\u4e36\u4e40\u4e41\u4e3f](?:[^\p{kana}\p{hira}\p{hani}\u30fc]|\z)|
24
+ [^\p{kana}\p{hira}\p{hani}\u30fc][\u30ce\u30f3\u30bd\u30be\u4e36\u4e40\u4e41\u4e3f]\z/x,
25
+
19
26
  /# Disallow three Hiragana letters (U+307[8-A]) or Katakana letters
20
27
  # (U+30D[8-A]) that look exactly like each other when they're used
21
28
  # in a label otherwise entirely in Katakana or Hiragana.
@@ -49,6 +56,14 @@ class HomographicSpoofing::Detector::Rule::Idn::DangerousPattern < HomographicSp
49
56
  [\u4e00\u3127\u4e28\u4e5b\u4e03\u4e05\u5341\u3007\u3112\u311a\u311f\u3128\u3129\u3108\u31ba\u31b3\u5de5\u31b2\u8ba0\u4e01]
50
57
  [^\p{kana}\p{hira}\p{hani}\p{bopo}]/x,
51
58
 
59
+ /# Rules see one label at a time, so a label that is only one of these
60
+ # ideographs has the dots the renderer draws on both sides, and neither
61
+ # is CJK: "a.一.example.com" reads as "a.-.example.com". A longer label
62
+ # with one at its edge is already caught by the two patterns above when
63
+ # its other neighbour is non-CJK, and stays safe when it is CJK, as in
64
+ # "工行" or "一号店".
65
+ \A[一ㄧ丨乛七丅十〇ㄒㄚㄟㄨㄩㄈㆺㆳ工ㆲ讠丁]\z/x,
66
+
52
67
  /# Disallow combining diacritical mark (U+0300-U+0339) after a
53
68
  # non-LGC character. Other combining diacritical marks are not in
54
69
  # the allowed character set.
@@ -9,6 +9,6 @@ class HomographicSpoofing::Detector::Rule::Idn::InvisibleCharacters < Homographi
9
9
  /\u0e48{2,}/, # Thai tone repeated
10
10
  /\u0301{2,}/, # accute accent repeated
11
11
  /\u00e1\u0301/, # 'a' with acuted accent + another acute accent
12
- /^\u0300/, # Combining mark at the beginning
12
+ /\A\p{M}/, # Combining mark at the beginning of a label
13
13
  )
14
14
  end
@@ -46,11 +46,23 @@ class HomographicSpoofing::Detector::Rule::Idn::ScriptConfusable < HomographicSp
46
46
  [ /\p{Mymr}/, /[ခဂငထပဝ၀၂ၔၜ\u1090\u1091\u1095\u1096\u1097]/, /[a-z]+\.mm/ ],
47
47
  # # Thai
48
48
  [ /\p{Thai}/, /[ทนบพรหเแ๐ดลปฟม]/, /th/ ]
49
- ].map { Confusable.new(*_1) }
49
+ ]
50
+ # Anchor each allowed-TLD pattern so it only matches whole trailing labels
51
+ # of the TLD, never a substring of one. Unanchored, `/ru/` matched `.guru`,
52
+ # `/su/` matched `.surf`, `/by/` matched `.baby`/`.rugby`, etc., silently
53
+ # disabling single-script confusable detection on those TLDs. The
54
+ # `(?:\A|\.)…\z` boundary keeps multi-label public suffixes working —
55
+ # `co.th` still matches `/th/`, `com.mm` still matches `/[a-z]+\.mm/`.
56
+ .map do |script, latin_lookalike, allowed_tlds|
57
+ anchored_tlds = allowed_tlds && /(?:\A|\.)(?:#{allowed_tlds.source})\z/
58
+ Confusable.new script, latin_lookalike, anchored_tlds
59
+ end
50
60
 
51
61
  def is_script_confusable_allowed_for_tld?(confusable)
52
- tld_contains_any_letter_from_script?(confusable.script) ||
53
- confusable.allowed_tlds&.match?(tld)
62
+ tld_allowances.fetch(confusable) do
63
+ tld_allowances[confusable] = tld_contains_any_letter_from_script?(confusable.script) ||
64
+ confusable.allowed_tlds&.match?(tld) || false
65
+ end
54
66
  end
55
67
 
56
68
  def tld_contains_any_letter_from_script?(script)
@@ -10,19 +10,153 @@ class HomographicSpoofing::Sanitizer::Base
10
10
  end
11
11
 
12
12
  def sanitize
13
- result = field.dup
14
- detector_class.new(field).detections.each do |detection|
15
- log(detection.reason, detection.label)
16
- result = punycode(result, detection.label)
17
- end
18
- result
13
+ detections = detector_class.new(field).detections
14
+ detections.each { |detection| log(detection.reason, detection.label) }
15
+ apply(field, detections)
19
16
  end
20
17
 
21
18
  private
22
19
  attr_reader :field
23
20
 
24
- def punycode(source, label)
25
- source.gsub(label, Dnsruby::Name.punycode(label))
21
+ # Punycode each offending label only within the component it came from. A
22
+ # spanned detection carries the [offset, length] of its component, so a
23
+ # domain-label spoof is confined to the domain and never rewrites a benign
24
+ # local part that merely equals the same string — this is what keeps
25
+ # "з@мир.з.example.com" punycoding the domain label "з" while leaving the "з"
26
+ # mailbox intact. Spans are disjoint or nested (a display name read from a
27
+ # comment the host's token carries), so they form a tree: each span's text is
28
+ # rebuilt with its inner spans already rewritten, then rewritten itself, in
29
+ # one pass over the field. A detection with no span (a bare IDN or quoted
30
+ # string) spans the whole field and is applied last, over what remains.
31
+ def apply(field, detections)
32
+ return field.dup if detections.empty?
33
+
34
+ whole, spanned = detections.partition { |detection| detection.span.nil? }
35
+ edits = spanned.group_by(&:span).map do |(offset, length), group|
36
+ Edit.new(offset, offset + length, group.map(&:label).uniq, [])
37
+ end
38
+ chars = field.chars
39
+ replace_labels(splice(chars, 0, chars.length, nest(edits)), whole.map(&:label).uniq)
40
+ end
41
+
42
+ Edit = Struct.new(:start, :finish, :labels, :inner)
43
+
44
+ # Arrange spans into a tree, outer before inner, in one pass over them sorted
45
+ # by start. A span that overlaps an earlier sibling without nesting in it is
46
+ # trimmed to start where that sibling ends.
47
+ def nest(edits)
48
+ outermost, open = [], []
49
+ edits.sort_by { |edit| [ edit.start, -edit.finish ] }.each do |edit|
50
+ open.pop while open.any? && open.last.finish < edit.finish
51
+ siblings = open.empty? ? outermost : open.last.inner
52
+ edit.start = [ edit.start, siblings.last.finish ].max if siblings.any?
53
+ next if edit.start > edit.finish
54
+
55
+ siblings << edit
56
+ open << edit
57
+ end
58
+ outermost
59
+ end
60
+
61
+ def splice(chars, start, finish, edits)
62
+ text, at = +"", start
63
+ edits.each do |edit|
64
+ text << chars[at...edit.start].join << replace_labels(splice(chars, edit.start, edit.finish, edit.inner), edit.labels)
65
+ at = edit.finish
66
+ end
67
+ text << chars[at...finish].join
68
+ end
69
+
70
+ # Replace each label where it is a whole "."-delimited component of the
71
+ # region, so an offending label is never rewritten as a substring of a benign
72
+ # sibling (the Cyrillic digit-look-alike "з" that also sits inside
73
+ # "магазин"), and every matching component is punycoded from its own
74
+ # spelling — case-exact, so a spoof repeated in different casing is handled
75
+ # per occurrence. A component is compared with its CFWS (comments and
76
+ # surrounding whitespace, which the parser drops from the label it reports)
77
+ # set aside, and only the label itself is rewritten, so "з(comment)" is
78
+ # punycoded at the label and a comment beside it never sends the replacement
79
+ # into a benign sibling. A label that is only a substring of a component (a
80
+ # display name carrying spaces) has no whole component to match and falls
81
+ # back to exact substring replacement.
82
+ #
83
+ # A label that keeps whitespace inside it, as PublicSuffix reports "а " in
84
+ # "А .com", matches its component as written.
85
+ #
86
+ # The region is scanned once for all labels, so sanitizing stays linear in
87
+ # its length however many labels were detected.
88
+ def replace_labels(region, labels)
89
+ return region if labels.empty?
90
+
91
+ components = label_components(region)
92
+ present = components.to_set(&:bare) | components.map(&:text)
93
+ whole, partial = labels.partition { |label| present.include?(label) }
94
+ whole = whole.to_set
95
+
96
+ result = components.map do |component|
97
+ if whole.include?(component.text) && component.bare
98
+ Dnsruby::Name.punycode(component.text)
99
+ elsif whole.include?(component.bare)
100
+ component.punycoded
101
+ else
102
+ component.text
103
+ end
104
+ end.join
105
+ partial.inject(result) { |text, label| text.gsub(label) { Dnsruby::Name.punycode(label) } }
106
+ end
107
+
108
+ # One "."-delimited component of a region: its text, the label it carries
109
+ # with CFWS set aside (`bare`), and where that label starts in the text, past
110
+ # any leading CFWS that may repeat it. Separators are components too, with no
111
+ # label.
112
+ Component = Struct.new(:text, :bare, :start) do
113
+ def punycoded
114
+ if text[start, bare.length] == bare
115
+ text[0, start] + Dnsruby::Name.punycode(bare) + text[(start + bare.length)..]
116
+ else
117
+ text
118
+ end
119
+ end
120
+ end
121
+
122
+ # Split a region into label components on each "." outside comments and
123
+ # quoted strings, in a single pass. Comments nest and honour backslash
124
+ # escapes, as RFC 5322 has them, so a comment's own dots or parentheses
125
+ # never split or end a label early.
126
+ def label_components(region)
127
+ components = []
128
+ text, bare, start = +"", +"", nil
129
+ depth, quoted, escaped = 0, false, false
130
+
131
+ region.each_char do |char|
132
+ commented = depth > 0
133
+ if escaped
134
+ escaped = false
135
+ elsif char == "\\" && (commented || quoted)
136
+ escaped = true
137
+ elsif commented
138
+ depth += 1 if char == "("
139
+ depth -= 1 if char == ")"
140
+ elsif quoted
141
+ quoted = false if char == '"'
142
+ elsif char == "("
143
+ depth, commented = 1, true
144
+ elsif char == '"'
145
+ quoted = true
146
+ elsif char == "."
147
+ components << Component.new(text, bare.strip, start || 0) << Component.new(char, nil, 0)
148
+ text, bare, start = +"", +"", nil
149
+ next
150
+ end
151
+
152
+ unless commented
153
+ start ||= text.length unless char.match?(/\s/)
154
+ bare << char
155
+ end
156
+ text << char
157
+ end
158
+
159
+ components << Component.new(text, bare.strip, start || 0)
26
160
  end
27
161
 
28
162
  def detector_class
@@ -7,4 +7,34 @@ class HomographicSpoofing::Sanitizer::Idn < HomographicSpoofing::Sanitizer::Base
7
7
  def spoofing_type
8
8
  "EmailIDN"
9
9
  end
10
+
11
+ # A bare domain has no comments or quoted strings: the detector splits it on
12
+ # every ".", so split it the same way. Honouring "(" or '"' here would let an
13
+ # unmatched one fold the rest of the domain into one component, and every
14
+ # detected label would then fall back to a substitution over the whole field.
15
+ #
16
+ # The detector reports labels as PublicSuffix does: with the domain's own
17
+ # leading and trailing padding set aside, but any whitespace inside the
18
+ # domain kept on its label ("А " in " А .А .com"). That padding is set apart
19
+ # as components with no label, so each label component reads as its label
20
+ # was reported.
21
+ def label_components(region)
22
+ core = region.strip
23
+ leading = core.empty? ? region : region[0, region.index(core)]
24
+ trailing = region[(leading.length + core.length)..]
25
+
26
+ [ padding(leading), *core.split(/(\.)/, -1).map { |text| component(text) }, padding(trailing) ].compact
27
+ end
28
+
29
+ def component(text)
30
+ if text == "."
31
+ Component.new(text, nil, 0)
32
+ else
33
+ Component.new(text, text.strip, text.index(/\S/) || 0)
34
+ end
35
+ end
36
+
37
+ def padding(text)
38
+ Component.new(text, nil, 0) unless text.empty?
39
+ end
10
40
  end
@@ -1,3 +1,3 @@
1
1
  module HomographicSpoofing
2
- VERSION = "0.1.2"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: homographic_spoofing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jacopo Beschi
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-07-22 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: zeitwerk
@@ -108,13 +107,13 @@ dependencies:
108
107
  - - "~>"
109
108
  - !ruby/object:Gem::Version
110
109
  version: '3.3'
111
- description:
112
110
  email:
113
111
  - jacopo@37signals.com
114
112
  executables: []
115
113
  extensions: []
116
114
  extra_rdoc_files: []
117
115
  files:
116
+ - CHANGELOG.md
118
117
  - MIT-LICENSE
119
118
  - README.md
120
119
  - lib/homographic_spoofing.rb
@@ -159,7 +158,9 @@ metadata:
159
158
  allowed_push_host: https://rubygems.org
160
159
  homepage_uri: https://github.com/basecamp/homographic_spoofing
161
160
  source_code_uri: https://github.com/basecamp/homographic_spoofing
162
- post_install_message:
161
+ changelog_uri: https://github.com/basecamp/homographic_spoofing/blob/main/CHANGELOG.md
162
+ bug_tracker_uri: https://github.com/basecamp/homographic_spoofing/issues
163
+ rubygems_mfa_required: 'true'
163
164
  rdoc_options: []
164
165
  require_paths:
165
166
  - lib
@@ -174,8 +175,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
174
175
  - !ruby/object:Gem::Version
175
176
  version: '0'
176
177
  requirements: []
177
- rubygems_version: 3.5.22
178
- signing_key:
178
+ rubygems_version: 3.6.9
179
179
  specification_version: 4
180
180
  summary: A toolkit to both detect and sanitize homographic spoofing attacks in URLs
181
181
  and Email addresses