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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 49ef08b44463d47d804c054dce8bb194276945134a618369e9d951ce239fd4cb
|
|
4
|
+
data.tar.gz: 69b761361564c42e91584a7029fcd5fb067d2d3dc9103614f37662a7a17be707
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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
|
-
|
|
129
|
-
|
|
189
|
+
!rsa_keys_present?
|
|
190
|
+
end
|
|
130
191
|
|
|
131
|
-
|
|
192
|
+
def rsa_keys_present?
|
|
132
193
|
@keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
|
|
133
|
-
|
|
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
|
-
|
|
411
|
-
|
|
481
|
+
else
|
|
482
|
+
valid_token(token)
|
|
412
483
|
end
|
|
413
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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
|
|
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
|
data/lib/tina4/cache_backends.rb
CHANGED
|
@@ -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
|
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 --
|