mailblastr 5.0.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53c08e2365d3814e563987d9625310f3cf42ed6d8aa5f1869b1603472dc72318
4
- data.tar.gz: 0f0f31d046c3e508ed8a6812f79c1f8c502ec1354f7ac3885636bd4e8c5e9eb3
3
+ metadata.gz: ec78acb7d963058af8b922481262fcf7b3c1d6692876f304bdd373572b7a25b9
4
+ data.tar.gz: 29f2b545a15086968ad83e8f6e687868d9d4c575e292839852c540a797897d1d
5
5
  SHA512:
6
- metadata.gz: 1e0eb93701e40a42117102a427d80b7d3003669515e31d3f58d96ad46680993a9989e98e205227d434e7227a7fead48e16e2bc541491920fe30104177fab8059
7
- data.tar.gz: 219ab57ee04faaf8817f98711a46887b87686c40df79189fd3b75d9076f804bcf579d76a3b126d21d714f329453936fcf548733c00a1636d0f30d1f6c2e31a84
6
+ metadata.gz: d3d399fe4c0db84908d49c0aa590dadc869ef661dabf0a9b3efea01ca106ec4a36bb5050d1ac760e5bd875ebaa85117750b9d8df8607f069a303391bc4763402
7
+ data.tar.gz: 8be077ef5c686252a518edf896002f48af3e6ac07450b1fbd3f18cf3fdf252cb05b05d186e502d342b8a00a8e40d67ec2ee9fca11fc74889527720810d61dbfa
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailblastr
4
- VERSION = "5.0.0"
4
+ VERSION = "5.0.1"
5
5
  end
@@ -149,25 +149,104 @@ module Mailblastr
149
149
  nil
150
150
  end
151
151
 
152
- # Derive the HMAC key from a `whsec_`-prefixed secret (base64-decode the
153
- # suffix); a secret without the prefix is used as raw UTF-8 bytes.
152
+ # Derive the HMAC key from a `whsec_`-prefixed secret the way the SIGNER
153
+ # does, byte for byte. The signer is Node: base64-decode the suffix with
154
+ # `Buffer.from(suffix, 'base64')` and, when that yields ZERO bytes, fall
155
+ # back to the UTF-8 bytes of the WHOLE secret — `whsec_` prefix INCLUDED
156
+ # (mailblastr_webapp/lib/crypto.ts secretToKey). A secret without the
157
+ # prefix is used as raw UTF-8 bytes.
154
158
  #
155
- # `tr` FIRST: the signer decodes with Node's Buffer.from(suffix, 'base64')
156
- # (lib/crypto.ts secretToKey), which reads "-"/"_" as the URL-safe
157
- # spellings of "+"/"/". `unpack1("m")` tolerates missing padding but
158
- # DROPS those two characters instead of translating them, yielding a
159
- # SHORTER, different key than the signer derived so a caller-supplied
160
- # URL-safe secret (POST /webhooks accepts `secret` verbatim, unvalidated)
161
- # made every genuinely signed delivery come back `no_match`. Standard,
162
- # padded and unpadded suffixes are unaffected by the `tr`, which is why
163
- # the failure looked arbitrary and secret-specific.
159
+ # The zero-byte fallback is not a curiosity: POST /webhooks stores
160
+ # `secret` verbatim with no shape validation, so "whsec_", "whsec_=",
161
+ # "whsec_!!!!" and "whsec_=YWJj" are all secrets a customer can really
162
+ # create, and each one keys the HMAC with its own literal text. Keying
163
+ # with an empty string instead — the obvious reading of "decode, then
164
+ # use the result" costs the whole endpoint silently, because a key
165
+ # that differs from the signer's does not fail loudly: verify answers
166
+ # `no_match` and a correctly configured endpoint treats every genuine
167
+ # delivery as forged.
164
168
  def secret_to_key(secret)
165
169
  s = secret.to_s
166
- if s.start_with?("whsec_")
167
- decoded = s["whsec_".length..].tr("-_", "+/").unpack1("m")
168
- return decoded if decoded && !decoded.empty?
169
- end
170
- s
170
+ return s unless s.start_with?("whsec_")
171
+
172
+ decoded = node_base64_decode(s["whsec_".length..])
173
+ decoded.empty? ? s : decoded
174
+ end
175
+
176
+ # Node's `Buffer.from(str, 'base64')`, reproduced. Every rule here was
177
+ # read off Node itself, NOT off a base64 RFC — following the RFC is
178
+ # precisely how this shipped broken twice — and each one costs a real
179
+ # key when Ruby's own decoder is trusted instead:
180
+ #
181
+ # * "=" TERMINATES the input. Everything from the first one onward is
182
+ # DISCARDED; it is not "padding to be stripped". "YWJj====ZA" is
183
+ # "abc", NOT "abcd", and a leading "=" leaves nothing at all (so the
184
+ # caller's raw fallback takes over). `unpack1("m")` decodes straight
185
+ # past an interior "=", deriving a LONGER key than the signer's.
186
+ # * "-" and "_" are the URL-safe spellings of "+" and "/" and must be
187
+ # TRANSLATED. `unpack1("m")` silently DROPS them, shortening the key.
188
+ # * Any other out-of-alphabet byte is SKIPPED, never fatal: whitespace,
189
+ # punctuation and non-ASCII are ignored, so "YW!Jj" is "abc".
190
+ # * A trailing group of ONE character carries no whole byte, so it is
191
+ # dropped here rather than left to `unpack1`'s discretion (2 chars ->
192
+ # 1 byte, 3 -> 2, 4 -> 3).
193
+ # * The unit Node indexes the alphabet with is the LOW 8 BITS OF EACH
194
+ # UTF-16 CODE UNIT, applied FIRST — see `utf16_low_bytes` below.
195
+ #
196
+ # Bytes, not characters, from the mask onward: a caller's secret need not
197
+ # be valid UTF-8, and an Encoding::CompatibilityError escaping this method
198
+ # would turn a webhook controller's 401 into a 500.
199
+ def node_base64_decode(suffix)
200
+ # Rule 5 runs BEFORE the "=" split, not after: U+013D masks to 0x3D and
201
+ # must TERMINATE the input like a literal "=" — splitting on the
202
+ # unmasked text would decode straight past it and derive a longer key.
203
+ s = utf16_low_bytes(suffix)
204
+ terminator = s.index("=")
205
+ s = s[0, terminator] if terminator
206
+
207
+ chars = s.tr("-_", "+/").gsub(%r{[^A-Za-z0-9+/]}n, "")
208
+ chars = chars[0, chars.bytesize - 1] if (chars.bytesize % 4) == 1
209
+ return "" if chars.empty?
210
+
211
+ chars.unpack1("m") || ""
212
+ end
213
+
214
+ # Rule 5, the one no SDK had: Node masks every UTF-16 CODE UNIT with 0xFF
215
+ # before the base64 table lookup, so the alphabet is indexed by a code
216
+ # unit's low byte, NOT by the codepoint and NOT by the UTF-8 bytes.
217
+ #
218
+ # Ruby strings are UTF-8, so the code units have to be materialised first:
219
+ # "Ł" (U+0141) is two UTF-8 bytes but ONE code unit masking to 0x41 "A",
220
+ # and "𝑁" (U+1D441) is ASTRAL — its four UTF-8 bytes are Node's TWO
221
+ # surrogate halves 0xD835/0xDC41, masking to 0x35 "5" and 0x41 "A". Taking
222
+ # UTF-8 bytes instead feeds the decoder continuation bytes that are all
223
+ # out-of-alphabet, silently shortening the key.
224
+ #
225
+ # Every codepoint below 0x100 masks to itself and every one at or above it
226
+ # is a different character entirely, so nothing under 0x100 can expose
227
+ # this — which is exactly why it survived a 31-vector corpus and a
228
+ # 2000-case ASCII fuzz, and why the cost lands only on the customer whose
229
+ # secret happens to carry a "Ł", a fullwidth letter or an emoji: their
230
+ # endpoint answers `no_match` to every genuine delivery.
231
+ #
232
+ # Undecodable input never raises out of here: invalid bytes become U+FFFD,
233
+ # whose low byte 0xFD is out of alphabet and therefore skipped — the same
234
+ # answer the old byte-wise reader gave — and a total conversion failure
235
+ # falls back to the raw bytes rather than 500-ing a webhook controller.
236
+ def utf16_low_bytes(str)
237
+ s = str.to_s
238
+ s = s.dup.force_encoding(Encoding::UTF_8) if s.encoding == Encoding::BINARY
239
+ s.encode(Encoding::UTF_16LE, invalid: :replace, undef: :replace)
240
+ .b.unpack("v*").map { |unit| unit & 0xFF }.pack("C*")
241
+ rescue StandardError
242
+ # Unreachable in practice: invalid:/undef: :replace make the encode total
243
+ # for any String. Kept so a webhook controller cannot 500 on a pathological
244
+ # input -- but it must NOT return str.b. Those are the raw UTF-8 bytes,
245
+ # which is exactly the pre-5.0.1 behaviour this method exists to replace,
246
+ # and returning them would silently derive a WRONG key that verifies
247
+ # nothing. Empty routes to the documented whole-secret fallback instead:
248
+ # still a mismatch, but a defined one rather than a reinstated bug.
249
+ ""
171
250
  end
172
251
 
173
252
  # Constant-time compare of two signature strings.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mailblastr
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.0
4
+ version: 5.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - MailBlastr
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-19 00:00:00.000000000 Z
11
+ date: 2026-08-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest