tina4ruby 3.13.138 → 3.13.139

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.
data/lib/tina4/push.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "base64"
11
11
  require "json"
12
12
  require "net/http"
13
13
  require "uri"
14
+ require_relative "ssrf"
14
15
 
15
16
  module Tina4
16
17
  class PushError < StandardError; end
@@ -40,7 +41,11 @@ module Tina4
40
41
  bytes.b.rjust(32, "\x00".b)
41
42
  end
42
43
 
43
- def initialize(subject: nil, public_key: nil, private_key: nil, ttl: 60, urgency: nil)
44
+ def initialize(subject: nil, public_key: nil, private_key: nil, ttl: 60, urgency: nil, allow_hosts: nil)
45
+ # SSRF guard (ADR-0084): the push endpoint is refused when it resolves to a
46
+ # private/internal address unless TINA4_ALLOW_PRIVATE_REQUESTS is truthy or
47
+ # the host/CIDR is on this allow-list.
48
+ @allow_hosts = allow_hosts || []
44
49
  @subject = (subject || ENV.fetch("TINA4_VAPID_SUBJECT", "")).strip
45
50
  @public_key = (public_key || ENV.fetch("TINA4_VAPID_PUBLIC", "")).strip
46
51
  @private_key = (private_key || ENV.fetch("TINA4_VAPID_PRIVATE", "")).strip
@@ -88,6 +93,13 @@ module Tina4
88
93
  end
89
94
 
90
95
  def deliver(endpoint, uri, subject, public_key, private, public, body)
96
+ # SSRF guard (ADR-0084): refuse a private/internal push endpoint. Net::HTTP
97
+ # does not follow redirects, so guarding the endpoint is sufficient.
98
+ begin
99
+ Tina4::Ssrf.guard_url!(uri, @allow_hosts)
100
+ rescue Tina4::SsrfError => e
101
+ raise PushError, e.message
102
+ end
91
103
  request = Net::HTTP::Post.new(uri)
92
104
  request["Authorization"] = "vapid t=#{vapid_token(uri, subject, private, public)}, k=#{public_key}"
93
105
  request["Content-Encoding"] = "aes128gcm"
@@ -267,6 +267,14 @@ module Tina4
267
267
  out
268
268
  end
269
269
 
270
+ # Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
271
+ def reject(job, reason = "")
272
+ job.attempts = [job.attempts + 1, @max_retries].max
273
+ job.error = reason
274
+ dead_letter(job)
275
+ acknowledge(job)
276
+ end
277
+
270
278
  def failed(_topic, max_retries: 3)
271
279
  raise NotImplementedError,
272
280
  "The kafka queue backend cannot answer failed(): a job that failed " \
@@ -177,6 +177,18 @@ module Tina4
177
177
  # Aligns with retry_job(id) which had always unlinked the dead-letter
178
178
  # file — two spellings of the same intent that previously diverged.
179
179
  # Increments attempts, clears the error.
180
+ # Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
181
+ # Unlike fail(), which requeues until max_retries is spent, reject() moves
182
+ # a known-poison job straight to the dead-letter store. attempts is floored
183
+ # at max_retries so dead_letters (attempts >= limit) returns it and
184
+ # dead_letter_count agrees.
185
+ def reject(job, reason = "")
186
+ clear_reservation(job.topic, job.id)
187
+ job.attempts = [job.attempts + 1, @max_retries].max
188
+ job.error = reason
189
+ move_to_dead_letter(job, reason)
190
+ end
191
+
180
192
  def retry(job, delay_seconds: 0)
181
193
  clear_reservation(job.topic, job.id)
182
194
  # Drop any dead-letter file for this id BEFORE the re-queue: if this
@@ -284,6 +284,18 @@ module Tina4
284
284
  # the normal path. This method did not exist at all, and Queue#failed
285
285
  # silently returned [] for it — indistinguishable from "nothing has
286
286
  # failed" (ADR-0022 decision 7).
287
+ # Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
288
+ def reject(job, reason = "")
289
+ job.attempts = [job.attempts + 1, @max_retries].max
290
+ job.error = reason
291
+ collection.find_one_and_update(
292
+ { _id: job.id },
293
+ { "$set" => { status: "dead", topic: "#{job.topic}.dead_letter",
294
+ attempts: job.attempts, error: reason, reserved_at: nil } },
295
+ upsert: true
296
+ )
297
+ end
298
+
287
299
  def failed(topic, max_retries: 3)
288
300
  collection.find(
289
301
  topic: topic,
@@ -177,6 +177,14 @@ module Tina4
177
177
  end
178
178
  end
179
179
 
180
+ # Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
181
+ def reject(job, reason = "")
182
+ job.attempts = [job.attempts + 1, @max_retries].max
183
+ job.error = reason
184
+ dead_letter(job)
185
+ complete(job)
186
+ end
187
+
180
188
  def failed(_topic, max_retries: 3)
181
189
  raise NotImplementedError,
182
190
  "The rabbitmq queue backend cannot answer failed(): a job that " \
@@ -1189,68 +1189,46 @@ module Tina4
1189
1189
  def self.enforce_route_auth(env, route)
1190
1190
  return nil unless route.auth_required
1191
1191
 
1192
- token = nil
1193
- token_source = nil # :header, :body, :session
1194
-
1195
- # Priority 1: Authorization Bearer header
1192
+ unauthorized = [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
1193
+
1194
+ # Token slots in priority order: Authorization Bearer header, then the
1195
+ # body formToken (frond.js puts the auth token it received as a
1196
+ # FreshToken there), then the session. The first slot holding a token
1197
+ # decides, EXCEPT that a form token ("type" => "form") proves where a
1198
+ # write came from, not who sent it: it is skipped and the next slot is
1199
+ # tried (ADR-0079 s1). So a logged-in user posting a rendered form (form
1200
+ # token in the body, auth token in the session) is still authenticated.
1201
+ candidates = []
1196
1202
  auth_header = env["HTTP_AUTHORIZATION"] || ""
1197
- if auth_header =~ /\ABearer\s+(.+)\z/i
1198
- token = Regexp.last_match(1)
1199
- token_source = :header
1200
- end
1203
+ candidates << [Regexp.last_match(1), :header] if auth_header =~ /\ABearer\s+(.+)\z/i
1204
+ form_token = _extract_form_token(_read_rack_body(env), env)
1205
+ candidates << [form_token, :body] if form_token && !form_token.empty?
1201
1206
 
1202
- # Priority 2: formToken from request body (for frond.js saveForm with {{ form_token() }})
1203
- if token.nil?
1204
- body_str = _read_rack_body(env)
1205
- form_token = _extract_form_token(body_str, env)
1206
- if form_token && !form_token.empty?
1207
- token = form_token
1208
- token_source = :body
1209
- end
1207
+ verdict = candidates.each do |token, source|
1208
+ outcome = _authenticate_token(env, token, source)
1209
+ break outcome unless outcome == :skip
1210
1210
  end
1211
+ return unauthorized if verdict == :denied
1211
1212
 
1212
- # Priority 3: Session token (for secured GET routes after login)
1213
- if token.nil?
1213
+ unless env.key?("tina4.auth_payload")
1214
1214
  # Request path, so the same log-loud-then-degrade policy as
1215
1215
  # Request#session (ADR-0021): an unreachable session store must not turn
1216
1216
  # the auth gate into a 500. It degrades to an empty session, which means
1217
1217
  # no token, which means the ordinary 401 below - a SERVED request.
1218
1218
  session = Tina4::Session.new(env, degrade_on_backend_failure: true)
1219
- sso = session.get("_tina4_sso")
1220
- identity = sso.is_a?(Hash) ? sso["identity"] : nil
1221
- if identity.is_a?(Hash) && identity["issuer"] && identity["subject"]
1219
+ # A provider-verified OIDC identity counts only while it is live
1220
+ # (ADR-0079 s5).
1221
+ identity = Tina4::Sso.live_session_identity(session.get("_tina4_sso"))
1222
+ if identity
1222
1223
  env["tina4.auth_payload"] = identity
1223
- deny = rbac_forbidden(route, identity)
1224
- return deny if deny
1225
-
1226
- return nil
1227
- end
1228
- session_token = session.get("token")
1229
- if session_token && !session_token.empty?
1230
- token = session_token
1231
- token_source = :session
1232
- end
1233
- end
1234
-
1235
- # API_KEY bypass — routed through the timing-safe Tina4::Auth.validate_api_key
1236
- # (OpenSSL.fixed_length_secure_compare), matching tina4_python's _check_auth.
1237
- # It used to be a plain `token == api_key`, which returns as soon as two
1238
- # bytes differ — so response timing leaks the key prefix and the key can be
1239
- # recovered a character at a time. validate_api_key also covers the unset /
1240
- # blank / wrong-length cases the old guard spelled out by hand.
1241
- if Tina4::Auth.validate_api_key(token)
1242
- env["tina4.auth_payload"] = { "_auth" => "api_key" }
1243
- elsif token
1244
- unless Tina4::Auth.valid_token(token)
1245
- return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
1224
+ else
1225
+ session_token = session.get("token")
1226
+ if session_token.is_a?(String) && !session_token.empty?
1227
+ return unauthorized unless _authenticate_token(env, session_token, :session) == :accepted
1228
+ end
1246
1229
  end
1247
- env["tina4.auth_payload"] = Tina4::Auth.get_payload(token)
1248
-
1249
- # When body formToken validates, store a refreshed token for the FreshToken response header
1250
- env["tina4.fresh_token"] = Tina4::Auth.refresh_token(token) if token_source == :body
1251
- else
1252
- return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
1253
1230
  end
1231
+ return unauthorized unless env.key?("tina4.auth_payload")
1254
1232
 
1255
1233
  # ── RBAC guards (Feature 138): authorization AFTER authentication ──
1256
1234
  # Auth has passed (401 ruled out above). If the route carries role/
@@ -1261,6 +1239,30 @@ module Tina4
1261
1239
  nil
1262
1240
  end
1263
1241
 
1242
+ # Judge one token from one slot: :accepted (env["tina4.auth_payload"] set),
1243
+ # :skip (a valid form token - not an identity, try the next slot) or
1244
+ # :denied (anything else).
1245
+ #
1246
+ # API_KEY bypass - routed through the timing-safe Tina4::Auth.validate_api_key
1247
+ # (OpenSSL.fixed_length_secure_compare), matching tina4_python's _check_auth.
1248
+ # A plain `token == api_key` returns as soon as two bytes differ, so response
1249
+ # timing would leak the key a character at a time.
1250
+ def self._authenticate_token(env, token, source)
1251
+ if Tina4::Auth.validate_api_key(token)
1252
+ env["tina4.auth_payload"] = { "_auth" => "api_key" }
1253
+ return :accepted
1254
+ end
1255
+ payload = Tina4::Auth.valid_token(token)
1256
+ return :denied unless payload
1257
+ return :skip unless Tina4::Auth.identity_payload?(payload)
1258
+
1259
+ env["tina4.auth_payload"] = payload
1260
+ # When a body token authenticates, store a refreshed token for the
1261
+ # FreshToken response header (identity tokens only).
1262
+ env["tina4.fresh_token"] = Tina4::Auth.refresh_token(token) if source == :body
1263
+ :accepted
1264
+ end
1265
+
1264
1266
  # Build a 403 Rack tuple when a route's RBAC guards are not satisfied by the
1265
1267
  # verified payload, else nil. AND across guard groups, OR within a group.
1266
1268
  # Roles read the `roles` claim (legacy singular `role` coerced); permissions
@@ -15,7 +15,15 @@ module Tina4
15
15
  class S3Storage < StorageBackend
16
16
  def initialize(endpoint: nil, key: nil, secret: nil, bucket: nil, region: nil)
17
17
  super()
18
- require "aws-sdk-s3" # optional dependency, loaded lazily
18
+ begin
19
+ require "aws-sdk-s3" # optional dependency, loaded lazily
20
+ rescue LoadError
21
+ # Storage.select rescues this and falls back to local; its warning
22
+ # carries this message, so the remedy reaches the operator.
23
+ raise LoadError,
24
+ "The 'aws-sdk-s3' gem is required for S3Storage. Install it with: " \
25
+ "bundle add aws-sdk-s3 (or add gem \"aws-sdk-s3\" to your Gemfile)"
26
+ end
19
27
 
20
28
  @bucket = bucket || ENV["TINA4_STORAGE_BUCKET"]
21
29
  raise ArgumentError, "S3Storage requires TINA4_STORAGE_BUCKET" if @bucket.nil? || @bucket.empty?
data/lib/tina4/request.rb CHANGED
@@ -292,6 +292,11 @@ module Tina4
292
292
  # still see the URL the client used. Matches Python/PHP/Node parity.
293
293
  def url
294
294
  scheme = self.class.secure_scheme?(env) ? "https" : "http"
295
+ # X-Forwarded-Host is honoured ONLY when the raw socket peer is a trusted
296
+ # proxy (TINA4_TRUSTED_PROXIES) — the same gate already applied to
297
+ # X-Forwarded-For. An untrusted client can otherwise forge the host and
298
+ # control the absolute request.url (password-reset links, cache keys,
299
+ # open-redirect base). Parity with Python/PHP/Node.
295
300
  forwarded_host = Tina4.trusted_proxy?(@remote_ip) ? env["HTTP_X_FORWARDED_HOST"] : nil
296
301
  host = forwarded_host || env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
297
302
  port = env["SERVER_PORT"]
@@ -217,25 +217,49 @@ module Tina4
217
217
  # spells "already exists" differently ("There is already an object named",
218
218
  # "already exists", SQLSTATE 42S01) and a string match would rot the first
219
219
  # time an engine reworded itself or ran under another locale.
220
+ #
221
+ # The re-check is RETRIED, a bounded number of times, because the loser
222
+ # can fail BEFORE the winner has committed. MEASURED on MySQL 8.4: CREATE
223
+ # TABLE IF NOT EXISTS takes a shared metadata lock on the name, checks,
224
+ # then upgrades it to exclusive, and two sessions upgrading at once is a
225
+ # metadata-lock deadlock. MySQL backs the victim off silently only when the
226
+ # session holds no other metadata lock; otherwise the victim gets 1213
227
+ # "Deadlock found" while the winner's table is still invisible, and one
228
+ # immediate re-check turned that lost race into a failed request (tina4-php
229
+ # CI run 35972320442). Same attempts and delays in all four frameworks.
230
+ CREATE_ATTEMPTS = 5
231
+ CREATE_RETRY_DELAY = 0.05
232
+
220
233
  def ensure_table
221
234
  return if @table_ready
222
235
 
223
236
  @table_ready = true
224
- begin
225
- db.execute(create_table_sql)
226
- rescue StandardError
227
- # A failed statement leaves PostgreSQL's transaction ABORTED, so
228
- # without this the re-check would fail for the wrong reason and report
229
- # a missing table that is right there. Best effort: an engine with no
230
- # open transaction is entitled to object to being rolled back.
237
+ attempt = 1
238
+ until db.table_exists?(TABLE_NAME)
231
239
  begin
232
- db.rollback
240
+ db.execute(create_table_sql)
241
+ return
233
242
  rescue StandardError
234
- nil
243
+ # A failed statement leaves PostgreSQL's transaction ABORTED, so
244
+ # without this the re-check would fail for the wrong reason and
245
+ # report a missing table that is right there. Best effort: an engine
246
+ # with no open transaction is entitled to object to being rolled back.
247
+ begin
248
+ db.rollback
249
+ rescue StandardError
250
+ nil
251
+ end
252
+ # Somebody else created it - that is the race, and it is a success.
253
+ # Still absent after the last attempt is a real failure, re-raised
254
+ # untouched.
255
+ if attempt >= CREATE_ATTEMPTS
256
+ return if db.table_exists?(TABLE_NAME)
257
+
258
+ raise
259
+ end
260
+ sleep(CREATE_RETRY_DELAY * attempt)
261
+ attempt += 1
235
262
  end
236
- # Somebody else created it - that is the race, and it is a success.
237
- # Anything else is a real failure and is re-raised untouched.
238
- raise unless db.table_exists?(TABLE_NAME)
239
263
  end
240
264
  end
241
265
  end
data/lib/tina4/sso.rb CHANGED
@@ -22,6 +22,30 @@ module Tina4
22
22
  class Sso
23
23
  PENDING_KEY = "_tina4_sso_pending"
24
24
  SESSION_KEY = "_tina4_sso"
25
+
26
+ # The stored SSO identity, only while it is live (ADR-0079 s5). +stored+ is
27
+ # the reserved _tina4_sso session value. The identity needs an issuer and a
28
+ # subject, and a numeric expires_at that is 0 (the provider gave no
29
+ # lifetime) or still in the future.
30
+ def self.live_session_identity(stored)
31
+ return nil unless stored.is_a?(Hash)
32
+
33
+ identity = stored["identity"]
34
+ return nil unless identity.is_a?(Hash) && identity["issuer"] && identity["subject"]
35
+
36
+ expires_at = stored.fetch("expires_at", 0)
37
+ return nil unless expires_at.is_a?(Integer) || expires_at.is_a?(Float)
38
+ return nil if expires_at.positive? && Time.now.to_i >= expires_at
39
+
40
+ identity
41
+ end
42
+
43
+ # When the provider's access token lapses; 0 when it gave no lifetime.
44
+ # Never "now", which is already in the past by the next request.
45
+ def self.expires_at(tokens)
46
+ lifetime = tokens.fetch("expires_in", 0).to_i
47
+ lifetime.positive? ? Time.now.to_i + lifetime : 0
48
+ end
25
49
  attr_reader :issuer, :client_id, :client_secret, :redirect_uri, :scopes,
26
50
  :verify, :post_logout_redirect_uri, :claim_map
27
51
  @mounted = false
@@ -221,14 +245,13 @@ module Tina4
221
245
  current.set(SESSION_KEY, {
222
246
  "version" => 1, "identity" => identity, "access_token" => tokens["access_token"],
223
247
  "refresh_token" => tokens["refresh_token"], "id_token" => tokens["id_token"],
224
- "expires_at" => Time.now.to_i + tokens.fetch("expires_in", 0).to_i
248
+ "expires_at" => self.class.expires_at(tokens)
225
249
  })
226
250
  { "identity" => identity, "return_to" => self.class.safe_return(pending["return_to"]) }
227
251
  end
228
252
 
229
253
  def identity(request_or_session)
230
- stored = session(request_or_session)&.get(SESSION_KEY)
231
- value = stored.is_a?(Hash) ? stored["identity"] : nil
254
+ value = self.class.live_session_identity(session(request_or_session)&.get(SESSION_KEY))
232
255
  request_or_session.user = value if value && !request_or_session.is_a?(Tina4::Session)
233
256
  value
234
257
  end
@@ -251,7 +274,7 @@ module Tina4
251
274
  "identity" => value, "access_token" => tokens["access_token"],
252
275
  "refresh_token" => tokens["refresh_token"] || stored["refresh_token"],
253
276
  "id_token" => tokens["id_token"] || stored["id_token"],
254
- "expires_at" => Time.now.to_i + tokens.fetch("expires_in", 0).to_i
277
+ "expires_at" => self.class.expires_at(tokens)
255
278
  ))
256
279
  value
257
280
  rescue StandardError
data/lib/tina4/ssrf.rb ADDED
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
8
+ require "ipaddr"
9
+ require "resolv"
10
+ require "socket"
11
+ require "uri"
12
+
13
+ module Tina4
14
+ # Raised when the SSRF guard refuses an outbound request.
15
+ class SsrfError < StandardError; end
16
+
17
+ # Outbound SSRF guard (ADR-0084).
18
+ #
19
+ # The Api client and Web Push refuse, by default, to connect to a private or
20
+ # internal address. Before each connection - the initial URL and every redirect
21
+ # hop the Api client follows - the host is resolved to its IP address(es) and
22
+ # the request is refused if any resolved address is loopback, private,
23
+ # link-local (including the cloud metadata address 169.254.169.254), unspecified
24
+ # or CGNAT. A non-http(s) scheme is refused.
25
+ #
26
+ # Off by default; TINA4_ALLOW_PRIVATE_REQUESTS (truthy) or an explicit allow-list
27
+ # of hosts / host:port / CIDRs opts out. Zero external dependencies - Ruby
28
+ # stdlib IPAddr + Resolv.
29
+ module Ssrf
30
+ ALLOW_PRIVATE_ENV = "TINA4_ALLOW_PRIVATE_REQUESTS"
31
+
32
+ # Truthiness identical to ADR-0070's set (trimmed, lower-cased).
33
+ TRUTHY = %w[1 true yes on].freeze
34
+
35
+ # The blocked address space, one definition shared with the contract fixture.
36
+ # 0.0.0.0/8 covers the unspecified address and the "this network" block.
37
+ BLOCKED = %w[
38
+ 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16
39
+ 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10
40
+ ::1/128 ::/128 fc00::/7 fe80::/10
41
+ ].map { |cidr| IPAddr.new(cidr) }.freeze
42
+
43
+ module_function
44
+
45
+ # True when TINA4_ALLOW_PRIVATE_REQUESTS opts out of the guard.
46
+ def allow_private_requests?
47
+ TRUTHY.include?(ENV.fetch(ALLOW_PRIVATE_ENV, "").strip.downcase)
48
+ end
49
+
50
+ # Classify one IP string: true = private/internal, refuse it. An IPv4-mapped
51
+ # IPv6 address is classified by its embedded IPv4. An unparseable value is
52
+ # treated as blocked - the guard refuses what it cannot classify.
53
+ def blocked_address?(ip)
54
+ addr = IPAddr.new(ip.to_s.split("%").first)
55
+ addr = addr.native if addr.ipv6? && addr.ipv4_mapped?
56
+ BLOCKED.any? { |network| network.include?(addr) }
57
+ rescue IPAddr::Error
58
+ true
59
+ end
60
+
61
+ # Refuse +url+ when it targets a private/internal address. Raises SsrfError
62
+ # for a non-http(s) scheme, an unresolvable host, or any resolved address in
63
+ # the blocked space. allow_hosts is a list of hosts / host:port / CIDRs.
64
+ def guard_url!(url, allow_hosts = nil)
65
+ uri = url.is_a?(URI) ? url : URI.parse(url.to_s)
66
+ scheme = uri.scheme&.downcase
67
+ unless %w[http https].include?(scheme)
68
+ raise SsrfError, "Blocked request: URL scheme '#{scheme || '(none)'}' is not http or https"
69
+ end
70
+ host = uri.host.to_s
71
+ raise SsrfError, "Blocked request: URL has no host" if host.empty?
72
+
73
+ host = host.delete_prefix("[").delete_suffix("]")
74
+ port = uri.port || (scheme == "https" ? 443 : 80)
75
+
76
+ return if allow_private_requests?
77
+
78
+ resolved = resolve(host)
79
+ return if matches_allow_list?(host, port, resolved, allow_hosts)
80
+
81
+ resolved.each do |ip|
82
+ next unless blocked_address?(ip)
83
+
84
+ log_block(host, ip)
85
+ raise SsrfError,
86
+ "Blocked request to private/internal address #{ip} (host #{host}): " \
87
+ "set #{ALLOW_PRIVATE_ENV}=true to allow, or pass an allow-list."
88
+ end
89
+ end
90
+
91
+ # Resolve a host to its IP address(es). An IP literal resolves to itself.
92
+ def resolve(host)
93
+ begin
94
+ IPAddr.new(host)
95
+ return [host]
96
+ rescue IPAddr::Error
97
+ # not a literal - fall through to DNS
98
+ end
99
+ addresses = begin
100
+ Resolv.getaddresses(host)
101
+ rescue StandardError
102
+ []
103
+ end
104
+ if addresses.empty?
105
+ begin
106
+ addresses = [IPSocket.getaddress(host)]
107
+ rescue SocketError
108
+ addresses = []
109
+ end
110
+ end
111
+ raise SsrfError, "Blocked request to #{host}: cannot resolve host" if addresses.empty?
112
+
113
+ addresses
114
+ end
115
+
116
+ def matches_allow_list?(host, port, resolved, allow_hosts)
117
+ host_lower = host.downcase
118
+ Array(allow_hosts).each do |raw|
119
+ entry = raw.to_s.strip.downcase
120
+ next if entry.empty?
121
+ return true if entry == host_lower || entry == "#{host_lower}:#{port}"
122
+
123
+ next unless entry.include?("/")
124
+
125
+ begin
126
+ network = IPAddr.new(entry)
127
+ rescue IPAddr::Error
128
+ next
129
+ end
130
+ resolved.each do |ip|
131
+ begin
132
+ candidate = IPAddr.new(ip.to_s.split("%").first)
133
+ rescue IPAddr::Error
134
+ next
135
+ end
136
+ return true if network.include?(candidate)
137
+ end
138
+ end
139
+ false
140
+ end
141
+
142
+ def log_block(host, ip)
143
+ Tina4::Log.warning("SSRF guard blocked outbound request to #{host} (#{ip})") if defined?(Tina4::Log)
144
+ rescue StandardError
145
+ nil
146
+ end
147
+ end
148
+ end
data/lib/tina4/version.rb CHANGED
@@ -7,5 +7,5 @@
7
7
 
8
8
 
9
9
  module Tina4
10
- VERSION = "3.13.138"
10
+ VERSION = "3.13.139"
11
11
  end