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.
@@ -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
@@ -159,6 +159,8 @@ module Tina4
159
159
  autoload :CRUD, File.expand_path("tina4/crud", __dir__)
160
160
  autoload :API, File.expand_path("tina4/api", __dir__)
161
161
  autoload :APIResponse, File.expand_path("tina4/api", __dir__)
162
+ autoload :Ssrf, File.expand_path("tina4/ssrf", __dir__)
163
+ autoload :SsrfError, File.expand_path("tina4/ssrf", __dir__)
162
164
  autoload :GraphQLType, File.expand_path("tina4/graphql", __dir__)
163
165
  autoload :GraphQLSchema, File.expand_path("tina4/graphql", __dir__)
164
166
  autoload :GraphQLParser, File.expand_path("tina4/graphql", __dir__)
@@ -479,8 +481,15 @@ module Tina4
479
481
  # boot after env load, before any auth use. Never crashes boot.
480
482
  Tina4::Auth.ensure_dev_secret(root_dir)
481
483
 
482
- # Setup auth keys
483
- Tina4::Auth.setup(root_dir)
484
+ # Point RS256 at <root>/.keys without creating anything: an
485
+ # operator-provisioned key pair keeps working, and a blank secret no
486
+ # longer mints one implicitly (ADR-0079 s2).
487
+ Tina4::Auth.keys_root = root_dir
488
+
489
+ # Refuse to serve with a secret anyone can reproduce: blank outside dev,
490
+ # or set but shorter than 32 bytes in any mode (ADR-0079 s2). Raises
491
+ # Tina4::Auth::InsecureSecretError naming TINA4_SECRET.
492
+ Tina4::Auth.require_boot_secret!
484
493
 
485
494
  # Auto-attach CSRF protection when TINA4_CSRF is enabled (OFF by default).
486
495
  # Mirrors the Python master's attach_csrf_from_env -- the env flag is the
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.138
4
+ version: 3.13.139
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-24 00:00:00.000000000 Z
11
+ date: 2026-09-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sqlite3
@@ -345,6 +345,7 @@ files:
345
345
  - lib/tina4/sql_translator.rb
346
346
  - lib/tina4/sqlite3_gem.rb
347
347
  - lib/tina4/sso.rb
348
+ - lib/tina4/ssrf.rb
348
349
  - lib/tina4/swagger.rb
349
350
  - lib/tina4/template.rb
350
351
  - lib/tina4/templates/base.twig