tina4ruby 3.11.15 → 3.11.16
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 +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +1289 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -124
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -116
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2087 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +871 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/plan.rb +471 -0
- data/lib/tina4/project_index.rb +366 -0
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +1264 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +1264 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -268
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +460 -458
- data/lib/tina4ruby.rb +4 -4
- metadata +5 -3
data/lib/tina4/middleware.rb
CHANGED
|
@@ -1,445 +1,445 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Tina4
|
|
4
|
-
class Middleware
|
|
5
|
-
class << self
|
|
6
|
-
def before_handlers
|
|
7
|
-
@before_handlers ||= []
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def after_handlers
|
|
11
|
-
@after_handlers ||= []
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
# Registry of class-based middleware (registered via Router.use)
|
|
15
|
-
def global_middleware
|
|
16
|
-
@global_middleware ||= []
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# Parity alias matching Python/PHP/Node orchestrators.
|
|
20
|
-
def get_global
|
|
21
|
-
global_middleware.dup
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def before(pattern = nil, &block)
|
|
25
|
-
before_handlers << { pattern: pattern, handler: block }
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def after(pattern = nil, &block)
|
|
29
|
-
after_handlers << { pattern: pattern, handler: block }
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Register a class-based middleware globally.
|
|
33
|
-
# The class should define static before_* and/or after_* methods.
|
|
34
|
-
def use(klass)
|
|
35
|
-
global_middleware << klass unless global_middleware.include?(klass)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def clear!
|
|
39
|
-
@before_handlers = []
|
|
40
|
-
@after_handlers = []
|
|
41
|
-
@global_middleware = []
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# Run all "before" hooks: block-based handlers, then class-based before_* methods.
|
|
45
|
-
#
|
|
46
|
-
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
47
|
-
# middleware classes explicitly.
|
|
48
|
-
#
|
|
49
|
-
# Returns [request, response] on success, or false to halt the request.
|
|
50
|
-
def run_before(middleware_classes, request, response)
|
|
51
|
-
# 1. Block-based before handlers (pattern-matched)
|
|
52
|
-
before_handlers.each do |entry|
|
|
53
|
-
next unless matches_pattern?(request.path, entry[:pattern])
|
|
54
|
-
result = entry[:handler].call(request, response)
|
|
55
|
-
return false if result == false
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# 2. Class-based middleware: call every before_* method
|
|
59
|
-
middleware_classes.each do |klass|
|
|
60
|
-
before_methods_for(klass).each do |method_name|
|
|
61
|
-
result = klass.send(method_name, request, response)
|
|
62
|
-
# Support returning [request, response] (Python convention) or false to halt
|
|
63
|
-
if result == false
|
|
64
|
-
return false
|
|
65
|
-
elsif result.is_a?(Array) && result.length == 2
|
|
66
|
-
request, response = result
|
|
67
|
-
# If response already has a non-2xx status, halt processing
|
|
68
|
-
return false if response.status_code >= 400
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
true
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# Run all "after" hooks: block-based handlers, then class-based after_* methods.
|
|
77
|
-
#
|
|
78
|
-
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
79
|
-
# middleware classes explicitly.
|
|
80
|
-
def run_after(middleware_classes, request, response)
|
|
81
|
-
# 1. Block-based after handlers (pattern-matched)
|
|
82
|
-
after_handlers.each do |entry|
|
|
83
|
-
next unless matches_pattern?(request.path, entry[:pattern])
|
|
84
|
-
entry[:handler].call(request, response)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# 2. Class-based middleware: call every after_* method
|
|
88
|
-
middleware_classes.each do |klass|
|
|
89
|
-
after_methods_for(klass).each do |method_name|
|
|
90
|
-
result = klass.send(method_name, request, response)
|
|
91
|
-
if result.is_a?(Array) && result.length == 2
|
|
92
|
-
request, response = result
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
private
|
|
99
|
-
|
|
100
|
-
def matches_pattern?(path, pattern)
|
|
101
|
-
return true if pattern.nil?
|
|
102
|
-
case pattern
|
|
103
|
-
when String
|
|
104
|
-
path.start_with?(pattern)
|
|
105
|
-
when Regexp
|
|
106
|
-
pattern.match?(path)
|
|
107
|
-
else
|
|
108
|
-
true
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Collect all public class methods matching before_*
|
|
113
|
-
def before_methods_for(klass)
|
|
114
|
-
klass.methods(false).select { |m| m.to_s.start_with?("before_") }.sort
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# Collect all public class methods matching after_*
|
|
118
|
-
def after_methods_for(klass)
|
|
119
|
-
klass.methods(false).select { |m| m.to_s.start_with?("after_") }.sort
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# ---------------------------------------------------------------------------
|
|
125
|
-
# Built-in class-based middleware
|
|
126
|
-
# ---------------------------------------------------------------------------
|
|
127
|
-
|
|
128
|
-
# CorsClassMiddleware -- sets CORS headers from env vars on every response.
|
|
129
|
-
# Uses the same config source as CorsMiddleware module.
|
|
130
|
-
class CorsClassMiddleware
|
|
131
|
-
class << self
|
|
132
|
-
def before_cors(request, response)
|
|
133
|
-
config = load_config
|
|
134
|
-
origin = resolve_origin(request, config)
|
|
135
|
-
|
|
136
|
-
response.headers["access-control-allow-origin"] = origin
|
|
137
|
-
response.headers["access-control-allow-methods"] = config[:methods]
|
|
138
|
-
response.headers["access-control-allow-headers"] = config[:headers]
|
|
139
|
-
response.headers["access-control-max-age"] = config[:max_age]
|
|
140
|
-
if config[:credentials] == "true"
|
|
141
|
-
response.headers["access-control-allow-credentials"] = "true"
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
[request, response]
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
private
|
|
148
|
-
|
|
149
|
-
def load_config
|
|
150
|
-
{
|
|
151
|
-
origins: ENV["TINA4_CORS_ORIGINS"] || "*",
|
|
152
|
-
methods: ENV["TINA4_CORS_METHODS"] || "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
153
|
-
headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,Authorization,X-Request-ID",
|
|
154
|
-
max_age: ENV["TINA4_CORS_MAX_AGE"] || "86400",
|
|
155
|
-
credentials: ENV["TINA4_CORS_CREDENTIALS"] || "false"
|
|
156
|
-
}
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def is_preflight(request)
|
|
160
|
-
request.method&.upcase == "OPTIONS" &&
|
|
161
|
-
request.headers["origin"] &&
|
|
162
|
-
request.headers["access-control-request-method"]
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def resolve_origin(request, config)
|
|
166
|
-
request_origin = request.headers["origin"] || request.headers["referer"]
|
|
167
|
-
|
|
168
|
-
if config[:origins] == "*"
|
|
169
|
-
"*"
|
|
170
|
-
elsif request_origin
|
|
171
|
-
allowed = config[:origins].split(",").map(&:strip)
|
|
172
|
-
clean = request_origin.chomp("/")
|
|
173
|
-
allowed.include?(clean) ? clean : allowed.first || "*"
|
|
174
|
-
else
|
|
175
|
-
config[:origins].split(",").first&.strip || "*"
|
|
176
|
-
end
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
# RateLimiterMiddleware -- tracks requests per IP, returns 429 when exceeded.
|
|
182
|
-
# Config via env: TINA4_RATE_LIMIT (default 100), TINA4_RATE_WINDOW (default 60s).
|
|
183
|
-
class RateLimiterMiddleware
|
|
184
|
-
@store = {}
|
|
185
|
-
@mutex = Mutex.new
|
|
186
|
-
@last_cleanup = Time.now
|
|
187
|
-
|
|
188
|
-
class << self
|
|
189
|
-
def before_rate_limit(request, response)
|
|
190
|
-
limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
|
|
191
|
-
window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
|
|
192
|
-
ip = request.ip || "unknown"
|
|
193
|
-
now = Time.now
|
|
194
|
-
|
|
195
|
-
cleanup_if_needed(now, window)
|
|
196
|
-
|
|
197
|
-
@mutex.synchronize do
|
|
198
|
-
@store[ip] ||= []
|
|
199
|
-
entries = @store[ip]
|
|
200
|
-
|
|
201
|
-
# Sliding window -- drop expired timestamps
|
|
202
|
-
cutoff = now - window
|
|
203
|
-
entries.reject! { |t| t < cutoff }
|
|
204
|
-
|
|
205
|
-
if entries.length >= limit
|
|
206
|
-
oldest = entries.first
|
|
207
|
-
retry_after = [(oldest + window - now).ceil, 1].max
|
|
208
|
-
|
|
209
|
-
response.headers["X-RateLimit-Limit"] = limit.to_s
|
|
210
|
-
response.headers["X-RateLimit-Remaining"] = "0"
|
|
211
|
-
response.headers["X-RateLimit-Reset"] = (oldest + window).to_i.to_s
|
|
212
|
-
response.headers["Retry-After"] = retry_after.to_s
|
|
213
|
-
response.json({ error: "Too Many Requests", retry_after: retry_after }, 429)
|
|
214
|
-
|
|
215
|
-
return [request, response]
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
entries << now
|
|
219
|
-
|
|
220
|
-
response.headers["X-RateLimit-Limit"] = limit.to_s
|
|
221
|
-
response.headers["X-RateLimit-Remaining"] = (limit - entries.length).to_s
|
|
222
|
-
response.headers["X-RateLimit-Reset"] = (now + window).to_i.to_s
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
[request, response]
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
def check(ip)
|
|
229
|
-
limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
|
|
230
|
-
window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
|
|
231
|
-
now = Time.now
|
|
232
|
-
|
|
233
|
-
@mutex.synchronize do
|
|
234
|
-
@store[ip] ||= []
|
|
235
|
-
entries = @store[ip]
|
|
236
|
-
entries.reject! { |t| t < now - window }
|
|
237
|
-
|
|
238
|
-
remaining = [limit - entries.length, 0].max
|
|
239
|
-
reset_at = entries.empty? ? window : (entries.first + window - now).ceil
|
|
240
|
-
|
|
241
|
-
if entries.length >= limit
|
|
242
|
-
return [false, { limit: limit, remaining: 0, reset: reset_at, window: window }]
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
entries << now
|
|
246
|
-
[true, { limit: limit, remaining: remaining - 1, reset: window, window: window }]
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
# Allow resetting state (useful in tests)
|
|
251
|
-
def reset!
|
|
252
|
-
@mutex.synchronize { @store.clear }
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
private
|
|
256
|
-
|
|
257
|
-
def cleanup_if_needed(now, window)
|
|
258
|
-
return if now - @last_cleanup < window
|
|
259
|
-
|
|
260
|
-
@mutex.synchronize do
|
|
261
|
-
return if now - @last_cleanup < window
|
|
262
|
-
|
|
263
|
-
cutoff = now - window
|
|
264
|
-
@store.delete_if do |_ip, entries|
|
|
265
|
-
entries.reject! { |t| t < cutoff }
|
|
266
|
-
entries.empty?
|
|
267
|
-
end
|
|
268
|
-
@last_cleanup = now
|
|
269
|
-
end
|
|
270
|
-
end
|
|
271
|
-
end
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
# RequestLoggerMiddleware -- logs method, path, and elapsed time for every request.
|
|
275
|
-
class RequestLoggerMiddleware
|
|
276
|
-
@request_times = {}
|
|
277
|
-
@mutex = Mutex.new
|
|
278
|
-
|
|
279
|
-
class << self
|
|
280
|
-
def before_log(request, response)
|
|
281
|
-
request_key = "#{request.object_id}"
|
|
282
|
-
@mutex.synchronize do
|
|
283
|
-
@request_times[request_key] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
284
|
-
end
|
|
285
|
-
[request, response]
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
def after_log(request, response)
|
|
289
|
-
request_key = "#{request.object_id}"
|
|
290
|
-
start_time = @mutex.synchronize { @request_times.delete(request_key) }
|
|
291
|
-
|
|
292
|
-
if start_time
|
|
293
|
-
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(3)
|
|
294
|
-
else
|
|
295
|
-
elapsed_ms = 0.0
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
Tina4::Log.info("[RequestLogger] #{request.method} #{request.path} -> #{response.status_code} (#{elapsed_ms}ms)")
|
|
299
|
-
[request, response]
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
def reset!
|
|
303
|
-
@mutex.synchronize { @request_times.clear }
|
|
304
|
-
end
|
|
305
|
-
end
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
# CsrfMiddleware -- validates form tokens on state-changing requests.
|
|
309
|
-
#
|
|
310
|
-
# Off by default -- only active when TINA4_CSRF=true in .env or when
|
|
311
|
-
# registered explicitly via Router.use(CsrfMiddleware).
|
|
312
|
-
#
|
|
313
|
-
# Behaviour:
|
|
314
|
-
# - Skips GET, HEAD, OPTIONS requests.
|
|
315
|
-
# - Skips routes marked .no_auth.
|
|
316
|
-
# - Skips requests with a valid Authorization: Bearer header (API clients).
|
|
317
|
-
# - Checks request.body["formToken"] then request.headers["X-Form-Token"].
|
|
318
|
-
# - Rejects if token found in request.query["formToken"] (log warning, 403).
|
|
319
|
-
# - Validates token with Auth.valid_token using SECRET env var.
|
|
320
|
-
# - If token payload has session_id, verifies it matches request.session.session_id.
|
|
321
|
-
# - Returns 403 with response.json({error: "CSRF_INVALID", message: ...}, 403) on failure.
|
|
322
|
-
class CsrfMiddleware
|
|
323
|
-
class << self
|
|
324
|
-
def before_csrf(request, response)
|
|
325
|
-
# Allow disabling CSRF via env var
|
|
326
|
-
csrf_env = ENV["TINA4_CSRF"].to_s.downcase
|
|
327
|
-
return [request, response] if %w[false 0 no].include?(csrf_env)
|
|
328
|
-
|
|
329
|
-
# Skip safe HTTP methods
|
|
330
|
-
method = (request.method || "GET").upcase
|
|
331
|
-
return [request, response] if %w[GET HEAD OPTIONS].include?(method)
|
|
332
|
-
|
|
333
|
-
# Skip routes marked no_auth
|
|
334
|
-
handler = request.respond_to?(:handler) ? request.handler : nil
|
|
335
|
-
if handler
|
|
336
|
-
no_auth = if handler.is_a?(Hash)
|
|
337
|
-
handler[:no_auth] || handler[:noAuth]
|
|
338
|
-
elsif handler.respond_to?(:no_auth)
|
|
339
|
-
handler.no_auth
|
|
340
|
-
end
|
|
341
|
-
return [request, response] if no_auth
|
|
342
|
-
end
|
|
343
|
-
|
|
344
|
-
# Skip requests with valid Bearer token (API clients)
|
|
345
|
-
headers = request.respond_to?(:headers) ? request.headers : {}
|
|
346
|
-
auth_header = headers["authorization"] || headers["Authorization"] || ""
|
|
347
|
-
if auth_header.start_with?("Bearer ")
|
|
348
|
-
bearer_token = auth_header[7..].strip
|
|
349
|
-
unless bearer_token.empty?
|
|
350
|
-
return [request, response] if Tina4::Auth.valid_token(bearer_token)
|
|
351
|
-
end
|
|
352
|
-
end
|
|
353
|
-
|
|
354
|
-
# Reject if token is in query string (security risk)
|
|
355
|
-
query = if request.respond_to?(:params)
|
|
356
|
-
request.params
|
|
357
|
-
elsif request.respond_to?(:query)
|
|
358
|
-
request.query
|
|
359
|
-
else
|
|
360
|
-
{}
|
|
361
|
-
end
|
|
362
|
-
query ||= {}
|
|
363
|
-
|
|
364
|
-
if query.is_a?(Hash) && query["formToken"] && !query["formToken"].to_s.empty?
|
|
365
|
-
Tina4::Log.warning("[CSRF] Token found in query string — rejected for security")
|
|
366
|
-
response.json({ error: "CSRF_INVALID", message: "Form token must not be sent in the URL query string" }, 403)
|
|
367
|
-
return [request, response]
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
# Extract token: body first, then header
|
|
371
|
-
token = nil
|
|
372
|
-
body = request.respond_to?(:body) ? request.body : nil
|
|
373
|
-
body ||= {}
|
|
374
|
-
token = body["formToken"] if body.is_a?(Hash)
|
|
375
|
-
|
|
376
|
-
if token.nil? || token.to_s.empty?
|
|
377
|
-
token = headers["X-Form-Token"] || headers["x-form-token"] || ""
|
|
378
|
-
end
|
|
379
|
-
|
|
380
|
-
if token.nil? || token.to_s.empty?
|
|
381
|
-
response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
|
|
382
|
-
return [request, response]
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
# Validate the token
|
|
386
|
-
unless Tina4::Auth.valid_token(token.to_s)
|
|
387
|
-
response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
|
|
388
|
-
return [request, response]
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
# Session binding — if token has session_id, verify it matches
|
|
392
|
-
payload = Tina4::Auth.get_payload(token.to_s) || {}
|
|
393
|
-
token_session_id = payload["session_id"]
|
|
394
|
-
if token_session_id
|
|
395
|
-
current_session_id = nil
|
|
396
|
-
session = request.respond_to?(:session) ? request.session : nil
|
|
397
|
-
if session
|
|
398
|
-
current_session_id = if session.respond_to?(:session_id)
|
|
399
|
-
session.session_id
|
|
400
|
-
elsif session.is_a?(Hash)
|
|
401
|
-
session["session_id"]
|
|
402
|
-
elsif session.respond_to?(:get)
|
|
403
|
-
session.get("session_id")
|
|
404
|
-
end
|
|
405
|
-
end
|
|
406
|
-
|
|
407
|
-
if current_session_id && token_session_id != current_session_id
|
|
408
|
-
response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
|
|
409
|
-
return [request, response]
|
|
410
|
-
end
|
|
411
|
-
end
|
|
412
|
-
|
|
413
|
-
[request, response]
|
|
414
|
-
end
|
|
415
|
-
end
|
|
416
|
-
end
|
|
417
|
-
|
|
418
|
-
# SecurityHeadersMiddleware -- injects security headers on every response.
|
|
419
|
-
# Config via env:
|
|
420
|
-
# TINA4_FRAME_OPTIONS — X-Frame-Options (default: SAMEORIGIN)
|
|
421
|
-
# TINA4_HSTS — Strict-Transport-Security max-age (default: "" = off)
|
|
422
|
-
# TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
|
|
423
|
-
# TINA4_REFERRER_POLICY — Referrer-Policy (default: strict-origin-when-cross-origin)
|
|
424
|
-
# TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
|
|
425
|
-
class SecurityHeadersMiddleware
|
|
426
|
-
class << self
|
|
427
|
-
def before_security(request, response)
|
|
428
|
-
response.headers["X-Frame-Options"] = ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"
|
|
429
|
-
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
430
|
-
|
|
431
|
-
hsts = ENV["TINA4_HSTS"] || ""
|
|
432
|
-
unless hsts.empty?
|
|
433
|
-
response.headers["Strict-Transport-Security"] = "max-age=#{hsts}; includeSubDomains"
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
response.headers["Content-Security-Policy"] = ENV["TINA4_CSP"] || "default-src 'self'"
|
|
437
|
-
response.headers["Referrer-Policy"] = ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"
|
|
438
|
-
response.headers["X-XSS-Protection"] = "0"
|
|
439
|
-
response.headers["Permissions-Policy"] = ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"
|
|
440
|
-
|
|
441
|
-
[request, response]
|
|
442
|
-
end
|
|
443
|
-
end
|
|
444
|
-
end
|
|
445
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
class Middleware
|
|
5
|
+
class << self
|
|
6
|
+
def before_handlers
|
|
7
|
+
@before_handlers ||= []
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def after_handlers
|
|
11
|
+
@after_handlers ||= []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Registry of class-based middleware (registered via Router.use)
|
|
15
|
+
def global_middleware
|
|
16
|
+
@global_middleware ||= []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Parity alias matching Python/PHP/Node orchestrators.
|
|
20
|
+
def get_global
|
|
21
|
+
global_middleware.dup
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def before(pattern = nil, &block)
|
|
25
|
+
before_handlers << { pattern: pattern, handler: block }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def after(pattern = nil, &block)
|
|
29
|
+
after_handlers << { pattern: pattern, handler: block }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Register a class-based middleware globally.
|
|
33
|
+
# The class should define static before_* and/or after_* methods.
|
|
34
|
+
def use(klass)
|
|
35
|
+
global_middleware << klass unless global_middleware.include?(klass)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def clear!
|
|
39
|
+
@before_handlers = []
|
|
40
|
+
@after_handlers = []
|
|
41
|
+
@global_middleware = []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Run all "before" hooks: block-based handlers, then class-based before_* methods.
|
|
45
|
+
#
|
|
46
|
+
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
47
|
+
# middleware classes explicitly.
|
|
48
|
+
#
|
|
49
|
+
# Returns [request, response] on success, or false to halt the request.
|
|
50
|
+
def run_before(middleware_classes, request, response)
|
|
51
|
+
# 1. Block-based before handlers (pattern-matched)
|
|
52
|
+
before_handlers.each do |entry|
|
|
53
|
+
next unless matches_pattern?(request.path, entry[:pattern])
|
|
54
|
+
result = entry[:handler].call(request, response)
|
|
55
|
+
return false if result == false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# 2. Class-based middleware: call every before_* method
|
|
59
|
+
middleware_classes.each do |klass|
|
|
60
|
+
before_methods_for(klass).each do |method_name|
|
|
61
|
+
result = klass.send(method_name, request, response)
|
|
62
|
+
# Support returning [request, response] (Python convention) or false to halt
|
|
63
|
+
if result == false
|
|
64
|
+
return false
|
|
65
|
+
elsif result.is_a?(Array) && result.length == 2
|
|
66
|
+
request, response = result
|
|
67
|
+
# If response already has a non-2xx status, halt processing
|
|
68
|
+
return false if response.status_code >= 400
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Run all "after" hooks: block-based handlers, then class-based after_* methods.
|
|
77
|
+
#
|
|
78
|
+
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
79
|
+
# middleware classes explicitly.
|
|
80
|
+
def run_after(middleware_classes, request, response)
|
|
81
|
+
# 1. Block-based after handlers (pattern-matched)
|
|
82
|
+
after_handlers.each do |entry|
|
|
83
|
+
next unless matches_pattern?(request.path, entry[:pattern])
|
|
84
|
+
entry[:handler].call(request, response)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# 2. Class-based middleware: call every after_* method
|
|
88
|
+
middleware_classes.each do |klass|
|
|
89
|
+
after_methods_for(klass).each do |method_name|
|
|
90
|
+
result = klass.send(method_name, request, response)
|
|
91
|
+
if result.is_a?(Array) && result.length == 2
|
|
92
|
+
request, response = result
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def matches_pattern?(path, pattern)
|
|
101
|
+
return true if pattern.nil?
|
|
102
|
+
case pattern
|
|
103
|
+
when String
|
|
104
|
+
path.start_with?(pattern)
|
|
105
|
+
when Regexp
|
|
106
|
+
pattern.match?(path)
|
|
107
|
+
else
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Collect all public class methods matching before_*
|
|
113
|
+
def before_methods_for(klass)
|
|
114
|
+
klass.methods(false).select { |m| m.to_s.start_with?("before_") }.sort
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Collect all public class methods matching after_*
|
|
118
|
+
def after_methods_for(klass)
|
|
119
|
+
klass.methods(false).select { |m| m.to_s.start_with?("after_") }.sort
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Built-in class-based middleware
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
# CorsClassMiddleware -- sets CORS headers from env vars on every response.
|
|
129
|
+
# Uses the same config source as CorsMiddleware module.
|
|
130
|
+
class CorsClassMiddleware
|
|
131
|
+
class << self
|
|
132
|
+
def before_cors(request, response)
|
|
133
|
+
config = load_config
|
|
134
|
+
origin = resolve_origin(request, config)
|
|
135
|
+
|
|
136
|
+
response.headers["access-control-allow-origin"] = origin
|
|
137
|
+
response.headers["access-control-allow-methods"] = config[:methods]
|
|
138
|
+
response.headers["access-control-allow-headers"] = config[:headers]
|
|
139
|
+
response.headers["access-control-max-age"] = config[:max_age]
|
|
140
|
+
if config[:credentials] == "true"
|
|
141
|
+
response.headers["access-control-allow-credentials"] = "true"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
[request, response]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def load_config
|
|
150
|
+
{
|
|
151
|
+
origins: ENV["TINA4_CORS_ORIGINS"] || "*",
|
|
152
|
+
methods: ENV["TINA4_CORS_METHODS"] || "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
153
|
+
headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,Authorization,X-Request-ID",
|
|
154
|
+
max_age: ENV["TINA4_CORS_MAX_AGE"] || "86400",
|
|
155
|
+
credentials: ENV["TINA4_CORS_CREDENTIALS"] || "false"
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def is_preflight(request)
|
|
160
|
+
request.method&.upcase == "OPTIONS" &&
|
|
161
|
+
request.headers["origin"] &&
|
|
162
|
+
request.headers["access-control-request-method"]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def resolve_origin(request, config)
|
|
166
|
+
request_origin = request.headers["origin"] || request.headers["referer"]
|
|
167
|
+
|
|
168
|
+
if config[:origins] == "*"
|
|
169
|
+
"*"
|
|
170
|
+
elsif request_origin
|
|
171
|
+
allowed = config[:origins].split(",").map(&:strip)
|
|
172
|
+
clean = request_origin.chomp("/")
|
|
173
|
+
allowed.include?(clean) ? clean : allowed.first || "*"
|
|
174
|
+
else
|
|
175
|
+
config[:origins].split(",").first&.strip || "*"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# RateLimiterMiddleware -- tracks requests per IP, returns 429 when exceeded.
|
|
182
|
+
# Config via env: TINA4_RATE_LIMIT (default 100), TINA4_RATE_WINDOW (default 60s).
|
|
183
|
+
class RateLimiterMiddleware
|
|
184
|
+
@store = {}
|
|
185
|
+
@mutex = Mutex.new
|
|
186
|
+
@last_cleanup = Time.now
|
|
187
|
+
|
|
188
|
+
class << self
|
|
189
|
+
def before_rate_limit(request, response)
|
|
190
|
+
limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
|
|
191
|
+
window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
|
|
192
|
+
ip = request.ip || "unknown"
|
|
193
|
+
now = Time.now
|
|
194
|
+
|
|
195
|
+
cleanup_if_needed(now, window)
|
|
196
|
+
|
|
197
|
+
@mutex.synchronize do
|
|
198
|
+
@store[ip] ||= []
|
|
199
|
+
entries = @store[ip]
|
|
200
|
+
|
|
201
|
+
# Sliding window -- drop expired timestamps
|
|
202
|
+
cutoff = now - window
|
|
203
|
+
entries.reject! { |t| t < cutoff }
|
|
204
|
+
|
|
205
|
+
if entries.length >= limit
|
|
206
|
+
oldest = entries.first
|
|
207
|
+
retry_after = [(oldest + window - now).ceil, 1].max
|
|
208
|
+
|
|
209
|
+
response.headers["X-RateLimit-Limit"] = limit.to_s
|
|
210
|
+
response.headers["X-RateLimit-Remaining"] = "0"
|
|
211
|
+
response.headers["X-RateLimit-Reset"] = (oldest + window).to_i.to_s
|
|
212
|
+
response.headers["Retry-After"] = retry_after.to_s
|
|
213
|
+
response.json({ error: "Too Many Requests", retry_after: retry_after }, 429)
|
|
214
|
+
|
|
215
|
+
return [request, response]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
entries << now
|
|
219
|
+
|
|
220
|
+
response.headers["X-RateLimit-Limit"] = limit.to_s
|
|
221
|
+
response.headers["X-RateLimit-Remaining"] = (limit - entries.length).to_s
|
|
222
|
+
response.headers["X-RateLimit-Reset"] = (now + window).to_i.to_s
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
[request, response]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def check(ip)
|
|
229
|
+
limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
|
|
230
|
+
window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
|
|
231
|
+
now = Time.now
|
|
232
|
+
|
|
233
|
+
@mutex.synchronize do
|
|
234
|
+
@store[ip] ||= []
|
|
235
|
+
entries = @store[ip]
|
|
236
|
+
entries.reject! { |t| t < now - window }
|
|
237
|
+
|
|
238
|
+
remaining = [limit - entries.length, 0].max
|
|
239
|
+
reset_at = entries.empty? ? window : (entries.first + window - now).ceil
|
|
240
|
+
|
|
241
|
+
if entries.length >= limit
|
|
242
|
+
return [false, { limit: limit, remaining: 0, reset: reset_at, window: window }]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
entries << now
|
|
246
|
+
[true, { limit: limit, remaining: remaining - 1, reset: window, window: window }]
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Allow resetting state (useful in tests)
|
|
251
|
+
def reset!
|
|
252
|
+
@mutex.synchronize { @store.clear }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private
|
|
256
|
+
|
|
257
|
+
def cleanup_if_needed(now, window)
|
|
258
|
+
return if now - @last_cleanup < window
|
|
259
|
+
|
|
260
|
+
@mutex.synchronize do
|
|
261
|
+
return if now - @last_cleanup < window
|
|
262
|
+
|
|
263
|
+
cutoff = now - window
|
|
264
|
+
@store.delete_if do |_ip, entries|
|
|
265
|
+
entries.reject! { |t| t < cutoff }
|
|
266
|
+
entries.empty?
|
|
267
|
+
end
|
|
268
|
+
@last_cleanup = now
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# RequestLoggerMiddleware -- logs method, path, and elapsed time for every request.
|
|
275
|
+
class RequestLoggerMiddleware
|
|
276
|
+
@request_times = {}
|
|
277
|
+
@mutex = Mutex.new
|
|
278
|
+
|
|
279
|
+
class << self
|
|
280
|
+
def before_log(request, response)
|
|
281
|
+
request_key = "#{request.object_id}"
|
|
282
|
+
@mutex.synchronize do
|
|
283
|
+
@request_times[request_key] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
284
|
+
end
|
|
285
|
+
[request, response]
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def after_log(request, response)
|
|
289
|
+
request_key = "#{request.object_id}"
|
|
290
|
+
start_time = @mutex.synchronize { @request_times.delete(request_key) }
|
|
291
|
+
|
|
292
|
+
if start_time
|
|
293
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(3)
|
|
294
|
+
else
|
|
295
|
+
elapsed_ms = 0.0
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
Tina4::Log.info("[RequestLogger] #{request.method} #{request.path} -> #{response.status_code} (#{elapsed_ms}ms)")
|
|
299
|
+
[request, response]
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def reset!
|
|
303
|
+
@mutex.synchronize { @request_times.clear }
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# CsrfMiddleware -- validates form tokens on state-changing requests.
|
|
309
|
+
#
|
|
310
|
+
# Off by default -- only active when TINA4_CSRF=true in .env or when
|
|
311
|
+
# registered explicitly via Router.use(CsrfMiddleware).
|
|
312
|
+
#
|
|
313
|
+
# Behaviour:
|
|
314
|
+
# - Skips GET, HEAD, OPTIONS requests.
|
|
315
|
+
# - Skips routes marked .no_auth.
|
|
316
|
+
# - Skips requests with a valid Authorization: Bearer header (API clients).
|
|
317
|
+
# - Checks request.body["formToken"] then request.headers["X-Form-Token"].
|
|
318
|
+
# - Rejects if token found in request.query["formToken"] (log warning, 403).
|
|
319
|
+
# - Validates token with Auth.valid_token using SECRET env var.
|
|
320
|
+
# - If token payload has session_id, verifies it matches request.session.session_id.
|
|
321
|
+
# - Returns 403 with response.json({error: "CSRF_INVALID", message: ...}, 403) on failure.
|
|
322
|
+
class CsrfMiddleware
|
|
323
|
+
class << self
|
|
324
|
+
def before_csrf(request, response)
|
|
325
|
+
# Allow disabling CSRF via env var
|
|
326
|
+
csrf_env = ENV["TINA4_CSRF"].to_s.downcase
|
|
327
|
+
return [request, response] if %w[false 0 no].include?(csrf_env)
|
|
328
|
+
|
|
329
|
+
# Skip safe HTTP methods
|
|
330
|
+
method = (request.method || "GET").upcase
|
|
331
|
+
return [request, response] if %w[GET HEAD OPTIONS].include?(method)
|
|
332
|
+
|
|
333
|
+
# Skip routes marked no_auth
|
|
334
|
+
handler = request.respond_to?(:handler) ? request.handler : nil
|
|
335
|
+
if handler
|
|
336
|
+
no_auth = if handler.is_a?(Hash)
|
|
337
|
+
handler[:no_auth] || handler[:noAuth]
|
|
338
|
+
elsif handler.respond_to?(:no_auth)
|
|
339
|
+
handler.no_auth
|
|
340
|
+
end
|
|
341
|
+
return [request, response] if no_auth
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Skip requests with valid Bearer token (API clients)
|
|
345
|
+
headers = request.respond_to?(:headers) ? request.headers : {}
|
|
346
|
+
auth_header = headers["authorization"] || headers["Authorization"] || ""
|
|
347
|
+
if auth_header.start_with?("Bearer ")
|
|
348
|
+
bearer_token = auth_header[7..].strip
|
|
349
|
+
unless bearer_token.empty?
|
|
350
|
+
return [request, response] if Tina4::Auth.valid_token(bearer_token)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Reject if token is in query string (security risk)
|
|
355
|
+
query = if request.respond_to?(:params)
|
|
356
|
+
request.params
|
|
357
|
+
elsif request.respond_to?(:query)
|
|
358
|
+
request.query
|
|
359
|
+
else
|
|
360
|
+
{}
|
|
361
|
+
end
|
|
362
|
+
query ||= {}
|
|
363
|
+
|
|
364
|
+
if query.is_a?(Hash) && query["formToken"] && !query["formToken"].to_s.empty?
|
|
365
|
+
Tina4::Log.warning("[CSRF] Token found in query string — rejected for security")
|
|
366
|
+
response.json({ error: "CSRF_INVALID", message: "Form token must not be sent in the URL query string" }, 403)
|
|
367
|
+
return [request, response]
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Extract token: body first, then header
|
|
371
|
+
token = nil
|
|
372
|
+
body = request.respond_to?(:body) ? request.body : nil
|
|
373
|
+
body ||= {}
|
|
374
|
+
token = body["formToken"] if body.is_a?(Hash)
|
|
375
|
+
|
|
376
|
+
if token.nil? || token.to_s.empty?
|
|
377
|
+
token = headers["X-Form-Token"] || headers["x-form-token"] || ""
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
if token.nil? || token.to_s.empty?
|
|
381
|
+
response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
|
|
382
|
+
return [request, response]
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Validate the token
|
|
386
|
+
unless Tina4::Auth.valid_token(token.to_s)
|
|
387
|
+
response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
|
|
388
|
+
return [request, response]
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Session binding — if token has session_id, verify it matches
|
|
392
|
+
payload = Tina4::Auth.get_payload(token.to_s) || {}
|
|
393
|
+
token_session_id = payload["session_id"]
|
|
394
|
+
if token_session_id
|
|
395
|
+
current_session_id = nil
|
|
396
|
+
session = request.respond_to?(:session) ? request.session : nil
|
|
397
|
+
if session
|
|
398
|
+
current_session_id = if session.respond_to?(:session_id)
|
|
399
|
+
session.session_id
|
|
400
|
+
elsif session.is_a?(Hash)
|
|
401
|
+
session["session_id"]
|
|
402
|
+
elsif session.respond_to?(:get)
|
|
403
|
+
session.get("session_id")
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
if current_session_id && token_session_id != current_session_id
|
|
408
|
+
response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
|
|
409
|
+
return [request, response]
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
[request, response]
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# SecurityHeadersMiddleware -- injects security headers on every response.
|
|
419
|
+
# Config via env:
|
|
420
|
+
# TINA4_FRAME_OPTIONS — X-Frame-Options (default: SAMEORIGIN)
|
|
421
|
+
# TINA4_HSTS — Strict-Transport-Security max-age (default: "" = off)
|
|
422
|
+
# TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
|
|
423
|
+
# TINA4_REFERRER_POLICY — Referrer-Policy (default: strict-origin-when-cross-origin)
|
|
424
|
+
# TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
|
|
425
|
+
class SecurityHeadersMiddleware
|
|
426
|
+
class << self
|
|
427
|
+
def before_security(request, response)
|
|
428
|
+
response.headers["X-Frame-Options"] = ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"
|
|
429
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
430
|
+
|
|
431
|
+
hsts = ENV["TINA4_HSTS"] || ""
|
|
432
|
+
unless hsts.empty?
|
|
433
|
+
response.headers["Strict-Transport-Security"] = "max-age=#{hsts}; includeSubDomains"
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
response.headers["Content-Security-Policy"] = ENV["TINA4_CSP"] || "default-src 'self'"
|
|
437
|
+
response.headers["Referrer-Policy"] = ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"
|
|
438
|
+
response.headers["X-XSS-Protection"] = "0"
|
|
439
|
+
response.headers["Permissions-Policy"] = ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"
|
|
440
|
+
|
|
441
|
+
[request, response]
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|