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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db78ab47dc98a9125d0dde9cb3ec17e95b83cafc3fae5922fa3f48cbcb85f0f2
4
- data.tar.gz: 8da7cf85d58c94bab1a13466ef7a236b24a2da1d6e021835bd5bf959b690d38c
3
+ metadata.gz: c3fafb950586fcfeabd5b472d69d2d039011c4e7f33d94bfee8edff8b8a42b91
4
+ data.tar.gz: e2785593bc12e88e6ece524df9dab17dd85edfbb557c884d57621d80c996b950
5
5
  SHA512:
6
- metadata.gz: 594fcc7d8f2f3d670154b11d9d0194df8f9fe2da2e8d4eadf222516cb76b6b1d82ac70214e873ccdaaf9947f95f0ca6012aba1079ee8942e25ad50845b2035be
7
- data.tar.gz: 35ee0b007e1e41807b9376d80c1e98d6b933af4388b8e4dae0aef6d5dc451d6ec0f012284619d9390cb4f055db82a5efce4199f73220df2ba8c26680a5006331
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
- ## [Unreleased]
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.
@@ -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
- ip = pin_address(current, resolver, opts)
114
- kind, status, headers, body = perform_request(current, ip, opts, deadline, clock, &chunk_block)
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
- def pin_address(uri, resolver, opts)
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.first
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, ip, opts, deadline, clock, &chunk_block)
213
- http = build_http(uri, ip, opts, deadline, clock)
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
- result = nil
217
- begin
218
- http.start do |conn|
219
- conn.request(req) do |res|
220
- code = res.code.to_i
221
- headers = headers_hash(res)
222
-
223
- if REDIRECT_STATUSES.include?(code) && headers["location"]
224
- result = [:redirect, code, headers, nil]
225
- elsif (200..299).cover?(code)
226
- # 2xx: stream chunks straight to caller; no buffering here.
227
- read_body!(res, headers, opts, deadline, clock, &chunk_block)
228
- result = [:final, code, headers, nil]
229
- else
230
- # Non-2xx: buffer body so ResponseError can carry partial data.
231
- error_body = String.new(capacity: CHUNK_SIZE)
232
- read_body!(res, headers, opts, deadline, clock) { |chunk| error_body << chunk }
233
- result = [:final, code, headers, error_body]
234
- end
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
- rescue Net::OpenTimeout, Net::ReadTimeout => e
238
- raise TimeoutError, e.message
239
- rescue OpenSSL::SSL::SSLError, SocketError, Errno::ECONNREFUSED,
240
- Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ECONNRESET,
241
- IOError => e
242
- raise ResponseError, "transport error: #{e.class}: #{e.message}"
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")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Radioactive
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
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 pin_address: (URI::Generic uri, AddressCheck::_Resolver resolver, Hash[Symbol, untyped] opts) -> IPAddr
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 ip, Hash[Symbol, untyped] opts, Float? deadline, _Clock clock) { (String) -> void } -> Array[untyped]
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
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: radioactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pawel Osiczko
metadata.gz.sig CHANGED
Binary file