tina4ruby 3.13.138 → 3.13.140
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 +23 -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/database/sqlite3_adapter.rb +6 -54
- data/lib/tina4/dev_admin.rb +11 -4
- data/lib/tina4/drivers/sqlite_driver.rb +14 -81
- 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/row_symbolizer.rb +54 -0
- data/lib/tina4/session_handlers/database_handler.rb +36 -12
- data/lib/tina4/sqlite_path_resolver.rb +72 -0
- 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 +13 -2
- metadata +5 -2
data/lib/tina4/graphql.rb
CHANGED
|
@@ -227,6 +227,14 @@ module Tina4
|
|
|
227
227
|
@source = source
|
|
228
228
|
@tokens = tokenize(source)
|
|
229
229
|
@pos = 0
|
|
230
|
+
# F2: bound the parser's own recursion. A deeply nested query
|
|
231
|
+
# ("{a{a{a...}}}") otherwise recurses in parse_selection_set until Ruby
|
|
232
|
+
# raises SystemStackError — which is NOT a StandardError, so the public
|
|
233
|
+
# execute's `rescue => e` misses it and it escapes as a 500. Raising a
|
|
234
|
+
# GraphQLError (a StandardError) at the depth bound keeps it caught and
|
|
235
|
+
# returned as a clean GraphQL error. Reuses TINA4_GRAPHQL_MAX_DEPTH.
|
|
236
|
+
@depth = 0
|
|
237
|
+
@max_depth = Integer(ENV.fetch("TINA4_GRAPHQL_MAX_DEPTH", "50"), exception: false) || 50
|
|
230
238
|
end
|
|
231
239
|
|
|
232
240
|
def parse
|
|
@@ -439,6 +447,19 @@ module Tina4
|
|
|
439
447
|
end
|
|
440
448
|
|
|
441
449
|
def parse_selection_set
|
|
450
|
+
@depth += 1
|
|
451
|
+
if @max_depth.positive? && @depth > @max_depth
|
|
452
|
+
@depth -= 1
|
|
453
|
+
raise GraphQLError, "Query exceeds maximum depth of #{@max_depth}"
|
|
454
|
+
end
|
|
455
|
+
begin
|
|
456
|
+
parse_selection_set_inner
|
|
457
|
+
ensure
|
|
458
|
+
@depth -= 1
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def parse_selection_set_inner
|
|
442
463
|
expect(:punct, "{")
|
|
443
464
|
selections = []
|
|
444
465
|
until current&.value == "}"
|
|
@@ -599,14 +620,47 @@ module Tina4
|
|
|
599
620
|
class GraphQLExecutor
|
|
600
621
|
# max_depth bounds selection-set nesting (DoS / stack-overflow guard).
|
|
601
622
|
# Threaded down from the owning GraphQL instance; <= 0 disables the guard.
|
|
602
|
-
attr_accessor :max_depth
|
|
623
|
+
attr_accessor :max_depth, :max_nodes
|
|
603
624
|
|
|
604
|
-
def initialize(schema, max_depth: 50)
|
|
625
|
+
def initialize(schema, max_depth: 50, max_nodes: 1000)
|
|
605
626
|
@schema = schema
|
|
606
627
|
@max_depth = max_depth
|
|
628
|
+
# F2: maximum expanded selection nodes per query. The depth guard bounds
|
|
629
|
+
# NESTING but not WIDTH — a fragment bomb stays shallow while expanding to
|
|
630
|
+
# millions of fields, and an alias explosion is not deep at all. This
|
|
631
|
+
# budget bounds the expanded selection tree (fragments expanded, each
|
|
632
|
+
# alias counted) so both are rejected before any resolver runs.
|
|
633
|
+
@max_nodes = max_nodes
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# F2: count the expanded selection nodes, aborting past max_nodes. Fragment
|
|
637
|
+
# spreads are expanded (so a fragment bomb is counted, not the small
|
|
638
|
+
# unexpanded document) and every field — including each alias — counts once.
|
|
639
|
+
# Capped by max_nodes and by the depth cap, so a circular fragment trips the
|
|
640
|
+
# budget instead of looping and the check can never cost more than the budget.
|
|
641
|
+
def query_complexity(selections, fragments, depth, count)
|
|
642
|
+
depth_cap = (@max_depth && @max_depth.positive?) ? @max_depth : 1000
|
|
643
|
+
return count if depth > depth_cap
|
|
644
|
+
|
|
645
|
+
selections.each do |sel|
|
|
646
|
+
count += 1
|
|
647
|
+
return count if count > @max_nodes
|
|
648
|
+
|
|
649
|
+
case sel[:kind]
|
|
650
|
+
when :fragment_spread
|
|
651
|
+
frag = fragments[sel[:name]]
|
|
652
|
+
count = query_complexity(frag[:selection_set], fragments, depth + 1, count) if frag
|
|
653
|
+
when :inline_fragment
|
|
654
|
+
count = query_complexity(sel[:selection_set] || [], fragments, depth + 1, count)
|
|
655
|
+
else
|
|
656
|
+
count = query_complexity(sel[:selection_set], fragments, depth + 1, count) if sel[:selection_set]
|
|
657
|
+
end
|
|
658
|
+
return count if count > @max_nodes
|
|
659
|
+
end
|
|
660
|
+
count
|
|
607
661
|
end
|
|
608
662
|
|
|
609
|
-
def execute(document, variables: {}, context: {}, operation_name: nil)
|
|
663
|
+
def execute(document, variables: {}, context: {}, operation_name: nil, allow_mutations: true)
|
|
610
664
|
# Collect fragments
|
|
611
665
|
fragments = {}
|
|
612
666
|
operations = []
|
|
@@ -631,9 +685,28 @@ module Tina4
|
|
|
631
685
|
|
|
632
686
|
raise GraphQLError, "Unknown operation: #{operation_name}" unless operation
|
|
633
687
|
|
|
688
|
+
# F2: reject an over-complex query (fragment bomb / alias explosion) before
|
|
689
|
+
# any resolver runs.
|
|
690
|
+
if @max_nodes && @max_nodes.positive?
|
|
691
|
+
complexity = query_complexity(operation[:selection_set], fragments, 1, 0)
|
|
692
|
+
if complexity > @max_nodes
|
|
693
|
+
return { "data" => nil,
|
|
694
|
+
"errors" => [{ "message" => "Query exceeds maximum complexity of #{@max_nodes} nodes" }] }
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
|
|
634
698
|
# Resolve variables
|
|
635
699
|
resolved_vars = resolve_variables(operation[:variables], variables)
|
|
636
700
|
|
|
701
|
+
# F4 (CSRF): a mutation must never run over a GET. A cross-site GET (an
|
|
702
|
+
# <img>/<script> src, a pre-rendered link) carries the victim's cookies
|
|
703
|
+
# and would otherwise trigger a state change. The GET handler passes
|
|
704
|
+
# allow_mutations: false; POST (which a browser cannot cross-site forge
|
|
705
|
+
# with a JSON content-type without a CORS preflight) still allows them.
|
|
706
|
+
if operation[:operation] == :mutation && !allow_mutations
|
|
707
|
+
raise GraphQLError, "Mutations are not allowed over GET"
|
|
708
|
+
end
|
|
709
|
+
|
|
637
710
|
# Choose root fields
|
|
638
711
|
root_fields = case operation[:operation]
|
|
639
712
|
when :query then @schema.queries
|
|
@@ -897,6 +970,15 @@ module Tina4
|
|
|
897
970
|
@executor&.max_depth = value
|
|
898
971
|
end
|
|
899
972
|
|
|
973
|
+
# F2 complexity budget — see GraphQLExecutor#max_nodes. Writer keeps the
|
|
974
|
+
# executor in sync so tests/app code can override it.
|
|
975
|
+
attr_reader :max_nodes
|
|
976
|
+
|
|
977
|
+
def max_nodes=(value)
|
|
978
|
+
@max_nodes = value
|
|
979
|
+
@executor&.max_nodes = value
|
|
980
|
+
end
|
|
981
|
+
|
|
900
982
|
# Class-level toggle for ORM auto-schema generation. Defaults to true,
|
|
901
983
|
# can be disabled via TINA4_GRAPHQL_AUTO_SCHEMA=false. Initializers and
|
|
902
984
|
# user app code can branch on this before calling `schema.from_orm(...)`.
|
|
@@ -969,7 +1051,10 @@ module Tina4
|
|
|
969
1051
|
# GraphQL DoS / stack-overflow vector. Default 50 is far beyond any
|
|
970
1052
|
# legitimate query; TINA4_GRAPHQL_MAX_DEPTH <= 0 disables the guard.
|
|
971
1053
|
@max_depth = Integer(ENV.fetch("TINA4_GRAPHQL_MAX_DEPTH", "50"), exception: false) || 50
|
|
972
|
-
|
|
1054
|
+
# F2: expanded-node (complexity) budget — bounds query WIDTH (fragment
|
|
1055
|
+
# bombs, alias explosions), which the depth guard does not.
|
|
1056
|
+
@max_nodes = Integer(ENV.fetch("TINA4_GRAPHQL_MAX_NODES", "1000"), exception: false) || 1000
|
|
1057
|
+
@executor = GraphQLExecutor.new(@schema, max_depth: @max_depth, max_nodes: @max_nodes)
|
|
973
1058
|
@field_resolvers = {}
|
|
974
1059
|
|
|
975
1060
|
# Drain any resolvers registered via the class-level GraphQL.resolve()
|
|
@@ -1010,10 +1095,11 @@ module Tina4
|
|
|
1010
1095
|
end
|
|
1011
1096
|
|
|
1012
1097
|
# Execute a query string directly
|
|
1013
|
-
def execute(query, variables: {}, context: {}, operation_name: nil)
|
|
1098
|
+
def execute(query, variables: {}, context: {}, operation_name: nil, allow_mutations: true)
|
|
1014
1099
|
parser = GraphQLParser.new(query)
|
|
1015
1100
|
document = parser.parse
|
|
1016
|
-
@executor.execute(document, variables: variables, context: context,
|
|
1101
|
+
@executor.execute(document, variables: variables, context: context,
|
|
1102
|
+
operation_name: operation_name, allow_mutations: allow_mutations)
|
|
1017
1103
|
rescue GraphQLError => e
|
|
1018
1104
|
{ "data" => nil, "errors" => [{ "message" => e.message }] }
|
|
1019
1105
|
rescue => e
|
|
@@ -1083,6 +1169,16 @@ module Tina4
|
|
|
1083
1169
|
|
|
1084
1170
|
graphql = self
|
|
1085
1171
|
Tina4.post path, auth: false do |request, response|
|
|
1172
|
+
# F4 (CSRF): require an application/json content-type. A browser can
|
|
1173
|
+
# only send a cross-site POST with text/plain, form-urlencoded or
|
|
1174
|
+
# multipart bodies without a CORS preflight; demanding JSON forces the
|
|
1175
|
+
# preflight (and its same-origin/allow-list check) for any cross-site
|
|
1176
|
+
# caller, so a forged form cannot drive a mutation. Same-origin XHR/
|
|
1177
|
+
# fetch and server clients set application/json and are unaffected.
|
|
1178
|
+
unless request.content_type.to_s.downcase.include?("application/json")
|
|
1179
|
+
next response.json({ "data" => nil, "errors" => [{ "message" =>
|
|
1180
|
+
"GraphQL requires a Content-Type of application/json" }] }, 415)
|
|
1181
|
+
end
|
|
1086
1182
|
# handle_request expects the raw JSON text (it JSON.parses internally),
|
|
1087
1183
|
# so read body_raw — request.body now returns the PARSED payload.
|
|
1088
1184
|
body = request.body_raw
|
|
@@ -1090,13 +1186,15 @@ module Tina4
|
|
|
1090
1186
|
response.json(result)
|
|
1091
1187
|
end
|
|
1092
1188
|
|
|
1093
|
-
# Optional: GET for GraphiQL/introspection
|
|
1189
|
+
# Optional: GET for GraphiQL/introspection. Queries only — a mutation over
|
|
1190
|
+
# GET is refused (F4), since a cross-site GET carries the victim's cookies.
|
|
1094
1191
|
Tina4.get path, auth: false do |request, response|
|
|
1095
1192
|
query = request.query["query"]
|
|
1096
1193
|
if query
|
|
1097
1194
|
variables = request.query["variables"]
|
|
1098
1195
|
variables = Tina4.parse_json(variables) if variables.is_a?(String) && !variables.empty?
|
|
1099
|
-
result = graphql.execute(query, variables: variables || {},
|
|
1196
|
+
result = graphql.execute(query, variables: variables || {},
|
|
1197
|
+
context: { request: request }, allow_mutations: false)
|
|
1100
1198
|
response.json(result)
|
|
1101
1199
|
else
|
|
1102
1200
|
response.html(graphiql_html(path))
|
data/lib/tina4/job.rb
CHANGED
|
@@ -113,9 +113,22 @@ module Tina4
|
|
|
113
113
|
self
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
-
# Reject this job
|
|
116
|
+
# Reject this job permanently — dead-letter it NOW, no retry (ADR-0023).
|
|
117
|
+
#
|
|
118
|
+
# Distinct from fail(): fail() records a failed attempt and only
|
|
119
|
+
# dead-letters once max_retries is exhausted (the job is retried first).
|
|
120
|
+
# reject() is for a message the consumer KNOWS is poison — it goes straight
|
|
121
|
+
# to the dead-letter store on this call. AMQP basic.reject(requeue=false)
|
|
122
|
+
# semantics. Was a literal alias for fail() before 3.13.139.
|
|
117
123
|
def reject(reason = "")
|
|
118
|
-
|
|
124
|
+
unless @queue
|
|
125
|
+
@attempts += 1
|
|
126
|
+
@status = :dead
|
|
127
|
+
return self
|
|
128
|
+
end
|
|
129
|
+
@queue.backend.reject(self, reason)
|
|
130
|
+
@status = :dead
|
|
131
|
+
self
|
|
119
132
|
end
|
|
120
133
|
end
|
|
121
134
|
end
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -159,7 +159,9 @@ module Tina4
|
|
|
159
159
|
#
|
|
160
160
|
# Python master parity: tina4_python.mcp.is_loopback.
|
|
161
161
|
def self.is_loopback?(ip)
|
|
162
|
-
|
|
162
|
+
# An empty or missing peer is UNKNOWN and is not loopback: a runtime path
|
|
163
|
+
# that lost its socket address must fail closed (ADR-0079 s4).
|
|
164
|
+
return false if ip.nil? || ip.to_s.empty?
|
|
163
165
|
|
|
164
166
|
addr = ip.to_s.strip.downcase
|
|
165
167
|
addr = addr[7..] if addr.start_with?("::ffff:")
|
data/lib/tina4/middleware.rb
CHANGED
|
@@ -677,7 +677,9 @@ module Tina4
|
|
|
677
677
|
auth_header = (headers["authorization"] || headers["Authorization"] || "").to_s
|
|
678
678
|
if auth_header.start_with?("Bearer ")
|
|
679
679
|
bearer_token = auth_header[7..].to_s.strip
|
|
680
|
-
|
|
680
|
+
# Only an IDENTITY token marks an API client; a form token in the
|
|
681
|
+
# Bearer slot is not one and does not skip the check (ADR-0079 s1).
|
|
682
|
+
return [request, response] if !bearer_token.empty? && Tina4::Auth.identity_payload?(Tina4::Auth.valid_token(bearer_token))
|
|
681
683
|
end
|
|
682
684
|
|
|
683
685
|
# 7. A token in the query string leaks through logs/referers/history --
|
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"]
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
module Tina4
|
|
9
|
+
# Shared row/key symbolisation for the two SQLite classes.
|
|
10
|
+
#
|
|
11
|
+
# `symbolize_rows`/`symbolize_keys` used to be byte-identical copy-paste in
|
|
12
|
+
# Tina4::Drivers::SqliteDriver and Tina4::Adapters::Sqlite3Adapter (the
|
|
13
|
+
# adapter's own comment pointed at the driver's copy). This module is the
|
|
14
|
+
# single home for the logic; both classes `include` it as private instance
|
|
15
|
+
# methods, so both share ONE implementation and can never drift again.
|
|
16
|
+
#
|
|
17
|
+
# The mapping is computed ONCE per query rather than per cell: the per-row
|
|
18
|
+
# form ran `k.to_s.to_sym` and an `is_a?` guard PER CELL, so a 5,000-row x
|
|
19
|
+
# 6-column fetch did 30,000 conversions for 6 distinct keys and made Tina4 the
|
|
20
|
+
# slowest of the four frameworks at bulk reads (Select ALL 5,000 rows:
|
|
21
|
+
# 10.16ms vs raw sqlite3 2.56ms). Hoisting the mapping out of the loop roughly
|
|
22
|
+
# halves the hydration cost with byte-identical output.
|
|
23
|
+
#
|
|
24
|
+
# The is_a?(String|Symbol) guard is preserved: older sqlite3 gems put
|
|
25
|
+
# positional Integer keys in the hash alongside the string names, and those
|
|
26
|
+
# must still be dropped.
|
|
27
|
+
module RowSymbolizer
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# Symbolize a whole result set's keys, computing the mapping once per query.
|
|
31
|
+
def symbolize_rows(rows)
|
|
32
|
+
return rows if rows.empty?
|
|
33
|
+
|
|
34
|
+
str_keys = rows.first.keys.select { |k| k.is_a?(String) || k.is_a?(Symbol) }
|
|
35
|
+
sym_keys = str_keys.map(&:to_sym)
|
|
36
|
+
count = str_keys.length
|
|
37
|
+
rows.map do |row|
|
|
38
|
+
out = {}
|
|
39
|
+
i = 0
|
|
40
|
+
while i < count
|
|
41
|
+
out[sym_keys[i]] = row[str_keys[i]]
|
|
42
|
+
i += 1
|
|
43
|
+
end
|
|
44
|
+
out
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Single-hash convenience; delegates to the batch path so there is one
|
|
49
|
+
# implementation of the mapping rule.
|
|
50
|
+
def symbolize_keys(hash)
|
|
51
|
+
symbolize_rows([hash]).first
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -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
|