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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -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/database/sqlite3_adapter.rb +6 -54
- data/lib/tina4/dev_admin.rb +11 -4
- data/lib/tina4/drivers/sqlite_driver.rb +14 -81
- 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/row_symbolizer.rb +54 -0
- data/lib/tina4/session_handlers/database_handler.rb +36 -12
- data/lib/tina4/sqlite_path_resolver.rb +72 -0
- 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 +13 -2
- metadata +5 -2
|
@@ -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" =>
|
|
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
|
-
|
|
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" =>
|
|
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
data/lib/tina4/websocket.rb
CHANGED
|
@@ -113,7 +113,10 @@ module Tina4
|
|
|
113
113
|
return [nil, false] unless token
|
|
114
114
|
|
|
115
115
|
payload = Tina4::Auth.valid_token(token)
|
|
116
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
#
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
618
|
+
"<faultcode>#{code}</faultcode>" \
|
|
598
619
|
"<faultstring>#{_escape_xml(message)}</faultstring>" \
|
|
599
620
|
"</soap:Fault></soap:Body></soap:Envelope>"
|
|
600
621
|
end
|
data/lib/tina4/xml_parser.rb
CHANGED
|
@@ -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.
|
|
26
|
-
# invalid UTF-8
|
|
27
|
-
# `<!DOCTYPE` regex guard,
|
|
28
|
-
#
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
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
|
-
#
|
|
483
|
-
|
|
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
|