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 +4 -4
- data/lib/mailblastr/version.rb +1 -1
- data/lib/mailblastr/webhooks.rb +95 -16
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec78acb7d963058af8b922481262fcf7b3c1d6692876f304bdd373572b7a25b9
|
|
4
|
+
data.tar.gz: 29f2b545a15086968ad83e8f6e687868d9d4c575e292839852c540a797897d1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3d399fe4c0db84908d49c0aa590dadc869ef661dabf0a9b3efea01ca106ec4a36bb5050d1ac760e5bd875ebaa85117750b9d8df8607f069a303391bc4763402
|
|
7
|
+
data.tar.gz: 8be077ef5c686252a518edf896002f48af3e6ac07450b1fbd3f18cf3fdf252cb05b05d186e502d342b8a00a8e40d67ec2ee9fca11fc74889527720810d61dbfa
|
data/lib/mailblastr/version.rb
CHANGED
data/lib/mailblastr/webhooks.rb
CHANGED
|
@@ -149,25 +149,104 @@ module Mailblastr
|
|
|
149
149
|
nil
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
-
# Derive the HMAC key from a `whsec_`-prefixed secret
|
|
153
|
-
#
|
|
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
|
-
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
#
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-08-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|