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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/lib/tina4/api.rb +17 -1
- data/lib/tina4/auth.rb +90 -14
- data/lib/tina4/cache_backends/base_backend.rb +8 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +16 -1
- data/lib/tina4/cache_backends.rb +3 -1
- data/lib/tina4/cli.rb +5 -0
- data/lib/tina4/dev_admin.rb +11 -4
- data/lib/tina4/drivers/sqlite_driver.rb +8 -0
- data/lib/tina4/env.rb +9 -7
- data/lib/tina4/frond.rb +4 -2
- data/lib/tina4/graphql.rb +106 -8
- data/lib/tina4/job.rb +15 -2
- data/lib/tina4/mcp.rb +3 -1
- data/lib/tina4/middleware.rb +3 -1
- data/lib/tina4/push.rb +13 -1
- data/lib/tina4/queue_backends/kafka_backend.rb +8 -0
- data/lib/tina4/queue_backends/lite_backend.rb +12 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +12 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +8 -0
- data/lib/tina4/rack_app.rb +52 -50
- data/lib/tina4/realtime/s3_storage.rb +9 -1
- data/lib/tina4/request.rb +5 -0
- data/lib/tina4/session_handlers/database_handler.rb +36 -12
- data/lib/tina4/sso.rb +27 -4
- data/lib/tina4/ssrf.rb +148 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +160 -16
- data/lib/tina4/websocket_backplane.rb +6 -0
- data/lib/tina4/wsdl.rb +25 -4
- data/lib/tina4/xml_parser.rb +18 -9
- data/lib/tina4.rb +11 -2
- metadata +3 -2
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 " \
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -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
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
#
|
|
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
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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.
|
|
240
|
+
db.execute(create_table_sql)
|
|
241
|
+
return
|
|
233
242
|
rescue StandardError
|
|
234
|
-
|
|
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" =>
|
|
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
|
-
|
|
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" =>
|
|
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