radioactive 0.1.1 → 0.1.2
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
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +36 -1
- data/README.md +12 -0
- data/lib/radioactive/fetcher.rb +79 -31
- data/lib/radioactive/version.rb +1 -1
- data/sig/radioactive.rbs +4 -2
- data.tar.gz.sig +0 -0
- metadata +1 -1
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c3fafb950586fcfeabd5b472d69d2d039011c4e7f33d94bfee8edff8b8a42b91
|
|
4
|
+
data.tar.gz: e2785593bc12e88e6ece524df9dab17dd85edfbb557c884d57621d80c996b950
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 55759e34aa89a393659815cb995c26e2299da4c01a19da7e0ffac8bef9aac9d9f55bc6d60321279f275f98079e79500f187a26a3622d8dd18e144b13bbd333c0
|
|
7
|
+
data.tar.gz: ecdcfa862aec8714e722ae6a69ba996dc058dd2f8a6684d65cf9cf12bc8930f43be4284ea10b70dcd0c6880c8db8a05e8f595a4b00e518c158292bcab5554b1b
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,42 @@ All notable changes to this project will be documented in this file. The format
|
|
|
4
4
|
is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [Released]
|
|
8
|
+
|
|
9
|
+
## [0.1.2] - 2026-05-06
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Dual-stack hosts whose resolver returns AAAA before A on a network without
|
|
14
|
+
IPv6 reachability no longer surface `ResponseError: transport error:
|
|
15
|
+
Errno::EHOSTUNREACH`. `Fetcher#perform_request` now iterates over the full
|
|
16
|
+
pre-validated candidate list returned by `pin_addresses`, advancing to the
|
|
17
|
+
next address on connect-phase transport errors (`Errno::EHOSTUNREACH`,
|
|
18
|
+
`Errno::ENETUNREACH`, `Errno::ECONNREFUSED`, `Net::OpenTimeout`). Errors
|
|
19
|
+
raised after a connection is established (TLS handshake failure, read
|
|
20
|
+
timeout, post-connect `ECONNRESET`, non-2xx response) do **not** trigger
|
|
21
|
+
fallback — silently retrying against a different IP would mask real
|
|
22
|
+
problems.
|
|
23
|
+
|
|
24
|
+
### Security
|
|
25
|
+
|
|
26
|
+
- Strict dual-A SSRF rejection is preserved end-to-end. Validation runs
|
|
27
|
+
across the entire resolved address list before any socket opens, so the
|
|
28
|
+
fallback added above cannot weaken the "if any resolved address is in a
|
|
29
|
+
forbidden range, refuse the request" rule. A new HTTP-level test
|
|
30
|
+
exercises the dual-stack-with-one-forbidden-address case to lock this in.
|
|
31
|
+
- Caller-supplied header validation now provably runs before any socket
|
|
32
|
+
attempt across the candidate list (regression-tested via the existing
|
|
33
|
+
CRLF / NUL header tests, which previously passed only because the single
|
|
34
|
+
pinned IP refused the connection first).
|
|
35
|
+
|
|
36
|
+
### Documentation
|
|
37
|
+
|
|
38
|
+
- `docs/REQUIREMENTS.md` §175 ("DNS resolution and IP pinning") rewritten:
|
|
39
|
+
step 4 now describes a candidate list rather than "the first remaining
|
|
40
|
+
address," and a new step 6 specifies the connect-phase fallback
|
|
41
|
+
semantics, what does *not* trigger fallback, and the exhaustion-error
|
|
42
|
+
shape (`TimeoutError` for `Net::OpenTimeout`, otherwise `ResponseError`).
|
|
8
43
|
|
|
9
44
|
## [0.1.1] - 2026-05-04
|
|
10
45
|
|
data/README.md
CHANGED
|
@@ -455,6 +455,18 @@ If anything fails midway (e.g. push rejected, MFA timeout), the tag may already
|
|
|
455
455
|
- Bump `lib/radioactive/version.rb` to the next anticipated version with a `.dev` or `.alpha` suffix if you want subsequent local builds to be distinguishable from the release.
|
|
456
456
|
- Push that commit; future changes accumulate under `[Unreleased]` until the next release.
|
|
457
457
|
|
|
458
|
+
### Release Cheatsheet
|
|
459
|
+
|
|
460
|
+
1. Edit CHANGELOG.md — add the [0.1.2] - 2026-05-06 section
|
|
461
|
+
2. Edit lib/radioactive/version.rb — "0.1.1" → "0.1.2"
|
|
462
|
+
3. Run bundle exec rake — confirm tests + lint + types still green
|
|
463
|
+
4. Run git status — sanity check what's about to be committed
|
|
464
|
+
5. Run git add -A && git commit — single commit covering fix + version + changelog
|
|
465
|
+
6. Run git push origin main — gets the commit on the remote before the tag
|
|
466
|
+
7. Run bundle exec rake gem:release — guard_clean → build → tag → push → publish (MFA)
|
|
467
|
+
8. Edit lib/radioactive/version.rb — bump to 0.1.3.dev (post-release commit)
|
|
468
|
+
9. Run git add ... && git commit && git push
|
|
469
|
+
|
|
458
470
|
### Stronger publishing setup (optional)
|
|
459
471
|
|
|
460
472
|
For a security-focused gem, consider [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) instead of pushing from a developer machine: a tagged commit triggers a GitHub Actions workflow that authenticates to rubygems via OIDC and publishes without any long-lived API key on disk. Removes the "stolen laptop = compromised gem" risk and complements the MFA requirement.
|
data/lib/radioactive/fetcher.rb
CHANGED
|
@@ -15,6 +15,18 @@ module Radioactive
|
|
|
15
15
|
CHUNK_SIZE = 16 * 1024
|
|
16
16
|
DEFAULT_USER_AGENT = "Radioactive/#{Radioactive::VERSION}"
|
|
17
17
|
|
|
18
|
+
# Connect-phase transport failures that justify trying the next pinned
|
|
19
|
+
# candidate. Anything else (TLS handshake failure, read timeout,
|
|
20
|
+
# post-connect ECONNRESET) is terminal — the server engaged with us, so
|
|
21
|
+
# silently retrying against a different IP would mask real problems and
|
|
22
|
+
# risks duplicating side-effecting requests in a future non-GET world.
|
|
23
|
+
CONNECT_FALLBACK_ERRORS = [
|
|
24
|
+
Errno::EHOSTUNREACH,
|
|
25
|
+
Errno::ENETUNREACH,
|
|
26
|
+
Errno::ECONNREFUSED,
|
|
27
|
+
Net::OpenTimeout
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
18
30
|
# Single-label hosts that are entirely digits or 0x-prefix hex are not
|
|
19
31
|
# valid RFC 1123 hostnames; they're SSRF-bypass attempts that some libc
|
|
20
32
|
# getaddrinfo implementations historically resolved as IPs.
|
|
@@ -110,8 +122,8 @@ module Radioactive
|
|
|
110
122
|
loop do
|
|
111
123
|
check_deadline(deadline, clock)
|
|
112
124
|
|
|
113
|
-
|
|
114
|
-
kind, status, headers, body = perform_request(current,
|
|
125
|
+
ips = pin_addresses(current, resolver, opts)
|
|
126
|
+
kind, status, headers, body = perform_request(current, ips, opts, deadline, clock, &chunk_block)
|
|
115
127
|
|
|
116
128
|
case kind
|
|
117
129
|
when :redirect
|
|
@@ -170,7 +182,12 @@ module Radioactive
|
|
|
170
182
|
host
|
|
171
183
|
end
|
|
172
184
|
|
|
173
|
-
|
|
185
|
+
# Resolve the host once and validate every address against the forbidden
|
|
186
|
+
# ranges before returning. Returns the full list in resolver order so the
|
|
187
|
+
# connect loop can fall back across dual-stack records when one path is
|
|
188
|
+
# down — the strict "any forbidden → reject" rule still defeats dual-A
|
|
189
|
+
# SSRF because validation runs upfront, before any socket opens.
|
|
190
|
+
def pin_addresses(uri, resolver, opts)
|
|
174
191
|
host = uri.host or raise AddressError, "URL has no host"
|
|
175
192
|
addresses = AddressCheck.resolve(host, resolver)
|
|
176
193
|
raise AddressError, "no addresses for #{host}" if addresses.empty?
|
|
@@ -183,7 +200,7 @@ module Radioactive
|
|
|
183
200
|
end
|
|
184
201
|
end
|
|
185
202
|
|
|
186
|
-
addresses
|
|
203
|
+
addresses
|
|
187
204
|
end
|
|
188
205
|
|
|
189
206
|
def resolve_redirect(current, location, opts)
|
|
@@ -209,37 +226,68 @@ module Radioactive
|
|
|
209
226
|
[value, remaining].min
|
|
210
227
|
end
|
|
211
228
|
|
|
212
|
-
def perform_request(uri,
|
|
213
|
-
|
|
229
|
+
def perform_request(uri, ips, opts, deadline, clock, &chunk_block)
|
|
230
|
+
# Build the request up front so caller-supplied input (e.g. CRLF in
|
|
231
|
+
# headers) is rejected before any socket opens — independent of which
|
|
232
|
+
# candidate we'd reach.
|
|
214
233
|
req = build_request(uri, opts)
|
|
234
|
+
last_connect_error = nil
|
|
215
235
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
236
|
+
ips.each do |ip|
|
|
237
|
+
http = build_http(uri, ip, opts, deadline, clock)
|
|
238
|
+
|
|
239
|
+
begin
|
|
240
|
+
http.start
|
|
241
|
+
rescue *CONNECT_FALLBACK_ERRORS => e
|
|
242
|
+
last_connect_error = e
|
|
243
|
+
next
|
|
244
|
+
rescue OpenSSL::SSL::SSLError, SocketError, Errno::ECONNRESET,
|
|
245
|
+
IOError => e
|
|
246
|
+
raise ResponseError, "transport error: #{e.class}: #{e.message}"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
begin
|
|
250
|
+
return execute_request(http, req, opts, deadline, clock, &chunk_block)
|
|
251
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
252
|
+
raise TimeoutError, e.message
|
|
253
|
+
rescue OpenSSL::SSL::SSLError, SocketError, Errno::ECONNREFUSED,
|
|
254
|
+
Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ECONNRESET,
|
|
255
|
+
IOError => e
|
|
256
|
+
raise ResponseError, "transport error: #{e.class}: #{e.message}"
|
|
257
|
+
ensure
|
|
258
|
+
begin
|
|
259
|
+
http.finish if http.started?
|
|
260
|
+
rescue IOError
|
|
261
|
+
# already closed
|
|
235
262
|
end
|
|
236
263
|
end
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# All candidates failed at connect. Surface the last error in the same
|
|
267
|
+
# shape a single-address failure would have taken.
|
|
268
|
+
raise TimeoutError, last_connect_error.message if last_connect_error.is_a?(Net::OpenTimeout)
|
|
269
|
+
raise ResponseError, "transport error: #{last_connect_error.class}: #{last_connect_error.message}"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def execute_request(http, req, opts, deadline, clock, &chunk_block)
|
|
273
|
+
result = nil
|
|
274
|
+
|
|
275
|
+
http.request(req) do |res|
|
|
276
|
+
code = res.code.to_i
|
|
277
|
+
headers = headers_hash(res)
|
|
278
|
+
|
|
279
|
+
if REDIRECT_STATUSES.include?(code) && headers["location"]
|
|
280
|
+
result = [:redirect, code, headers, nil]
|
|
281
|
+
elsif (200..299).cover?(code)
|
|
282
|
+
# 2xx: stream chunks straight to caller; no buffering here.
|
|
283
|
+
read_body!(res, headers, opts, deadline, clock, &chunk_block)
|
|
284
|
+
result = [:final, code, headers, nil]
|
|
285
|
+
else
|
|
286
|
+
# Non-2xx: buffer body so ResponseError can carry partial data.
|
|
287
|
+
error_body = String.new(capacity: CHUNK_SIZE)
|
|
288
|
+
read_body!(res, headers, opts, deadline, clock) { |chunk| error_body << chunk }
|
|
289
|
+
result = [:final, code, headers, error_body]
|
|
290
|
+
end
|
|
243
291
|
end
|
|
244
292
|
|
|
245
293
|
result || raise(ResponseError, "request produced no response")
|
data/lib/radioactive/version.rb
CHANGED
data/sig/radioactive.rbs
CHANGED
|
@@ -136,6 +136,7 @@ module Radioactive
|
|
|
136
136
|
RESERVED_HEADERS: Array[String]
|
|
137
137
|
CHUNK_SIZE: Integer
|
|
138
138
|
DEFAULT_USER_AGENT: String
|
|
139
|
+
CONNECT_FALLBACK_ERRORS: Array[Class]
|
|
139
140
|
NUMERIC_ONLY_HOST: Regexp
|
|
140
141
|
HEADER_INVALID_CHAR: Regexp
|
|
141
142
|
DEFAULTS: Hash[Symbol, untyped]
|
|
@@ -219,11 +220,12 @@ module Radioactive
|
|
|
219
220
|
def run_streaming: (uri_like url, Hash[Symbol, untyped] call_opts) { (String) -> void } -> Hash[Symbol, untyped]
|
|
220
221
|
def parse_url: (uri_like url, Hash[Symbol, untyped] opts) -> URI::Generic
|
|
221
222
|
def canonicalize_host: (String host) -> String
|
|
222
|
-
def
|
|
223
|
+
def pin_addresses: (URI::Generic uri, AddressCheck::_Resolver resolver, Hash[Symbol, untyped] opts) -> Array[IPAddr]
|
|
223
224
|
def resolve_redirect: (URI::Generic current, String location, Hash[Symbol, untyped] opts) -> URI::Generic
|
|
224
225
|
def check_deadline: (Float? deadline, _Clock clock) -> void
|
|
225
226
|
def clamp_timeout: ((Float | Integer)? value, Float? deadline, _Clock clock) -> (Float | Integer)?
|
|
226
|
-
def perform_request: (URI::Generic uri, IPAddr
|
|
227
|
+
def perform_request: (URI::Generic uri, Array[IPAddr] ips, Hash[Symbol, untyped] opts, Float? deadline, _Clock clock) { (String) -> void } -> Array[untyped]
|
|
228
|
+
def execute_request: (Net::HTTP http, Net::HTTPRequest req, Hash[Symbol, untyped] opts, Float? deadline, _Clock clock) { (String) -> void } -> Array[untyped]
|
|
227
229
|
def build_http: (URI::Generic uri, IPAddr ip, Hash[Symbol, untyped] opts, Float? deadline, _Clock clock) -> Net::HTTP
|
|
228
230
|
def build_request: (URI::Generic uri, Hash[Symbol, untyped] opts) -> Net::HTTPRequest
|
|
229
231
|
def headers_hash: (Net::HTTPResponse res) -> header_hash
|
data.tar.gz.sig
CHANGED
|
Binary file
|
metadata
CHANGED
metadata.gz.sig
CHANGED
|
Binary file
|