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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d0175b940e39eb271fcdda84cecc6893217d9d660febdcd0afc2f96d8c38dfe
4
- data.tar.gz: b44423de677c9523749b49db4814e156bc621090167a8870d0335ca46cef7af3
3
+ metadata.gz: 49ef08b44463d47d804c054dce8bb194276945134a618369e9d951ce239fd4cb
4
+ data.tar.gz: 69b761361564c42e91584a7029fcd5fb067d2d3dc9103614f37662a7a17be707
5
5
  SHA512:
6
- metadata.gz: eb1cf1ca28e038b3d3704fd41bfad3a3e176a114e0b82875ae26af0d0a15e7f8117003c6d0f48c5f44c87a3096eaa27f6683f944df80c04845536ee431ed782d
7
- data.tar.gz: e48d0a97a012fbc94b256dbddbf1fd911b39dfdff548e63516d9daf3f2b6aad2028e8706fe3da9c2f522aca427ab8440f5c24689d3f448161ea24f748fb91cc3
6
+ metadata.gz: c7db9c5c8d7c0242fdbb2e3a7b43b80b5567d46443a3b029e7e546daf4b8f6eb508b7671ae077e3a661e8f07bf15e0ba809fe9e5259e5bb7117ee67afc35828d
7
+ data.tar.gz: 975471f353c0f9804bc7678e9f2bafdd8f47d7eb44f50d54347ea08454b8560fc15b3bb8c740851cef08926a28f349e7a8121c5c09e7ca90f4e9a797f418ad0d
data/CHANGELOG.md CHANGED
@@ -6,6 +6,10 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.139
10
+
11
+ Outbound requests from the Api client and Web Push are guarded against SSRF: private, loopback and link-local targets are refused unless explicitly allowed (ADR-0084). The development admin surface is hardened -- the health endpoint no longer discloses the framework version outside debug, and development binds loopback by default (ADR-0078). Production refuses to boot with a blank or short HMAC secret so tokens cannot be forged (ADR-0079). Queue reject() dead-letters immediately and size("dead") reports the dead-letter depth (ADR-0022, ADR-0023). Session table first-use is concurrency-safe across every engine. Mail encryption follows the configured TLS/STARTTLS contract (ADR-0071). Log output, driver-install hints and WSDL/mail diagnostics redact credentials and URLs. Publishing is gated behind a protected environment with release actions pinned by commit SHA, and a medium-severity API token-leak path is closed. The framework still has no required runtime dependencies.
12
+
9
13
  ## 3.13.138
10
14
 
11
15
  HTTP clients strip configured-origin Authorization and Cookie credentials from off-origin initial targets across normal, upload, download and streaming paths. Forwarded Host and Proto require a trusted raw transport peer. Every development HTTP method uses the peer, Host and Origin boundary; resolved file paths and database identifiers are confined. Remote non-MCP development routes require the dedicated token while the accepted MCP transport API_KEY fallback remains supported. Development file readers validate and read one descriptor, preserving public in-project symlinks and existing size behavior. Database, MQTT and malformed Redis backplane diagnostics redact connection credentials; raw file responses preserve exact binary bytes.
data/lib/tina4/api.rb CHANGED
@@ -13,6 +13,7 @@ require "json"
13
13
  require_relative "base64"
14
14
  require "securerandom"
15
15
  require_relative "parse_json"
16
+ require_relative "ssrf"
16
17
 
17
18
  module Tina4
18
19
  # Statuses that warrant an automatic retry when max_retries > 0: rate-limit
@@ -108,7 +109,7 @@ module Tina4
108
109
  def initialize(base_url, headers: {}, timeout: 30,
109
110
  bearer_token: nil, username: nil, password: nil,
110
111
  verify_ssl: nil, max_retries: 0, retry_backoff: 0.5,
111
- transport: nil, cookies: false)
112
+ transport: nil, cookies: false, allow_hosts: nil)
112
113
  if !transport.nil? && !transport.respond_to?(:call)
113
114
  raise ArgumentError, "transport must respond to #call(method, url, headers, body, timeout)"
114
115
  end
@@ -130,6 +131,8 @@ module Tina4
130
131
  @transport = transport
131
132
  @cookies_enabled = cookies ? true : false
132
133
  @cookies = {}
134
+ # SSRF guard (ADR-0084): explicit allow-list of hosts / host:port / CIDRs.
135
+ @allow_hosts = allow_hosts || []
133
136
 
134
137
  # Bearer wins over basic-auth when both passed
135
138
  if bearer_token
@@ -455,6 +458,13 @@ module Tina4
455
458
  when "HEAD" then Net::HTTP::Head
456
459
  else raise ArgumentError, "unsupported stream method: #{method}"
457
460
  end
461
+ # SSRF guard (ADR-0084): refuse a private/internal target before connect.
462
+ begin
463
+ Tina4::Ssrf.guard_url!(uri, @allow_hosts)
464
+ rescue Tina4::SsrfError => e
465
+ raise APIStreamError.new(e.message)
466
+ end
467
+
458
468
  request = request_class.new(uri)
459
469
  apply_headers(request, headers || {})
460
470
  cookie = cookie_header
@@ -596,6 +606,12 @@ module Tina4
596
606
  current_body = body
597
607
 
598
608
  loop do
609
+ # SSRF guard (ADR-0084): re-validated per hop, so a redirect to a
610
+ # private/internal address is refused even when the initial URL was
611
+ # public. Raises SsrfError; attempt_request/download turn it into a
612
+ # status-0 error response.
613
+ Tina4::Ssrf.guard_url!(current_uri, @allow_hosts)
614
+
599
615
  http = Net::HTTP.new(current_uri.host, current_uri.port)
600
616
  http.use_ssl = current_uri.scheme == "https"
601
617
  # Only disable verification when EXPLICITLY false — nil/true keep the
data/lib/tina4/auth.rb CHANGED
@@ -68,13 +68,72 @@ module Tina4
68
68
  # rejected for no real reason; RFC 7519 explicitly allows "a small leeway".
69
69
  JWT_LEEWAY_SECONDS = 60
70
70
 
71
+ # Token purposes that are never an identity (ADR-0079 s1). Frond's
72
+ # form_token signs a JWT with the same TINA4_SECRET as an auth token, marked
73
+ # "type" => "form". A form token proves where a write came from, never who
74
+ # the caller is, so every identity gate refuses these purposes. valid_token
75
+ # itself stays purpose-neutral: CsrfMiddleware needs it for form tokens.
76
+ NON_IDENTITY_TOKEN_TYPES = %w[form].freeze
77
+
78
+ # Minimum HMAC key length in bytes (ADR-0079 s2): the HS256 output size,
79
+ # RFC 7518 s3.2. A blank key is the shortest case - anyone can reproduce an
80
+ # HMAC made with it.
81
+ MIN_SECRET_BYTES = 32
82
+
83
+ # The HMAC signing key is blank or shorter than MIN_SECRET_BYTES. An
84
+ # ArgumentError, Ruby's equivalent of Python's ValueError.
85
+ class InsecureSecretError < ArgumentError; end
86
+
71
87
  class << self
88
+ # Explicitly provision an RS256 key pair in <root_dir>/.keys (opt-in). The
89
+ # framework never calls this on its own: a blank TINA4_SECRET used to mint
90
+ # a key pair implicitly at boot (ADR-0079 s2).
72
91
  def setup(root_dir = Dir.pwd)
73
92
  @keys_dir = File.join(root_dir, KEYS_DIR)
74
93
  FileUtils.mkdir_p(@keys_dir)
75
94
  ensure_keys
76
95
  end
77
96
 
97
+ # Point RS256 at <root_dir>/.keys WITHOUT creating anything. Boot uses
98
+ # this, so an operator-provisioned key pair there keeps working.
99
+ def keys_root=(root_dir)
100
+ @keys_dir = File.join(root_dir, KEYS_DIR)
101
+ @private_key = nil
102
+ @public_key = nil
103
+ end
104
+
105
+ # True when a VERIFIED payload may stand for a caller's identity. A payload
106
+ # whose "type" is a reserved non-identity purpose (a Frond form token) is
107
+ # refused; application payloads are otherwise untouched.
108
+ def identity_payload?(payload)
109
+ payload.is_a?(Hash) && !NON_IDENTITY_TOKEN_TYPES.include?(payload["type"])
110
+ end
111
+
112
+ # The actionable error for a weak HMAC key, or nil when the key is usable.
113
+ def insecure_secret_message(secret)
114
+ length = secret.to_s.bytesize
115
+ return nil if length >= MIN_SECRET_BYTES
116
+
117
+ what = length.zero? ? "is not set" : "is #{length} bytes"
118
+ "Auth: TINA4_SECRET #{what}; an HMAC JWT secret must be at least " \
119
+ "#{MIN_SECRET_BYTES} bytes. Generate one with `openssl rand -hex 32` and set " \
120
+ "TINA4_SECRET in your environment or .env."
121
+ end
122
+
123
+ # Refuse to boot with a secret that makes tokens forgeable (ADR-0079 s2).
124
+ # Outside dev a blank TINA4_SECRET is refused; in any mode a set-but-short
125
+ # one is refused. An operator-provisioned RS256 key pair in .keys/ stands
126
+ # in for the secret. Raises InsecureSecretError with the actionable message.
127
+ def require_boot_secret!
128
+ return if rsa_keys_present?
129
+
130
+ secret = ENV["TINA4_SECRET"].to_s
131
+ return if secret.empty? && dev?
132
+
133
+ message = insecure_secret_message(secret)
134
+ raise InsecureSecretError, message if message
135
+ end
136
+
78
137
  # Boot-time bootstrap (run once after env load, before auth is used).
79
138
  #
80
139
  # Mirrors the Python master's tina4_python.auth.ensure_dev_secret:
@@ -123,15 +182,17 @@ module Tina4
123
182
 
124
183
  # ── HS256 helpers (stdlib only, no gem) ──────────────────────
125
184
 
126
- # Returns true when SECRET env var is set and no RSA keys exist in .keys/
185
+ # HMAC with TINA4_SECRET unless an RS256 key pair has been provisioned in
186
+ # .keys/ (kept for existing deployments). A blank secret no longer falls
187
+ # back to an implicitly generated key pair: it is refused (ADR-0079 s2).
127
188
  def use_hmac?
128
- secret = ENV["TINA4_SECRET"]
129
- return false if secret.nil? || secret.empty?
189
+ !rsa_keys_present?
190
+ end
130
191
 
131
- # If RSA keys already exist on disk, prefer RS256 for backward compat
192
+ def rsa_keys_present?
132
193
  @keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
133
- !(File.exist?(File.join(@keys_dir, "private.pem")) &&
134
- File.exist?(File.join(@keys_dir, "public.pem")))
194
+ File.exist?(File.join(@keys_dir, "private.pem")) &&
195
+ File.exist?(File.join(@keys_dir, "public.pem"))
135
196
  end
136
197
 
137
198
  # Lazy per-call secret resolver. When the secret is blank, emit the
@@ -180,6 +241,9 @@ module Tina4
180
241
  # HMAC the signing input with the digest the algorithm actually names, so the
181
242
  # header's "alg" can never disagree with the bytes we produced.
182
243
  def hmac_signature(algorithm, secret, signing_input)
244
+ weak = insecure_secret_message(secret)
245
+ raise InsecureSecretError, weak if weak
246
+
183
247
  OpenSSL::HMAC.digest(HMAC_ALGORITHMS.fetch(algorithm).new, secret.to_s, signing_input)
184
248
  end
185
249
 
@@ -253,6 +317,15 @@ module Tina4
253
317
  # configuration error that must surface, not become a nil "invalid token".
254
318
  alg = resolve_algorithm(algorithm)
255
319
 
320
+ # A blank or short key verifies tokens anyone can mint, so the token is
321
+ # REJECTED (fail closed with a 401, never a 500) and the operator is
322
+ # told why. ADR-0079 s2.
323
+ weak = insecure_secret_message(secret)
324
+ if weak
325
+ log_warning(weak)
326
+ return nil
327
+ end
328
+
256
329
  decode_envelope(token, alg) do |input, signature|
257
330
  expected = hmac_signature(alg, secret, input)
258
331
  # Constant-time comparison to prevent timing attacks. Lengths must match
@@ -310,7 +383,6 @@ module Tina4
310
383
  elsif use_hmac?
311
384
  hmac_encode(claims, hmac_secret, algorithm: algorithm)
312
385
  else
313
- ensure_keys
314
386
  rs256_encode(claims)
315
387
  end
316
388
  end
@@ -327,7 +399,6 @@ module Tina4
327
399
  if use_hmac?
328
400
  hmac_decode(token, hmac_secret) # returns Hash payload or nil
329
401
  else
330
- ensure_keys
331
402
  rs256_decode(token)
332
403
  end
333
404
  end
@@ -407,10 +478,11 @@ module Tina4
407
478
  # than this process's env-resolved defaults.
408
479
  payload = if secret || algorithm
409
480
  hmac_decode(token, secret || hmac_secret, algorithm: algorithm)
410
- elsif valid_token(token)
411
- get_payload(token)
481
+ else
482
+ valid_token(token)
412
483
  end
413
- return payload if payload
484
+ # A form token is not an identity (ADR-0079 s1).
485
+ return identity_payload?(payload) ? payload : nil if payload
414
486
 
415
487
  # API_KEY bypass — timing-safe comparison via validate_api_key
416
488
  # (OpenSSL.fixed_length_secure_compare). Parity with Python's
@@ -451,9 +523,13 @@ module Tina4
451
523
  token = Regexp.last_match(1)
452
524
 
453
525
  # JWT first, then the API key — same order and same payload shape as
454
- # authenticate_request above (and as Python/PHP/Node).
455
- if valid_token(token)
456
- env["tina4.auth"] = get_payload(token)
526
+ # authenticate_request above (and as Python/PHP/Node). A form token is
527
+ # not an identity (ADR-0079 s1).
528
+ payload = valid_token(token)
529
+ if payload
530
+ return false unless identity_payload?(payload)
531
+
532
+ env["tina4.auth"] = payload
457
533
  return true
458
534
  end
459
535
 
@@ -78,6 +78,14 @@ module Tina4
78
78
  def available?
79
79
  true
80
80
  end
81
+
82
+ # When available? is false because a required gem is not installed, the
83
+ # sentence that names the gem and the install command; nil otherwise.
84
+ #
85
+ # @return [String, nil]
86
+ def missing_driver_message
87
+ nil
88
+ end
81
89
  end
82
90
  end
83
91
  end
@@ -23,6 +23,7 @@ module Tina4
23
23
  @hits = 0
24
24
  @misses = 0
25
25
  @coll = nil
26
+ @driver_missing = false
26
27
  begin
27
28
  require "mongo"
28
29
  Mongo::Logger.logger.level = Logger::FATAL if defined?(Mongo::Logger)
@@ -39,7 +40,12 @@ module Tina4
39
40
  @coll = client[:tina4_cache]
40
41
  @coll.indexes.create_one({ expires_at: 1 }, expire_after: 0)
41
42
  client.database.command(ping: 1)
42
- rescue LoadError, StandardError
43
+ rescue LoadError
44
+ # Recorded apart from a service failure so the factory's fallback
45
+ # warning names the gem only when the gem is really what is missing.
46
+ @driver_missing = true
47
+ @coll = nil
48
+ rescue StandardError
43
49
  @coll = nil
44
50
  end
45
51
  end
@@ -48,6 +54,15 @@ module Tina4
48
54
  !@coll.nil?
49
55
  end
50
56
 
57
+ # The install hint for the factory's fallback warning, or nil when the
58
+ # gem loaded and the service is what failed.
59
+ def missing_driver_message
60
+ return nil unless @driver_missing
61
+
62
+ "The 'mongo' gem is required for the mongodb cache backend. Install it with: " \
63
+ "bundle add mongo (or add gem \"mongo\" to your Gemfile)"
64
+ end
65
+
51
66
  def get(key)
52
67
  if @coll.nil?
53
68
  @misses += 1
@@ -92,9 +92,11 @@ module Tina4
92
92
 
93
93
  # Configured backend unusable — degrade to the file backend.
94
94
  begin
95
+ install_hint = be.missing_driver_message
95
96
  Tina4::Log.warning(
96
97
  "Cache backend '#{backend}' is unavailable " \
97
- "(driver missing or service unreachable) — falling back to 'file'."
98
+ "(driver missing or service unreachable) — falling back to 'file'." \
99
+ "#{install_hint ? " #{install_hint}" : ""}"
98
100
  )
99
101
  rescue StandardError
100
102
  # Logging must never break cache construction.
data/lib/tina4/cli.rb CHANGED
@@ -715,6 +715,11 @@ module Tina4
715
715
 
716
716
  require_relative "../tina4"
717
717
 
718
+ # --production turns debug off: the explicit flag beats .env and the
719
+ # environment (ADR-0041, ADR-0079 s3). Set before the env files load,
720
+ # which are first-wins. It used to only choose Puma.
721
+ ENV["TINA4_DEBUG"] = "false" if options[:production]
722
+
718
723
  root_dir = Dir.pwd
719
724
  Tina4.initialize!(root_dir)
720
725
 
@@ -822,10 +822,17 @@ module Tina4
822
822
  resolved = begin
823
823
  safe_project_path(file_path, extra_roots: [scan_root])
824
824
  rescue ArgumentError
825
- begin
826
- scan_root ? safe_project_path(File.join(scan_root, file_path), extra_roots: [scan_root]) : nil
827
- rescue ArgumentError
828
- nil
825
+ # A RELATIVE path may be retried against the last scan root; an
826
+ # absolute path that resolved outside the project is simply outside -
827
+ # never re-based under the scan root (File.join would fold it into a
828
+ # bogus in-root path that 404s instead of refusing 403). Parity with
829
+ # Python/Node: outside -> 403.
830
+ if scan_root && !file_path.empty? && !File.absolute_path?(file_path)
831
+ begin
832
+ safe_project_path(File.join(scan_root, file_path), extra_roots: [scan_root])
833
+ rescue ArgumentError
834
+ nil
835
+ end
829
836
  end
830
837
  end
831
838
  return json_response({ "error" => "Path outside project" }, 403) if !file_path.empty? && resolved.nil?
@@ -39,6 +39,14 @@ module Tina4
39
39
 
40
40
  @connection = SQLite3::Database.new(db_path)
41
41
  @connection.results_as_hash = true
42
+ # Wait up to 5 s for another process's write lock instead of failing at
43
+ # once with SQLite3::BusyException. The sqlite3 gem's default is NO wait,
44
+ # so two processes writing the same file collided on the first overlap:
45
+ # MEASURED on the lab, five of six processes racing a first session write
46
+ # died with "database is locked". 5000 ms matches PHP's busyTimeout(5000)
47
+ # and Python's sqlite3 default. Set BEFORE the WAL pragma so that write
48
+ # waits too.
49
+ @connection.busy_timeout = 5000
42
50
  @connection.execute("PRAGMA journal_mode=WAL")
43
51
  @connection.execute("PRAGMA foreign_keys=ON")
44
52
  end
data/lib/tina4/env.rb CHANGED
@@ -5,8 +5,6 @@
5
5
  # License, v. 2.0. If a copy of the MPL was not distributed with this
6
6
  # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
7
 
8
- require "digest"
9
-
10
8
  module Tina4
11
9
  # Legacy env var names that v3.12 has retired. If any of these are set in
12
10
  # the environment we refuse to boot — silently ignoring them would cause
@@ -90,11 +88,15 @@ module Tina4
90
88
  # signal for Auth.ensure_dev_secret to mint a per-machine random dev secret
91
89
  # (saved to gitignored .env.local) in dev, or to emit the actionable
92
90
  # "set TINA4_SECRET" warning in CI/prod. Parity with the Python master.
91
+ #
92
+ # TINA4_DEBUG is deliberately absent too: unset debug means OFF, and a boot
93
+ # side effect must never switch it on (ADR-0079 s3). `tina4ruby init`, an
94
+ # explicit developer action, writes TINA4_DEBUG=true into the project it
95
+ # scaffolds.
93
96
  DEFAULT_ENV = {
94
97
  "PROJECT_NAME" => "Tina4 Ruby Project",
95
98
  "TINA4_SWAGGER_VERSION" => "1.0.0",
96
99
  "TINA4_LOCALE" => "en",
97
- "TINA4_DEBUG" => "true",
98
100
  "TINA4_LOG_LEVEL" => "ALL"
99
101
  }.freeze
100
102
 
@@ -266,11 +268,11 @@ module Tina4
266
268
  File.join(root_dir, ".env")
267
269
  end
268
270
 
271
+ # A missing .env gets the neutral defaults only. It no longer mints a
272
+ # TINA4_API_KEY: an MD5 of the clock is guessable, and that key opened
273
+ # the route auth gate and the dev-admin write gate (ADR-0079 s3).
269
274
  def create_default_env(path)
270
- api_key = Digest::MD5.hexdigest(Time.now.to_s)
271
- content = DEFAULT_ENV.map { |k, v| "#{k}=\"#{v}\"" }.join("\n")
272
- content += "\nTINA4_API_KEY=\"#{api_key}\"\n"
273
- File.write(path, content)
275
+ File.write(path, DEFAULT_ENV.map { |k, v| "#{k}=\"#{v}\"" }.join("\n") + "\n")
274
276
  end
275
277
 
276
278
  # Parse a dotenv file into ENV.
data/lib/tina4/frond.rb CHANGED
@@ -3258,9 +3258,11 @@ module Tina4
3258
3258
  sid = form_token_session_id.to_s
3259
3259
  payload["session_id"] = sid unless sid.empty?
3260
3260
 
3261
+ # get_token's expires_in is MINUTES, like every framework's. This used to
3262
+ # pass minutes * 60, so a form token lived 60 hours, not 60 minutes
3263
+ # (ADR-0079 s5).
3261
3264
  ttl_minutes = (ENV["TINA4_TOKEN_LIMIT"] || "60").to_i
3262
- expires_in = ttl_minutes * 60
3263
- Tina4::Auth.create_token(payload, expires_in: expires_in)
3265
+ Tina4::Auth.create_token(payload, expires_in: ttl_minutes)
3264
3266
  end
3265
3267
 
3266
3268
  def self.generate_form_token(descriptor = "")
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
- @executor = GraphQLExecutor.new(@schema, max_depth: @max_depth)
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, operation_name: operation_name)
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 || {}, context: { request: request })
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 with a reason. Alias for fail().
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
- fail(reason)
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
- return true if ip.nil? || ip.to_s.empty?
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:")
@@ -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
- return [request, response] if !bearer_token.empty? && Tina4::Auth.valid_token(bearer_token)
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 --