tina4ruby 3.13.149 → 3.13.151

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.
@@ -108,6 +108,8 @@ module Tina4
108
108
  # value. Cleared in `ensure`, exactly like the request id (an overlapping
109
109
  # request on another thread never shares it).
110
110
  Tina4::Csp.set_current_nonce(Tina4::Csp.generate_nonce)
111
+ # form_token() binds each token to this request's session (CSRF).
112
+ Tina4::Frond.request_env = env
111
113
 
112
114
  begin
113
115
  result = dispatch_pipeline(env)
@@ -121,6 +123,7 @@ module Tina4
121
123
  # nonce has the same lifetime.
122
124
  Tina4::Log.clear_request_id
123
125
  Tina4::Csp.clear_current_nonce
126
+ Tina4::Frond.request_env = nil
124
127
  end
125
128
  end
126
129
 
@@ -1247,7 +1250,7 @@ module Tina4
1247
1250
  # token in the body, auth token in the session) is still authenticated.
1248
1251
  candidates = []
1249
1252
  auth_header = env["HTTP_AUTHORIZATION"] || ""
1250
- candidates << [Regexp.last_match(1), :header] if auth_header =~ /\ABearer\s+(.+)\z/i
1253
+ candidates << [Regexp.last_match(1), :header] if auth_header =~ /\ABearer\s++(.+)\z/i
1251
1254
  form_token = _extract_form_token(_read_rack_body(env), env)
1252
1255
  candidates << [form_token, :body] if form_token && !form_token.empty?
1253
1256
 
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
8
+ require "net/http"
9
+ require "uri"
10
+ require "openssl"
11
+ require "digest"
12
+ require "time"
13
+
14
+ module Tina4
15
+ module Realtime
16
+ # An S3 request returned a non-2xx status; +status+ holds the HTTP code.
17
+ class S3Error < StandardError
18
+ attr_reader :status
19
+ attr_accessor :retry_after
20
+
21
+ def initialize(status, message)
22
+ super(message)
23
+ @status = status
24
+ @retry_after = nil
25
+ end
26
+ end
27
+
28
+ # The client cannot sign a request: no access key or secret was given.
29
+ class S3ConfigError < StandardError; end
30
+
31
+ # Zero-dependency S3 client (AWS Signature Version 4) on the Ruby standard
32
+ # library. Covers exactly what the realtime {S3Storage} backend needs — PUT,
33
+ # GET, DELETE, HEAD, and a presigned GET URL — so the +s3+ backend no longer
34
+ # needs the aws-sdk-s3 gem.
35
+ #
36
+ # Path-style addressing (<endpoint>/<bucket>/<key>), which S3-compatible
37
+ # servers (MinIO, Garage) require and AWS accepts; with no endpoint it
38
+ # targets https://s3.<region>.amazonaws.com. SigV4 is HMAC-SHA256 canonical
39
+ # signing — the same primitive Tina4's JWT already uses (stdlib OpenSSL);
40
+ # nothing here is reinvented crypto. Mirrors the Python master, proven
41
+ # against AWS's published SigV4 test vector.
42
+ class S3Client
43
+ ALGORITHM = "AWS4-HMAC-SHA256"
44
+ SERVICE = "s3"
45
+ # Retry policy, as for the AI client (ADR-0053): HTTP 429, any 5xx (S3's
46
+ # SlowDown is a 503) and a dropped connection are sent again, a bounded
47
+ # number of times, honouring Retry-After. Every operation here is idempotent.
48
+ MAX_RETRIES = 2
49
+ BACKOFF_BASE = 0.5 # seconds; retry n waits up to base * 2**n, jittered
50
+ BACKOFF_CAP = 20.0
51
+ # A connection refused, reset, dropped or timed out: nothing was answered.
52
+ RETRYABLE_NETWORK = [
53
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE, EOFError,
54
+ SocketError, IOError, Net::OpenTimeout, Net::ReadTimeout, Timeout::Error
55
+ ].freeze
56
+
57
+ def initialize(endpoint: nil, access_key: "", secret_key: "", region: "us-east-1", timeout: 30)
58
+ @region = region && !region.empty? ? region : "us-east-1"
59
+ @access_key = access_key || ""
60
+ @secret_key = secret_key || ""
61
+ @timeout = timeout
62
+ base = endpoint && !endpoint.empty? ? endpoint : "https://s3.#{@region}.amazonaws.com"
63
+ base = base.delete_suffix("/") while base.end_with?("/") # strip trailing slashes without a regex
64
+ @scheme = base[%r{\A([a-z][a-z0-9+.\-]*)://}i, 1] || "https"
65
+ authority = base.sub(%r{\A[a-z][a-z0-9+.\-]*://}i, "")
66
+ # Split authority (host[:port], the Host header + signed `host:`) from a
67
+ # path an endpoint carries (https://host/s3 -> "/s3"), which is kept in
68
+ # front of every request path and in what is signed.
69
+ slash = authority.index("/")
70
+ @host = slash ? authority[0...slash] : authority
71
+ @prefix = slash ? authority[slash..].delete_suffix("/") : ""
72
+ @prefix = @prefix.delete_suffix("/") while @prefix.end_with?("/")
73
+ end
74
+
75
+ # -- public API (the subset S3Storage uses) ------------------------------
76
+
77
+ def put_object(bucket, key, data, content_type = "application/octet-stream")
78
+ request("PUT", canonical_uri(bucket, key), data, { "content-type" => content_type })
79
+ end
80
+
81
+ def get_object(bucket, key)
82
+ request("GET", canonical_uri(bucket, key), "")[1]
83
+ end
84
+
85
+ def delete_object(bucket, key)
86
+ request("DELETE", canonical_uri(bucket, key), "")
87
+ nil
88
+ end
89
+
90
+ def head_object(bucket, key)
91
+ request("HEAD", canonical_uri(bucket, key), "")
92
+ nil
93
+ end
94
+
95
+ # Create the bucket (path-style PUT /<bucket>); an already-owned bucket (409) is success.
96
+ def create_bucket(bucket)
97
+ request("PUT", "#{@prefix}/#{self.class.uri_encode(bucket, true)}", "")
98
+ nil
99
+ rescue S3Error => e
100
+ raise unless e.status == 409
101
+ end
102
+
103
+ # A URL that fetches the object directly, signed via SigV4 query params.
104
+ def presigned_get_url(bucket, key, expires: 3600)
105
+ require_credentials
106
+ now = Time.now.utc
107
+ amz_date = now.strftime("%Y%m%dT%H%M%SZ")
108
+ datestamp = now.strftime("%Y%m%d")
109
+ uri = canonical_uri(bucket, key)
110
+ scope = "#{datestamp}/#{@region}/#{SERVICE}/aws4_request"
111
+ query = {
112
+ "X-Amz-Algorithm" => ALGORITHM,
113
+ "X-Amz-Credential" => "#{@access_key}/#{scope}",
114
+ "X-Amz-Date" => amz_date,
115
+ "X-Amz-Expires" => expires.to_s,
116
+ "X-Amz-SignedHeaders" => "host"
117
+ }
118
+ canonical_qs = canonical_query(query)
119
+ canonical_request = ["GET", uri, canonical_qs, "host:#{@host}\n", "host", "UNSIGNED-PAYLOAD"].join("\n")
120
+ signature = sign(canonical_request, amz_date, datestamp, scope)
121
+ "#{@scheme}://#{@host}#{uri}?#{canonical_qs}&X-Amz-Signature=#{signature}"
122
+ end
123
+
124
+ # SigV4 canonical encoding: keep the RFC 3986 unreserved set literal; keep
125
+ # "/" in a path, percent-encode it in a query; space -> %20.
126
+ def self.uri_encode(value, is_path)
127
+ value.to_s.b.each_byte.map do |byte|
128
+ ch = byte.chr
129
+ if ch.match?(/[A-Za-z0-9\-_.~]/) || (is_path && ch == "/")
130
+ ch
131
+ else
132
+ format("%%%02X", byte)
133
+ end
134
+ end.join
135
+ end
136
+
137
+ private
138
+
139
+ # Signing with an empty key only earns a 403 from the server, which says
140
+ # nothing about the cause. Name the settings instead, before sending.
141
+ def require_credentials
142
+ return unless @access_key.empty? || @secret_key.empty?
143
+
144
+ raise S3ConfigError, "S3 credentials are not set: set TINA4_STORAGE_KEY and TINA4_STORAGE_SECRET"
145
+ end
146
+
147
+ # Seconds from a Retry-After header in its delta-seconds form, else nil.
148
+ def retry_after_seconds(raw)
149
+ return nil if raw.nil?
150
+
151
+ value = Float(raw)
152
+ value.negative? ? 0.0 : value
153
+ rescue ArgumentError, TypeError
154
+ nil
155
+ end
156
+
157
+ def canonical_uri(bucket, key)
158
+ "#{@prefix}/#{self.class.uri_encode(bucket, true)}/#{self.class.uri_encode(key, true)}"
159
+ end
160
+
161
+ def canonical_query(query)
162
+ query.sort.map { |k, v| "#{self.class.uri_encode(k, false)}=#{self.class.uri_encode(v, false)}" }.join("&")
163
+ end
164
+
165
+ def signing_key(datestamp)
166
+ k_date = OpenSSL::HMAC.digest("SHA256", "AWS4#{@secret_key}", datestamp)
167
+ k_region = OpenSSL::HMAC.digest("SHA256", k_date, @region)
168
+ k_service = OpenSSL::HMAC.digest("SHA256", k_region, SERVICE)
169
+ OpenSSL::HMAC.digest("SHA256", k_service, "aws4_request")
170
+ end
171
+
172
+ def sign(canonical_request, amz_date, datestamp, scope)
173
+ string_to_sign = [ALGORITHM, amz_date, scope, Digest::SHA256.hexdigest(canonical_request)].join("\n")
174
+ OpenSSL::HMAC.hexdigest("SHA256", signing_key(datestamp), string_to_sign)
175
+ end
176
+
177
+ def request(method, canonical_uri, body, extra_headers = {})
178
+ require_credentials
179
+ attempt = 0
180
+ loop do
181
+ last = attempt == MAX_RETRIES
182
+ delay = nil
183
+ begin
184
+ # Signed afresh each attempt: x-amz-date moves on.
185
+ return send_once(method, canonical_uri, body, extra_headers)
186
+ rescue S3Error => e
187
+ raise if last || !(e.status == 429 || e.status >= 500)
188
+
189
+ delay = e.retry_after
190
+ rescue *RETRYABLE_NETWORK
191
+ raise if last
192
+ end
193
+ delay ||= rand * (BACKOFF_BASE * (2**attempt))
194
+ sleep([delay, BACKOFF_CAP].min)
195
+ attempt += 1
196
+ end
197
+ end
198
+
199
+ def send_once(method, canonical_uri, body, extra_headers = {})
200
+ now = Time.now.utc
201
+ amz_date = now.strftime("%Y%m%dT%H%M%SZ")
202
+ datestamp = now.strftime("%Y%m%d")
203
+ payload_hash = Digest::SHA256.hexdigest(body || "")
204
+
205
+ signed = { "host" => @host, "x-amz-content-sha256" => payload_hash, "x-amz-date" => amz_date }
206
+ canonical_headers = signed.sort.map { |k, v| "#{k}:#{v}\n" }.join
207
+ signed_headers = signed.keys.sort.join(";")
208
+ canonical_request = [method, canonical_uri, "", canonical_headers, signed_headers, payload_hash].join("\n")
209
+ scope = "#{datestamp}/#{@region}/#{SERVICE}/aws4_request"
210
+ signature = sign(canonical_request, amz_date, datestamp, scope)
211
+ authorization = "#{ALGORITHM} Credential=#{@access_key}/#{scope}, " \
212
+ "SignedHeaders=#{signed_headers}, Signature=#{signature}"
213
+
214
+ uri = URI("#{@scheme}://#{@host}#{canonical_uri}")
215
+ klass = { "PUT" => Net::HTTP::Put, "GET" => Net::HTTP::Get,
216
+ "DELETE" => Net::HTTP::Delete, "HEAD" => Net::HTTP::Head }.fetch(method)
217
+ req = klass.new(uri)
218
+ req["Authorization"] = authorization
219
+ req["x-amz-content-sha256"] = payload_hash
220
+ req["x-amz-date"] = amz_date
221
+ extra_headers.each { |name, value| req[name] = value }
222
+ req.body = body if %w[PUT POST].include?(method)
223
+
224
+ http = Net::HTTP.new(uri.host, uri.port)
225
+ http.use_ssl = uri.scheme == "https"
226
+ http.open_timeout = @timeout
227
+ http.read_timeout = @timeout
228
+ response = http.request(req)
229
+ status = response.code.to_i
230
+ if status < 200 || status >= 300
231
+ error = S3Error.new(status, "#{method} #{canonical_uri}: HTTP #{status}")
232
+ error.retry_after = retry_after_seconds(response["retry-after"])
233
+ raise error
234
+ end
235
+
236
+ [status, response.body || ""]
237
+ end
238
+ end
239
+ end
240
+ end
@@ -8,55 +8,42 @@
8
8
 
9
9
  module Tina4
10
10
  module Realtime
11
- # S3-compatible store (AWS S3, MinIO, ...). Opt-in; needs the aws-sdk-s3 gem.
11
+ # S3-compatible store (AWS S3, MinIO, Garage, ...). Opt-in, zero dependencies.
12
12
  #
13
- # Presigned GET URLs let clients fetch large blobs straight from object
14
- # storage instead of streaming through the app.
13
+ # Talks to S3 through Tina4's own stdlib SigV4 client ({S3Client}) — no
14
+ # aws-sdk-s3 gem. Presigned GET URLs let clients fetch large blobs straight
15
+ # from object storage instead of streaming through the app.
15
16
  class S3Storage < StorageBackend
16
17
  def initialize(endpoint: nil, key: nil, secret: nil, bucket: nil, region: nil)
17
18
  super()
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
27
-
28
19
  @bucket = bucket || ENV["TINA4_STORAGE_BUCKET"]
29
20
  raise ArgumentError, "S3Storage requires TINA4_STORAGE_BUCKET" if @bucket.nil? || @bucket.empty?
30
21
 
31
- opts = {
32
- access_key_id: key || ENV["TINA4_STORAGE_KEY"],
33
- secret_access_key: secret || ENV["TINA4_STORAGE_SECRET"],
34
- region: region || ENV["TINA4_STORAGE_REGION"] || "us-east-1",
35
- force_path_style: true
36
- }
37
- ep = endpoint || ENV["TINA4_STORAGE_URL"]
38
- opts[:endpoint] = ep if ep && !ep.empty?
39
- @client = Aws::S3::Client.new(**opts)
22
+ @client = S3Client.new(
23
+ endpoint: endpoint || ENV["TINA4_STORAGE_URL"],
24
+ access_key: key || ENV["TINA4_STORAGE_KEY"] || "",
25
+ secret_key: secret || ENV["TINA4_STORAGE_SECRET"] || "",
26
+ region: region || ENV["TINA4_STORAGE_REGION"] || "us-east-1"
27
+ )
40
28
  end
41
29
 
42
30
  def put(key, data, mime = "application/octet-stream")
43
- @client.put_object(bucket: @bucket, key: key, body: data, content_type: mime)
31
+ @client.put_object(@bucket, key, data, mime)
44
32
  nil
45
33
  end
46
34
 
47
35
  def get(key)
48
- @client.get_object(bucket: @bucket, key: key).body.read
36
+ @client.get_object(@bucket, key)
49
37
  rescue StandardError
50
38
  nil
51
39
  end
52
40
 
53
41
  def url(key, ttl = 3600)
54
- Aws::S3::Presigner.new(client: @client)
55
- .presigned_url(:get_object, bucket: @bucket, key: key, expires_in: ttl)
42
+ @client.presigned_get_url(@bucket, key, expires: ttl)
56
43
  end
57
44
 
58
45
  def delete(key)
59
- @client.delete_object(bucket: @bucket, key: key)
46
+ @client.delete_object(@bucket, key)
60
47
  nil
61
48
  rescue StandardError => e
62
49
  Tina4::Log.error("S3Storage delete failed for #{key}: #{e.message}")
@@ -64,7 +51,7 @@ module Tina4
64
51
  end
65
52
 
66
53
  def exists?(key)
67
- @client.head_object(bucket: @bucket, key: key)
54
+ @client.head_object(@bucket, key)
68
55
  true
69
56
  rescue StandardError
70
57
  false
@@ -39,11 +39,10 @@ module Tina4
39
39
  if name == "s3"
40
40
  begin
41
41
  return S3Storage.new
42
- # LoadError (missing aws-sdk-s3 gem) is a ScriptError, NOT a
43
- # StandardError, so it must be rescued explicitly or the "graceful
44
- # fallback when the driver is absent" contract breaks (the whole mount
45
- # would crash instead of degrading to local).
46
- rescue StandardError, LoadError => e
42
+ # S3Storage is zero-dependency now (Tina4's own SigV4 client, no gem),
43
+ # so the only construction failure is an incomplete config, e.g. a
44
+ # missing bucket (ArgumentError) — degrade to local rather than crash.
45
+ rescue StandardError => e
47
46
  Tina4::Log.warning(
48
47
  "realtime files: S3 storage unavailable (#{e.message}); " \
49
48
  "falling back to local filesystem storage."
@@ -18,6 +18,7 @@ require_relative "realtime/message"
18
18
  require_relative "realtime/attachment"
19
19
  require_relative "realtime/storage_backend"
20
20
  require_relative "realtime/local_storage"
21
+ require_relative "realtime/s3_client"
21
22
  require_relative "realtime/s3_storage"
22
23
  require_relative "realtime/storage"
23
24
  require_relative "parse_json"
@@ -80,7 +81,7 @@ module Tina4
80
81
  # @param features [Array<String>] any of "calls", "chat", "files"
81
82
  def mount(prefix: "", authorize: nil, storage: nil, features: nil)
82
83
  features = features || ["calls"]
83
- p = prefix.to_s.gsub(%r{\A/+|/+\z}, "")
84
+ p = prefix.to_s.gsub(%r{\A/++|/++\z}, "")
84
85
  p = p.empty? ? "" : "/#{p}"
85
86
  @authorize = authorize
86
87
  @storage = storage
data/lib/tina4/request.rb CHANGED
@@ -205,7 +205,7 @@ module Tina4
205
205
  FileUtils.mkdir_p(target_dir)
206
206
  dest = File.join(target_dir, base)
207
207
  # Defence in depth: the resolved parent of the destination must be exactly
208
- # the resolved target dir (guards a pre-existing symlink at target/base).
208
+ # the resolved target dir.
209
209
  real_dir = File.realpath(target_dir)
210
210
  real_parent = File.realpath(File.dirname(dest))
211
211
  unless real_parent == real_dir
@@ -213,10 +213,35 @@ module Tina4
213
213
  end
214
214
 
215
215
  content = file["content"] || file[:content] || ""
216
- File.binwrite(dest, content)
216
+ write_upload(dest, content)
217
217
  dest
218
218
  end
219
219
 
220
+ # Never write THROUGH a link already at target/base: a symlink (even a
221
+ # dangling one) leads out of target_dir, and a hard link shares its bytes
222
+ # with a file elsewhere. Such an entry is removed so the upload gets a file
223
+ # of its own; a plain file is rewritten in place and keeps its mode.
224
+ def self.write_upload(dest, content)
225
+ File.unlink(dest) if File.symlink?(dest)
226
+ flags = File::WRONLY | File::CREAT | File::BINARY | (defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0)
227
+ # 0o600: a newly created upload is owner-only, never group/other readable
228
+ # or writable (CWE-732). The mode applies only when CREAT makes the file;
229
+ # a plain file rewritten in place keeps its own mode, per the note above.
230
+ io = File.open(dest, flags, 0o600)
231
+ if io.stat.nlink > 1
232
+ io.close
233
+ File.unlink(dest)
234
+ io = File.open(dest, flags | File::EXCL, 0o600)
235
+ end
236
+ begin
237
+ io.truncate(0)
238
+ io.write(content)
239
+ ensure
240
+ io.close
241
+ end
242
+ end
243
+ private_class_method :write_upload
244
+
220
245
  def initialize(env, path_params = {})
221
246
  @env = env
222
247
  @method = env["REQUEST_METHOD"]
@@ -563,7 +588,7 @@ module Tina4
563
588
  body_raw
564
589
  end
565
590
  elsif @content_type.include?("application/x-www-form-urlencoded")
566
- parse_query_to_hash(body_raw)
591
+ parse_form_body(body_raw)
567
592
  elsif @content_type.include?("multipart/form-data")
568
593
  # Form fields from the parsed multipart body (Tina4::FormParser).
569
594
  # Files are handled separately by extract_files.
@@ -594,6 +619,19 @@ module Tina4
594
619
  result
595
620
  end
596
621
 
622
+ # An urlencoded body, nested the way a multipart form is
623
+ # (`tags[]=a&tags[]=b` -> {"tags" => ["a", "b"]}), so a form arrives in one
624
+ # shape however it was encoded. A field whose name conflicts with an earlier
625
+ # one (`a=1&a[b]=2`) keeps its flat key. The query string stays flat:
626
+ # AutoCrud reads `filter[...]` keys from it.
627
+ def parse_form_body(body)
628
+ pairs = body.split("&").map do |pair|
629
+ key, value = pair.split("=", 2)
630
+ [URI.decode_www_form_component(key.to_s), URI.decode_www_form_component(value.to_s)]
631
+ end
632
+ Tina4::FormParser.nest(pairs, lenient: true)
633
+ end
634
+
597
635
  def extract_files
598
636
  result = {}
599
637
  return result unless @content_type.include?("multipart/form-data")
@@ -502,7 +502,8 @@ module Tina4
502
502
  # Authorization, and Tina4's own session mechanism IS a cookie. Because
503
503
  # the key is method + URL, storing such a response replays one signed-in
504
504
  # user's page to whoever asks for that URL next; a response that installs
505
- # a session (Set-Cookie) is per-user by construction for the same reason.
505
+ # a session (Set-Cookie, or a session about to add one) is per-user by
506
+ # construction for the same reason.
506
507
  #
507
508
  # Both cookie cases stay cacheable when the response opts in explicitly with
508
509
  # a shared-cache directive, which is how a genuinely public page served to
@@ -517,6 +518,13 @@ module Tina4
517
518
  return shared_cache_allowed?(response) unless header_value(request, "Authorization").nil?
518
519
  return shared_cache_allowed?(response) unless header_value(request, "Cookie").nil?
519
520
  return shared_cache_allowed?(response) unless header_value(response, "Set-Cookie").nil?
521
+ # The session cookie is added after this check, so a response that is
522
+ # about to install one carries no Set-Cookie yet: a request that came
523
+ # without a cookie and leaves its session holding data (a form token
524
+ # bound to it marks it) is that case. Read the session only if the
525
+ # request already opened one; asking for it would open one.
526
+ session = request.instance_variable_get(:@session) if request.instance_variable_defined?(:@session)
527
+ return shared_cache_allowed?(response) if session.respond_to?(:fresh?) && !session.fresh?
520
528
 
521
529
  true
522
530
  end
data/lib/tina4/router.rb CHANGED
@@ -724,7 +724,7 @@ module Tina4
724
724
  # trailing slash on a prefix could leave "/api//users").
725
725
  def join_group_path(prefix, path)
726
726
  full = "#{prefix}/#{path.sub(%r{\A/+}, '')}"
727
- full = "/#{full.gsub(%r{\A/+|/+\z}, '')}"
727
+ full = "/#{full.gsub(%r{\A/++|/++\z}, '')}"
728
728
  full.gsub(%r{/+}, "/")
729
729
  end
730
730
 
@@ -316,11 +316,11 @@ module Tina4
316
316
  sql
317
317
  .gsub(/\bBIGINT\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/i, "BIGSERIAL PRIMARY KEY")
318
318
  .gsub(/\bINTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/i, "SERIAL PRIMARY KEY")
319
- .gsub(/\s*\bAUTOINCREMENT\b/i, "")
319
+ .gsub(/\s*+\bAUTOINCREMENT\b/i, "")
320
320
  when "mssql"
321
321
  sql.gsub(/AUTOINCREMENT/i, "IDENTITY(1,1)")
322
322
  when "firebird"
323
- sql.gsub(/\s*AUTOINCREMENT\b/i, "")
323
+ sql.gsub(/\s*+AUTOINCREMENT\b/i, "")
324
324
  else
325
325
  sql
326
326
  end
@@ -490,7 +490,9 @@ module Tina4
490
490
  "mariadb" => "mysql"
491
491
  }.freeze
492
492
 
493
- INSERT_VALUES = /\A\s*INSERT\s+INTO\s+.+?\s+VALUES\s*\(([^()]*)\)\s*\z/im
493
+ # ReDoS-safe: the table ref is bounded by non-space at both ends (\S(?:.*?\S)?)
494
+ # so the `\s+` before VALUES can't overlap it the way `.+?\s+` did.
495
+ INSERT_VALUES = /\A\s*INSERT\s+INTO\s+\S(?:.*?\S)?\s+VALUES\s*\(([^()]*)\)\s*\z/im
494
496
 
495
497
  # Engines whose last_insert_id reports the FIRST generated id of a multi-row
496
498
  # INSERT rather than the last. Verified live against MySQL.
@@ -232,7 +232,10 @@ module Tina4
232
232
  "nl2br" => ->(v) { v.to_s.gsub("\n", "<br>") },
233
233
  "number_format" => ->(v, d) { format("%.#{d || 0}f", v.to_f) },
234
234
  "raw" => ->(v) { v },
235
- "striptags" => ->(v) { v.to_s.gsub(/<[^>]+>/, "") },
235
+ # Fixed-point strip: a single gsub leaves a tag that only surfaces once the
236
+ # surrounding one is removed (`<scr<script>ipt>` -> `<script>`). Repeat
237
+ # until nothing more matches (incomplete multi-character sanitization).
238
+ "striptags" => ->(v) { s = v.to_s; loop { t = s.gsub(/<[^>]+>/, ""); break if t == s; s = t }; s },
236
239
  "sort" => ->(v) { v.respond_to?(:sort) ? v.sort : v },
237
240
  "keys" => ->(v) { v.respond_to?(:keys) ? v.keys : [] },
238
241
  "values" => ->(v) { v.respond_to?(:values) ? v.values : [v] },
@@ -272,8 +275,10 @@ module Tina4
272
275
  content = process_conditionals(content)
273
276
  content = process_set(content)
274
277
  content = process_expressions(content)
275
- content = content.gsub(/\{%.*?%\}/m, "")
276
- content = content.gsub(/\{#.*?#\}/m, "")
278
+ # ReDoS-safe: a tempered token ([^%] | % not before }) walks to the first
279
+ # %} in one pass — no lazy `.*?` re-scanning each `{%` start (that was O(n^2)).
280
+ content = content.gsub(/\{%(?:[^%]|%(?!\}))*%\}/m, "")
281
+ content = content.gsub(/\{#(?:[^#]|#(?!\}))*#\}/m, "")
277
282
  content
278
283
  end
279
284
 
@@ -295,7 +300,9 @@ module Tina4
295
300
  private
296
301
 
297
302
  def process_extends(content)
298
- if content =~ /\{%\s*extends\s+["'](.+?)["']\s*%\}/
303
+ # ReDoS-safe: a negated class ([^"']*) for the quoted path can't backtrack
304
+ # across the closing quote the way a lazy (.+?) does.
305
+ if content =~ /\{%\s*extends\s+["']([^"']*)["']\s*%\}/
299
306
  parent_path = Regexp.last_match(1)
300
307
  full_parent = resolve_template(parent_path)
301
308
  if full_parent && File.exist?(full_parent)
@@ -358,7 +365,7 @@ module Tina4
358
365
  # grandparent, blocks are merged (child overrides parent) and the
359
366
  # chain is followed recursively.
360
367
  def render_with_blocks(parent_source, child_blocks)
361
- extends_re = /\A\s*\{%\s*extends\s+["'](.+?)["']\s*%\}/
368
+ extends_re = /\A\s*\{%\s*extends\s+["']([^"']*)["']\s*%\}/
362
369
 
363
370
  # Multi-level: if the parent itself extends another template, recurse
364
371
  if parent_source =~ extends_re
@@ -375,7 +382,7 @@ module Tina4
375
382
 
376
383
  # Resolve nested blocks: if a block value contains {% block inner %}
377
384
  # tags, replace them with merged_blocks values too
378
- block_re = /\{%[-\s]*block\s+(\w+)\s*-?%\}(.*?)\{%[-\s]*endblock\s*-?%\}/m
385
+ block_re = /\{%[-\s]*block\s+(\w+)\s*-?%\}((?:[^{]|\{(?!%[-\s]*endblock\s*-?%\}))*)\{%[-\s]*endblock\s*-?%\}/m
379
386
  changed = true
380
387
  while changed
381
388
  changed = false
@@ -398,7 +405,7 @@ module Tina4
398
405
  end
399
406
 
400
407
  # Leaf parent (no extends) — resolve blocks and render
401
- parent_source.gsub(/\{%[-\s]*block\s+(\w+)\s*-?%\}(.*?)\{%[-\s]*endblock\s*-?%\}/m) do
408
+ parent_source.gsub(/\{%[-\s]*block\s+(\w+)\s*-?%\}((?:[^{]|\{(?!%[-\s]*endblock\s*-?%\}))*)\{%[-\s]*endblock\s*-?%\}/m) do
402
409
  name = Regexp.last_match(1)
403
410
  default_body = Regexp.last_match(2)
404
411
  child_blocks[name] || default_body
@@ -406,7 +413,7 @@ module Tina4
406
413
  end
407
414
 
408
415
  def process_blocks(content)
409
- content.gsub(/\{%\s*block\s+(\w+)\s*%\}(.*?)\{%\s*endblock\s*%\}/m) do
416
+ content.gsub(/\{%\s*block\s+(\w+)\s*%\}((?:[^{]|\{(?!%\s*endblock\s*%\}))*)\{%\s*endblock\s*%\}/m) do
410
417
  name = Regexp.last_match(1)
411
418
  default_body = Regexp.last_match(2)
412
419
  @blocks[name] || default_body
@@ -414,7 +421,9 @@ module Tina4
414
421
  end
415
422
 
416
423
  def process_includes(content)
417
- content.gsub(/\{%\s*include\s+["'](.+?)["'](?:\s+with\s+(.+?))?\s*%\}/) do
424
+ # ReDoS-safe: negated class for the quoted path, and the (unused) `with`
425
+ # clause is consumed by a tempered token instead of a lazy (.+?)\s* pair.
426
+ content.gsub(/\{%\s*include\s+["']([^"']*)["'](?:\s+with\s++(?:[^%]|%(?!\}))*+)?\s*+%\}/) do
418
427
  inc_path = Regexp.last_match(1)
419
428
  full_path = resolve_template(inc_path)
420
429
  if full_path && File.exist?(full_path)
@@ -430,10 +439,10 @@ module Tina4
430
439
  max_depth = 10
431
440
  depth = 0
432
441
  while content =~ /\{%\s*for\s+/ && depth < max_depth
433
- content = content.gsub(/\{%\s*for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+?)\s*%\}(.*?)\{%\s*endfor\s*%\}/m) do
442
+ content = content.gsub(/\{%\s*for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s++((?:[^%]|%(?!\}))++)%\}((?:[^{]|\{(?!%\s*endfor\s*%\}))*+)\{%\s*endfor\s*%\}/m) do
434
443
  key_or_val = Regexp.last_match(1)
435
444
  val_name = Regexp.last_match(2)
436
- collection_expr = Regexp.last_match(3)
445
+ collection_expr = Regexp.last_match(3).strip
437
446
  body = Regexp.last_match(4)
438
447
  collection = evaluate_expression(collection_expr)
439
448
  output = +""
@@ -475,9 +484,11 @@ module Tina4
475
484
  depth = 0
476
485
  while content =~ /\{%\s*if\s+/ && depth < max_depth
477
486
  content = content.gsub(
478
- /\{%\s*if\s+(.+?)\s*%\}(.*?)(?:\{%\s*else\s*%\}(.*?))?\{%\s*endif\s*%\}/m
487
+ # ReDoS-safe: tempered tokens — condition walks to %}, bodies stop at
488
+ # the first else/endif, so no lazy (.+?)/\s* overlap to backtrack on.
489
+ /\{%\s*if\s++((?:[^%]|%(?!\}))++)%\}((?:[^{]|\{(?!%\s*(?:else|endif)\s*%\}))*+)(?:\{%\s*else\s*%\}((?:[^{]|\{(?!%\s*endif\s*%\}))*+))?\{%\s*endif\s*%\}/m
479
490
  ) do
480
- condition = Regexp.last_match(1)
491
+ condition = Regexp.last_match(1).strip
481
492
  true_body = Regexp.last_match(2)
482
493
  false_body = Regexp.last_match(3) || ""
483
494
 
@@ -511,17 +522,21 @@ module Tina4
511
522
  end
512
523
 
513
524
  def process_set(content)
514
- content.gsub(/\{%\s*set\s+(\w+)\s*=\s*(.+?)\s*%\}/) do
525
+ content.gsub(/\{%\s*set\s+(\w+)\s*=((?:[^%]|%(?!\}))+)%\}/) do
515
526
  var_name = Regexp.last_match(1)
516
- expr = Regexp.last_match(2)
527
+ expr = Regexp.last_match(2).strip
517
528
  @context[var_name] = evaluate_expression(expr)
518
529
  ""
519
530
  end
520
531
  end
521
532
 
522
533
  def process_expressions(content)
523
- content.gsub(/\{\{\s*(.+?)\s*\}\}/) do
524
- expr = Regexp.last_match(1)
534
+ # ReDoS-safe: the old /\{\{\s*(.+?)\s*\}\}/ let the surrounding \s* AND the
535
+ # lazy (.+?) both match spaces — two ways to assign the whitespace, so
536
+ # polynomial backtracking. A tempered token ([^}] | } not before }) walks
537
+ # to the first }} in one deterministic pass; strip the whitespace in code.
538
+ content.gsub(/\{\{((?:[^}]|\}(?!\}))++)\}\}/) do
539
+ expr = Regexp.last_match(1).strip
525
540
  evaluate_piped_expression(expr).to_s
526
541
  end
527
542
  end