tina4ruby 3.13.138 → 3.13.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,72 @@
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 "fileutils"
9
+
10
+ module Tina4
11
+ # Shared SQLite database-path resolution for the two SQLite resolvers.
12
+ #
13
+ # `resolve_path` used to be copy-pasted into Tina4::Drivers::SqliteDriver and
14
+ # Tina4::Adapters::Sqlite3Adapter with drifted scheme-stripping. This module is
15
+ # the single home for the logic; both classes `extend` it, so both keep a
16
+ # `.resolve_path` class method backed by ONE implementation and can never
17
+ # drift again.
18
+ #
19
+ # Convention (matches tina4-python, tina4-php, tina4-nodejs):
20
+ # sqlite::memory: → :memory:
21
+ # sqlite:///:memory: → :memory:
22
+ # sqlite:///app.db → {cwd}/app.db (relative)
23
+ # sqlite:///data/app.db → {cwd}/data/app.db (relative; auto-mkdir under cwd)
24
+ # sqlite:////var/data/app.db → /var/data/app.db (absolute; no auto-mkdir)
25
+ # sqlite:///C:/Users/app.db → C:/Users/app.db (Windows absolute)
26
+ #
27
+ # `sqlite3:` is a documented alias for `sqlite:` and is normalised first.
28
+ # Never mkdir outside cwd — that was the root cause of the
29
+ # "Read-only file system: '/data'" crash on macOS.
30
+ module SqlitePathResolver
31
+ # Resolve a SQLite URL / path against the project root (cwd).
32
+ #
33
+ # @param connection_string [String] a sqlite: / sqlite3: URL or bare path
34
+ # @return [String] the resolved path to hand to the driver, or ":memory:"
35
+ def resolve_path(connection_string)
36
+ return ":memory:" if connection_string == "sqlite::memory:" || connection_string == "sqlite:///:memory:"
37
+
38
+ # `sqlite3:` is a documented alias for `sqlite:`; normalise it FIRST or the
39
+ # strips below miss and the file ends up literally named "sqlite3:app.db"
40
+ # (and a colon is an illegal filename character on Windows).
41
+ normalised = connection_string.to_s.sub(/^sqlite3:/, "sqlite:")
42
+ raw = normalised.sub(%r{^sqlite:///}, "").sub(%r{^sqlite://}, "").sub(/^sqlite:/, "")
43
+ return ":memory:" if raw == ":memory:"
44
+
45
+ is_windows_abs = raw.match?(%r{^[A-Za-z]:[/\\]})
46
+ is_unix_abs = raw.start_with?("/")
47
+
48
+ if is_windows_abs || is_unix_abs
49
+ # Absolute — trust the user; don't auto-mkdir outside cwd.
50
+ raw
51
+ else
52
+ # Relative — resolve under cwd; create the parent (mode 0775) ONLY when
53
+ # it stays inside cwd (ADR-0086). A relative path whose parent escapes
54
+ # the project (e.g. "../../etc/foo.db") is REFUSED loudly here — never
55
+ # the silent mkdir outside the project that used to happen.
56
+ resolved = File.join(Dir.pwd, raw)
57
+ parent = File.dirname(resolved)
58
+ expanded_parent = File.expand_path(parent)
59
+ cwd = File.expand_path(Dir.pwd)
60
+ unless expanded_parent == cwd || expanded_parent.start_with?(cwd + File::SEPARATOR)
61
+ raise ArgumentError,
62
+ "SQLite path #{connection_string.inspect} resolves outside the project " \
63
+ "directory: #{expanded_parent.inspect} is not within #{cwd.inspect}. " \
64
+ "Tina4 refuses to create directories outside the project (ADR-0086). " \
65
+ "Use an absolute path for a database that lives outside the project."
66
+ end
67
+ FileUtils.mkdir_p(parent, mode: 0o775) unless File.directory?(parent)
68
+ resolved
69
+ end
70
+ end
71
+ end
72
+ end
data/lib/tina4/sso.rb CHANGED
@@ -22,6 +22,30 @@ module Tina4
22
22
  class Sso
23
23
  PENDING_KEY = "_tina4_sso_pending"
24
24
  SESSION_KEY = "_tina4_sso"
25
+
26
+ # The stored SSO identity, only while it is live (ADR-0079 s5). +stored+ is
27
+ # the reserved _tina4_sso session value. The identity needs an issuer and a
28
+ # subject, and a numeric expires_at that is 0 (the provider gave no
29
+ # lifetime) or still in the future.
30
+ def self.live_session_identity(stored)
31
+ return nil unless stored.is_a?(Hash)
32
+
33
+ identity = stored["identity"]
34
+ return nil unless identity.is_a?(Hash) && identity["issuer"] && identity["subject"]
35
+
36
+ expires_at = stored.fetch("expires_at", 0)
37
+ return nil unless expires_at.is_a?(Integer) || expires_at.is_a?(Float)
38
+ return nil if expires_at.positive? && Time.now.to_i >= expires_at
39
+
40
+ identity
41
+ end
42
+
43
+ # When the provider's access token lapses; 0 when it gave no lifetime.
44
+ # Never "now", which is already in the past by the next request.
45
+ def self.expires_at(tokens)
46
+ lifetime = tokens.fetch("expires_in", 0).to_i
47
+ lifetime.positive? ? Time.now.to_i + lifetime : 0
48
+ end
25
49
  attr_reader :issuer, :client_id, :client_secret, :redirect_uri, :scopes,
26
50
  :verify, :post_logout_redirect_uri, :claim_map
27
51
  @mounted = false
@@ -221,14 +245,13 @@ module Tina4
221
245
  current.set(SESSION_KEY, {
222
246
  "version" => 1, "identity" => identity, "access_token" => tokens["access_token"],
223
247
  "refresh_token" => tokens["refresh_token"], "id_token" => tokens["id_token"],
224
- "expires_at" => Time.now.to_i + tokens.fetch("expires_in", 0).to_i
248
+ "expires_at" => self.class.expires_at(tokens)
225
249
  })
226
250
  { "identity" => identity, "return_to" => self.class.safe_return(pending["return_to"]) }
227
251
  end
228
252
 
229
253
  def identity(request_or_session)
230
- stored = session(request_or_session)&.get(SESSION_KEY)
231
- value = stored.is_a?(Hash) ? stored["identity"] : nil
254
+ value = self.class.live_session_identity(session(request_or_session)&.get(SESSION_KEY))
232
255
  request_or_session.user = value if value && !request_or_session.is_a?(Tina4::Session)
233
256
  value
234
257
  end
@@ -251,7 +274,7 @@ module Tina4
251
274
  "identity" => value, "access_token" => tokens["access_token"],
252
275
  "refresh_token" => tokens["refresh_token"] || stored["refresh_token"],
253
276
  "id_token" => tokens["id_token"] || stored["id_token"],
254
- "expires_at" => Time.now.to_i + tokens.fetch("expires_in", 0).to_i
277
+ "expires_at" => self.class.expires_at(tokens)
255
278
  ))
256
279
  value
257
280
  rescue StandardError
data/lib/tina4/ssrf.rb ADDED
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
8
+ require "ipaddr"
9
+ require "resolv"
10
+ require "socket"
11
+ require "uri"
12
+
13
+ module Tina4
14
+ # Raised when the SSRF guard refuses an outbound request.
15
+ class SsrfError < StandardError; end
16
+
17
+ # Outbound SSRF guard (ADR-0084).
18
+ #
19
+ # The Api client and Web Push refuse, by default, to connect to a private or
20
+ # internal address. Before each connection - the initial URL and every redirect
21
+ # hop the Api client follows - the host is resolved to its IP address(es) and
22
+ # the request is refused if any resolved address is loopback, private,
23
+ # link-local (including the cloud metadata address 169.254.169.254), unspecified
24
+ # or CGNAT. A non-http(s) scheme is refused.
25
+ #
26
+ # Off by default; TINA4_ALLOW_PRIVATE_REQUESTS (truthy) or an explicit allow-list
27
+ # of hosts / host:port / CIDRs opts out. Zero external dependencies - Ruby
28
+ # stdlib IPAddr + Resolv.
29
+ module Ssrf
30
+ ALLOW_PRIVATE_ENV = "TINA4_ALLOW_PRIVATE_REQUESTS"
31
+
32
+ # Truthiness identical to ADR-0070's set (trimmed, lower-cased).
33
+ TRUTHY = %w[1 true yes on].freeze
34
+
35
+ # The blocked address space, one definition shared with the contract fixture.
36
+ # 0.0.0.0/8 covers the unspecified address and the "this network" block.
37
+ BLOCKED = %w[
38
+ 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16
39
+ 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10
40
+ ::1/128 ::/128 fc00::/7 fe80::/10
41
+ ].map { |cidr| IPAddr.new(cidr) }.freeze
42
+
43
+ module_function
44
+
45
+ # True when TINA4_ALLOW_PRIVATE_REQUESTS opts out of the guard.
46
+ def allow_private_requests?
47
+ TRUTHY.include?(ENV.fetch(ALLOW_PRIVATE_ENV, "").strip.downcase)
48
+ end
49
+
50
+ # Classify one IP string: true = private/internal, refuse it. An IPv4-mapped
51
+ # IPv6 address is classified by its embedded IPv4. An unparseable value is
52
+ # treated as blocked - the guard refuses what it cannot classify.
53
+ def blocked_address?(ip)
54
+ addr = IPAddr.new(ip.to_s.split("%").first)
55
+ addr = addr.native if addr.ipv6? && addr.ipv4_mapped?
56
+ BLOCKED.any? { |network| network.include?(addr) }
57
+ rescue IPAddr::Error
58
+ true
59
+ end
60
+
61
+ # Refuse +url+ when it targets a private/internal address. Raises SsrfError
62
+ # for a non-http(s) scheme, an unresolvable host, or any resolved address in
63
+ # the blocked space. allow_hosts is a list of hosts / host:port / CIDRs.
64
+ def guard_url!(url, allow_hosts = nil)
65
+ uri = url.is_a?(URI) ? url : URI.parse(url.to_s)
66
+ scheme = uri.scheme&.downcase
67
+ unless %w[http https].include?(scheme)
68
+ raise SsrfError, "Blocked request: URL scheme '#{scheme || '(none)'}' is not http or https"
69
+ end
70
+ host = uri.host.to_s
71
+ raise SsrfError, "Blocked request: URL has no host" if host.empty?
72
+
73
+ host = host.delete_prefix("[").delete_suffix("]")
74
+ port = uri.port || (scheme == "https" ? 443 : 80)
75
+
76
+ return if allow_private_requests?
77
+
78
+ resolved = resolve(host)
79
+ return if matches_allow_list?(host, port, resolved, allow_hosts)
80
+
81
+ resolved.each do |ip|
82
+ next unless blocked_address?(ip)
83
+
84
+ log_block(host, ip)
85
+ raise SsrfError,
86
+ "Blocked request to private/internal address #{ip} (host #{host}): " \
87
+ "set #{ALLOW_PRIVATE_ENV}=true to allow, or pass an allow-list."
88
+ end
89
+ end
90
+
91
+ # Resolve a host to its IP address(es). An IP literal resolves to itself.
92
+ def resolve(host)
93
+ begin
94
+ IPAddr.new(host)
95
+ return [host]
96
+ rescue IPAddr::Error
97
+ # not a literal - fall through to DNS
98
+ end
99
+ addresses = begin
100
+ Resolv.getaddresses(host)
101
+ rescue StandardError
102
+ []
103
+ end
104
+ if addresses.empty?
105
+ begin
106
+ addresses = [IPSocket.getaddress(host)]
107
+ rescue SocketError
108
+ addresses = []
109
+ end
110
+ end
111
+ raise SsrfError, "Blocked request to #{host}: cannot resolve host" if addresses.empty?
112
+
113
+ addresses
114
+ end
115
+
116
+ def matches_allow_list?(host, port, resolved, allow_hosts)
117
+ host_lower = host.downcase
118
+ Array(allow_hosts).each do |raw|
119
+ entry = raw.to_s.strip.downcase
120
+ next if entry.empty?
121
+ return true if entry == host_lower || entry == "#{host_lower}:#{port}"
122
+
123
+ next unless entry.include?("/")
124
+
125
+ begin
126
+ network = IPAddr.new(entry)
127
+ rescue IPAddr::Error
128
+ next
129
+ end
130
+ resolved.each do |ip|
131
+ begin
132
+ candidate = IPAddr.new(ip.to_s.split("%").first)
133
+ rescue IPAddr::Error
134
+ next
135
+ end
136
+ return true if network.include?(candidate)
137
+ end
138
+ end
139
+ false
140
+ end
141
+
142
+ def log_block(host, ip)
143
+ Tina4::Log.warning("SSRF guard blocked outbound request to #{host} (#{ip})") if defined?(Tina4::Log)
144
+ rescue StandardError
145
+ nil
146
+ end
147
+ end
148
+ end
data/lib/tina4/version.rb CHANGED
@@ -7,5 +7,5 @@
7
7
 
8
8
 
9
9
  module Tina4
10
- VERSION = "3.13.138"
10
+ VERSION = "3.13.140"
11
11
  end
@@ -113,7 +113,10 @@ module Tina4
113
113
  return [nil, false] unless token
114
114
 
115
115
  payload = Tina4::Auth.valid_token(token)
116
- [payload, !payload.nil?]
116
+ # A form token is not an identity (ADR-0079 s1).
117
+ return [nil, false] unless Tina4::Auth.identity_payload?(payload)
118
+
119
+ [payload, true]
117
120
  end
118
121
 
119
122
  # Whether the client offered the "bearer" subprotocol — in which case the
@@ -199,6 +202,17 @@ module Tina4
199
202
  @connections
200
203
  end
201
204
 
205
+ # Seconds a client may take to send its upgrade request (Python uses 10).
206
+ HANDSHAKE_TIMEOUT = 10
207
+ # Upper bound on the request head; a longer one is not a WebSocket upgrade.
208
+ MAX_REQUEST_HEAD = 16_384
209
+
210
+ # Run a standalone WebSocket server. port: 0 picks a free port (see #port).
211
+ #
212
+ # Each accepted socket gets its own thread that reads the HTTP request head,
213
+ # builds the Rack-style env handle_upgrade expects, and upgrades. This loop
214
+ # used to hand handle_upgrade an EMPTY env, so no Sec-WebSocket-Key was ever
215
+ # seen and no handshake could complete (the socket was left open, too).
202
216
  def start(host: "0.0.0.0", port: 7147)
203
217
  require "socket"
204
218
  @server_socket = TCPServer.new(host, port)
@@ -207,9 +221,8 @@ module Tina4
207
221
  while @running
208
222
  begin
209
223
  client = @server_socket.accept
210
- env = {}
211
- handle_upgrade(env, client)
212
- rescue => e
224
+ Thread.new(client) { |socket| serve_standalone(socket) }
225
+ rescue StandardError
213
226
  break unless @running
214
227
  end
215
228
  end
@@ -217,6 +230,53 @@ module Tina4
217
230
  self
218
231
  end
219
232
 
233
+ # The port the standalone server is listening on (useful with port: 0).
234
+ def port
235
+ @server_socket&.local_address&.ip_port
236
+ end
237
+
238
+ # Read one upgrade request from +socket+ and hand it to handle_upgrade; a
239
+ # request that is not a WebSocket upgrade gets a 400 and is closed.
240
+ def serve_standalone(socket)
241
+ env = read_upgrade_request(socket)
242
+ unless env && env["HTTP_SEC_WEBSOCKET_KEY"] && upgrade?(env)
243
+ socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n") rescue nil
244
+ socket.close rescue nil
245
+ return
246
+ end
247
+ handle_upgrade(env, socket)
248
+ rescue StandardError => e
249
+ Tina4::Log.warning("WebSocket standalone upgrade failed: #{e.message}") if defined?(Tina4::Log)
250
+ socket.close rescue nil
251
+ end
252
+
253
+ # The request head as a Rack-style env (REQUEST_PATH, QUERY_STRING and
254
+ # HTTP_* headers), or nil when it does not arrive in time or is too long.
255
+ # Read a byte at a time so nothing after the head is consumed.
256
+ def read_upgrade_request(socket)
257
+ head = +""
258
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + HANDSHAKE_TIMEOUT
259
+ until head.end_with?("\r\n\r\n")
260
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
261
+ return nil if remaining <= 0 || head.bytesize > MAX_REQUEST_HEAD || !socket.wait_readable(remaining)
262
+
263
+ head << socket.readpartial(1)
264
+ end
265
+ request_line, *header_lines = head.split("\r\n")
266
+ method, target, = request_line.to_s.split(" ")
267
+ path, _, query = target.to_s.partition("?")
268
+ env = { "REQUEST_METHOD" => method.to_s, "REQUEST_PATH" => path, "PATH_INFO" => path, "QUERY_STRING" => query }
269
+ header_lines.each do |line|
270
+ name, value = line.split(":", 2)
271
+ next if value.nil?
272
+
273
+ env["HTTP_#{name.strip.upcase.tr("-", "_")}"] = value.strip
274
+ end
275
+ env
276
+ rescue IOError, SystemCallError
277
+ nil
278
+ end
279
+
220
280
  def stop
221
281
  @running = false
222
282
  @server_socket&.close rescue nil
@@ -739,13 +799,14 @@ module Tina4
739
799
 
740
800
  def send(message)
741
801
  data = message.is_a?(String) ? message : message.to_s
742
- # Text frames must be valid UTF-8; binary payloads are sent verbatim.
743
- data = data.encode("UTF-8") if data.encoding != Encoding::ASCII_8BIT
744
- frame = build_frame(0x1, data)
745
- @socket.write(frame)
746
- rescue IOError
747
- # Connection closed — mark dead so the broadcast path prunes it.
748
- @closed = true
802
+ # A binary (ASCII-8BIT) payload goes out as a BINARY frame, verbatim. It
803
+ # used to go out as a TEXT frame, which is invalid UTF-8 on the wire and
804
+ # makes a browser fail the connection. Text frames must be valid UTF-8.
805
+ if data.encoding == Encoding::ASCII_8BIT
806
+ write_frame(build_frame(0x2, data))
807
+ else
808
+ write_frame(build_frame(0x1, data.encode("UTF-8")))
809
+ end
749
810
  end
750
811
 
751
812
  alias_method :send_text, :send
@@ -757,16 +818,48 @@ module Tina4
757
818
  end
758
819
 
759
820
  def send_pong(data)
760
- frame = build_frame(0xA, data || "")
761
- @socket.write(frame)
762
- rescue IOError
763
- @closed = true
821
+ write_frame(build_frame(0xA, data || ""))
764
822
  end
765
823
 
766
824
  def close(code: 1000, reason: "")
767
825
  payload = [code].pack("n") + reason
768
826
  frame = build_frame(0x8, payload)
769
- @socket.write(frame) rescue nil
827
+ # Best effort and never blocking: a peer that stopped reading must not
828
+ # hold up the reaper or a shutdown. The close frame only goes out when no
829
+ # earlier frame is still queued, so it can never land mid-frame.
830
+ write_lock.synchronize do
831
+ @socket.write_nonblock(frame, exception: false) if pending.empty?
832
+ end
833
+ rescue IOError, SystemCallError
834
+ nil
835
+ ensure
836
+ @closed = true
837
+ @socket.close rescue nil
838
+ end
839
+
840
+ # Hand a frame to the socket WITHOUT blocking the caller. What the kernel
841
+ # cannot take yet waits in a per-connection queue that a flusher thread
842
+ # drains as the peer reads. A peer whose queue passes TINA4_WS_MAX_BACKLOG
843
+ # bytes (default 1 MiB, 0 = unbounded; the same setting as tina4-nodejs) is
844
+ # hopelessly behind or never reads, so it is closed. A blocking write here
845
+ # used to stall every broadcast forever on one client that stopped reading.
846
+ def write_frame(frame)
847
+ return if @closed
848
+
849
+ write_lock.synchronize do
850
+ pending << frame.b
851
+ flush_pending
852
+ limit = max_backlog
853
+ if limit.positive? && pending.bytesize > limit
854
+ backlog = pending.bytesize
855
+ drop_slow_client("backlog #{backlog} bytes exceeds TINA4_WS_MAX_BACKLOG (#{limit})")
856
+ elsif !pending.empty?
857
+ start_flusher
858
+ end
859
+ end
860
+ rescue IOError, SystemCallError
861
+ # Broken pipe / reset / closed socket: mark dead so the broadcast path prunes it.
862
+ @closed = true
770
863
  @socket.close rescue nil
771
864
  end
772
865
 
@@ -802,6 +895,57 @@ module Tina4
802
895
  def build_frame(opcode, data)
803
896
  Tina4.build_frame(opcode, data)
804
897
  end
898
+
899
+ private
900
+
901
+ def write_lock
902
+ @write_lock ||= Mutex.new
903
+ end
904
+
905
+ def pending
906
+ @pending ||= +"".b
907
+ end
908
+
909
+ def max_backlog
910
+ Integer(ENV["TINA4_WS_MAX_BACKLOG"] || "1048576")
911
+ rescue ArgumentError, TypeError
912
+ 1_048_576
913
+ end
914
+
915
+ # Write as much of the queue as the kernel takes right now. Caller holds the lock.
916
+ def flush_pending
917
+ until pending.empty?
918
+ written = @socket.write_nonblock(pending, exception: false)
919
+ break if written == :wait_writable
920
+
921
+ @pending = pending.byteslice(written..)
922
+ end
923
+ end
924
+
925
+ # One background drainer per connection while bytes are queued.
926
+ def start_flusher
927
+ return if @flusher&.alive?
928
+
929
+ @flusher = Thread.new do
930
+ until @closed
931
+ done = write_lock.synchronize { pending.empty? }
932
+ break if done
933
+
934
+ @socket.wait_writable(1)
935
+ write_lock.synchronize { flush_pending }
936
+ end
937
+ rescue IOError, SystemCallError
938
+ @closed = true
939
+ @socket.close rescue nil
940
+ end
941
+ end
942
+
943
+ def drop_slow_client(reason)
944
+ Tina4::Log.warning("WebSocket client #{@id} dropped as too slow: #{reason}") if defined?(Tina4::Log)
945
+ @pending = +"".b
946
+ @closed = true
947
+ @socket.close rescue nil
948
+ end
805
949
  end
806
950
 
807
951
  # Shared, process-wide manager for the dev-reload channel (/__dev_reload).
@@ -115,6 +115,12 @@ module Tina4
115
115
  block.call(msg) if @running
116
116
  end
117
117
  end
118
+ rescue StandardError => e
119
+ # A refused login or a dropped connection ends this listener for good.
120
+ # Say so in the framework log: before, the thread died with only a raw
121
+ # stack trace on stderr while "backplane active" stood as the last word.
122
+ # The redis client's messages carry the URL without its userinfo.
123
+ Tina4::Log.error("WebSocket backplane subscriber stopped on '#{channel}': #{e.message}") if @running && defined?(Tina4::Log)
118
124
  end
119
125
  end
120
126
 
data/lib/tina4/wsdl.rb CHANGED
@@ -279,6 +279,14 @@ module Tina4
279
279
  def process_soap(xml_body)
280
280
  on_request(@request)
281
281
 
282
+ # The shared body rule, before anything reads the bytes: UTF-8 only, no
283
+ # byte order mark, no NUL byte, no other declared encoding.
284
+ begin
285
+ xml_body = XmlParser.utf8_text(xml_body)
286
+ rescue XmlParser::ParseError
287
+ return soap_fault("Client", "Malformed XML")
288
+ end
289
+
282
290
  # SOAP 1.1 (§3) forbids a Document Type Declaration in a SOAP message, so
283
291
  # reject any DOCTYPE up front with the same Client fault as the other three
284
292
  # frameworks. The parser has no DTD support at all (no entity expansion, no
@@ -499,14 +507,27 @@ module Tina4
499
507
  end
500
508
 
501
509
  def handle_soap_request(xml_body)
510
+ # Malformed XML is the CLIENT's fault, as in Python and WSDL#process_soap.
511
+ # Both refusals below used to fall into the operation catch-all at the end
512
+ # of this method and answer "Internal server error" (a soap:Server fault).
513
+ begin
514
+ xml_body = XmlParser.utf8_text(xml_body) # the shared body encoding rule
515
+ rescue XmlParser::ParseError
516
+ return _soap_fault("Malformed XML", code: "Client")
517
+ end
518
+
502
519
  # SOAP 1.1 (§3) forbids a DOCTYPE/DTD. Reject before parsing; the parser
503
520
  # itself has no DTD support and accepts UTF-8 only. Mirrors the
504
521
  # class-based process_soap path.
505
- if xml_body.to_s.b.match?(/<!DOCTYPE/in)
522
+ if xml_body.b.match?(/<!DOCTYPE/in)
506
523
  return _soap_fault("DOCTYPE declarations are not allowed in SOAP messages")
507
524
  end
508
525
 
509
- doc = XmlParser.parse(xml_body)
526
+ begin
527
+ doc = XmlParser.parse(xml_body)
528
+ rescue XmlParser::ParseError
529
+ return _soap_fault("Malformed XML", code: "Client")
530
+ end
510
531
 
511
532
  # Find Body element (namespace-agnostic)
512
533
  body_el = _find_child(doc.root, "Body")
@@ -590,11 +611,11 @@ module Tina4
590
611
  xml
591
612
  end
592
613
 
593
- def _soap_fault(message)
614
+ def _soap_fault(message, code: "soap:Server")
594
615
  '<?xml version="1.0" encoding="UTF-8"?>' \
595
616
  '<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">' \
596
617
  "<soap:Body><soap:Fault>" \
597
- "<faultcode>soap:Server</faultcode>" \
618
+ "<faultcode>#{code}</faultcode>" \
598
619
  "<faultstring>#{_escape_xml(message)}</faultstring>" \
599
620
  "</soap:Fault></soap:Body></soap:Envelope>"
600
621
  end
@@ -22,10 +22,11 @@ module Tina4
22
22
  # * DTDs. `<!DOCTYPE` and every other `<!` declaration is a ParseError, so
23
23
  # there is no entity expansion (billion laughs) and no external entity
24
24
  # (XXE). The WSDL server also rejects DOCTYPE before parsing.
25
- # * Any encoding but UTF-8. A UTF-16/UTF-32 body (BOM or NUL bytes) or
26
- # invalid UTF-8 is a ParseError. A UTF-16 body used to slip past the
27
- # `<!DOCTYPE` regex guard, because the guard read "<\0!\0D\0..." while
28
- # REXML decoded the BOM and parsed the DTD. A UTF-8 BOM is skipped.
25
+ # * Any encoding but UTF-8. Any byte order mark (UTF-8 included), a NUL
26
+ # byte, invalid UTF-8, or an XML declaration naming another encoding is a
27
+ # ParseError. A UTF-16 body used to slip past the `<!DOCTYPE` regex guard,
28
+ # because the guard read "<\0!\0D\0..." while REXML decoded the BOM and
29
+ # parsed the DTD.
29
30
  #
30
31
  # Element#text follows ElementTree (the Python master): the character data
31
32
  # before the first child element, entity- and CDATA-decoded; nil when there
@@ -71,7 +72,10 @@ module Tina4
71
72
 
72
73
  PREDEFINED_ENTITIES = { "lt" => "<", "gt" => ">", "amp" => "&", "quot" => '"', "apos" => "'" }.freeze
73
74
  NAME = /[A-Za-z_:\u00C0-\u{EFFFF}][A-Za-z0-9_:.\-\u00B7\u00C0-\u{EFFFF}]*/
74
- UTF16_OR_32_BOMS = ["\xFE\xFF".b, "\xFF\xFE".b, "\x00\x00\xFE\xFF".b].freeze
75
+ # Every byte order mark, the UTF-8 one included (the shared SOAP body rule:
76
+ # a body is plain UTF-8, never a BOM, in all four frameworks).
77
+ BYTE_ORDER_MARKS = ["\xEF\xBB\xBF".b, "\xFE\xFF".b, "\xFF\xFE".b, "\x00\x00\xFE\xFF".b].freeze
78
+ DECLARED_ENCODING = /\A<\?xml\b[^>]*?\bencoding\s*=\s*["']([^"']*)["']/
75
79
 
76
80
  module_function
77
81
 
@@ -81,16 +85,21 @@ module Tina4
81
85
  Reader.new(utf8_text(xml)).document
82
86
  end
83
87
 
88
+ # The body as UTF-8 text, or a ParseError. Callers that must refuse BEFORE
89
+ # any parse (the WSDL server) call this first; #parse calls it too.
84
90
  def utf8_text(xml)
85
91
  bytes = xml.to_s.b
86
- if UTF16_OR_32_BOMS.any? { |bom| bytes.start_with?(bom) } || bytes.include?("\x00")
87
- raise ParseError, "only UTF-8 XML is accepted (UTF-16/UTF-32 body)"
88
- end
92
+ raise ParseError, "only UTF-8 XML without a byte order mark is accepted" if BYTE_ORDER_MARKS.any? { |bom| bytes.start_with?(bom) }
93
+ raise ParseError, "only UTF-8 XML is accepted (NUL byte, UTF-16/UTF-32 body)" if bytes.include?("\x00")
89
94
 
90
- bytes = bytes.byteslice(3..) if bytes.start_with?("\xEF\xBB\xBF".b)
91
95
  text = bytes.force_encoding(Encoding::UTF_8)
92
96
  raise ParseError, "only UTF-8 XML is accepted (invalid UTF-8 bytes)" unless text.valid_encoding?
93
97
 
98
+ declared = text[DECLARED_ENCODING, 1]
99
+ if declared && !declared.casecmp?("UTF-8")
100
+ raise ParseError, "only UTF-8 XML is accepted (declared encoding #{declared.inspect})"
101
+ end
102
+
94
103
  text
95
104
  end
96
105
 
data/lib/tina4.rb CHANGED
@@ -12,6 +12,8 @@ require_relative "tina4/constants"
12
12
  require_relative "tina4/log"
13
13
  require_relative "tina4/debug" # backward compat alias
14
14
  require_relative "tina4/env"
15
+ require_relative "tina4/sqlite_path_resolver"
16
+ require_relative "tina4/row_symbolizer"
15
17
  require_relative "tina4/router"
16
18
  require_relative "tina4/request"
17
19
  require_relative "tina4/response"
@@ -159,6 +161,8 @@ module Tina4
159
161
  autoload :CRUD, File.expand_path("tina4/crud", __dir__)
160
162
  autoload :API, File.expand_path("tina4/api", __dir__)
161
163
  autoload :APIResponse, File.expand_path("tina4/api", __dir__)
164
+ autoload :Ssrf, File.expand_path("tina4/ssrf", __dir__)
165
+ autoload :SsrfError, File.expand_path("tina4/ssrf", __dir__)
162
166
  autoload :GraphQLType, File.expand_path("tina4/graphql", __dir__)
163
167
  autoload :GraphQLSchema, File.expand_path("tina4/graphql", __dir__)
164
168
  autoload :GraphQLParser, File.expand_path("tina4/graphql", __dir__)
@@ -479,8 +483,15 @@ module Tina4
479
483
  # boot after env load, before any auth use. Never crashes boot.
480
484
  Tina4::Auth.ensure_dev_secret(root_dir)
481
485
 
482
- # Setup auth keys
483
- Tina4::Auth.setup(root_dir)
486
+ # Point RS256 at <root>/.keys without creating anything: an
487
+ # operator-provisioned key pair keeps working, and a blank secret no
488
+ # longer mints one implicitly (ADR-0079 s2).
489
+ Tina4::Auth.keys_root = root_dir
490
+
491
+ # Refuse to serve with a secret anyone can reproduce: blank outside dev,
492
+ # or set but shorter than 32 bytes in any mode (ADR-0079 s2). Raises
493
+ # Tina4::Auth::InsecureSecretError naming TINA4_SECRET.
494
+ Tina4::Auth.require_boot_secret!
484
495
 
485
496
  # Auto-attach CSRF protection when TINA4_CSRF is enabled (OFF by default).
486
497
  # Mirrors the Python master's attach_csrf_from_env -- the env flag is the