tina4ruby 3.13.121 → 3.13.123
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/lib/tina4/cli.rb +29 -1
- data/lib/tina4/middleware.rb +847 -818
- data/lib/tina4/version.rb +1 -1
- metadata +2 -2
data/lib/tina4/middleware.rb
CHANGED
|
@@ -1,818 +1,847 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "cgi"
|
|
4
|
-
|
|
5
|
-
module Tina4
|
|
6
|
-
class Middleware
|
|
7
|
-
class << self
|
|
8
|
-
def before_handlers
|
|
9
|
-
@before_handlers ||= []
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def after_handlers
|
|
13
|
-
@after_handlers ||= []
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# Registry of class-based middleware (registered via Router.use)
|
|
17
|
-
def global_middleware
|
|
18
|
-
@global_middleware ||= []
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# Parity alias matching Python/PHP/Node orchestrators.
|
|
22
|
-
def get_global
|
|
23
|
-
global_middleware.dup
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def before(pattern = nil, &block)
|
|
27
|
-
before_handlers << { pattern: pattern, handler: block }
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def after(pattern = nil, &block)
|
|
31
|
-
after_handlers << { pattern: pattern, handler: block }
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Register a class-based middleware globally.
|
|
35
|
-
# The class should define static before_* and/or after_* methods.
|
|
36
|
-
def use(klass)
|
|
37
|
-
global_middleware << klass unless global_middleware.include?(klass)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Global middleware that runs BEFORE route matching.
|
|
41
|
-
#
|
|
42
|
-
# A middleware opts in by declaring `def self.pre_match?; true; end`.
|
|
43
|
-
#
|
|
44
|
-
# NOT `before_match?` - the hook discovery treats every `before_*` method
|
|
45
|
-
# as a middleware hook and calls it with (request, response), so that name
|
|
46
|
-
# made the flag itself run as middleware and 500 the request.
|
|
47
|
-
# Everything else stays where it has always run - after matching - so
|
|
48
|
-
# this is additive and no existing middleware changes behaviour.
|
|
49
|
-
#
|
|
50
|
-
# The split exists because the two groups need opposite things. CORS must
|
|
51
|
-
# run before matching so its headers survive a short-circuited 401/403;
|
|
52
|
-
# a browser that gets a 401 without them reports a CORS error and the real
|
|
53
|
-
# status is invisible. CSRF must run AFTER, because it reads the matched
|
|
54
|
-
# route's metadata to honour a route marked no_auth - PHP shipped exactly
|
|
55
|
-
# that bypass as dead code once, because the metadata was not set yet.
|
|
56
|
-
def pre_match_middleware
|
|
57
|
-
global_middleware.select { |k| k.respond_to?(:pre_match?) && k.pre_match? }
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Global middleware that runs after matching, once the matched route's
|
|
61
|
-
# metadata is readable. This is the default.
|
|
62
|
-
def post_match_middleware
|
|
63
|
-
global_middleware.reject { |k| k.respond_to?(:pre_match?) && k.pre_match? }
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def clear!
|
|
67
|
-
@before_handlers = []
|
|
68
|
-
@after_handlers = []
|
|
69
|
-
@global_middleware = []
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# Run all "before" hooks: block-based handlers, then class-based before_*
|
|
73
|
-
# methods (in definition order).
|
|
74
|
-
#
|
|
75
|
-
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
76
|
-
# middleware classes explicitly.
|
|
77
|
-
#
|
|
78
|
-
# THE RETURN-VALUE CONTRACT is #apply_before_result below — one table,
|
|
79
|
-
# applied to EVERY before_* hook at EVERY scope. Per-route middleware
|
|
80
|
-
# comes through this same method (Tina4::Route#run_middleware), so there
|
|
81
|
-
# is exactly one implementation of the table, not two.
|
|
82
|
-
#
|
|
83
|
-
# M2 — visible-but-resilient: every before_* call is wrapped so a THROW
|
|
84
|
-
# never crashes the worker. On a throw the error is LOGGED and the
|
|
85
|
-
# response becomes a clean 500 ({"error":"Internal Server Error",
|
|
86
|
-
# "status":500}), then processing halts (handler skipped) — deterministic,
|
|
87
|
-
# never an unhandled exception. after_* still run on either halt path
|
|
88
|
-
# (see the dispatcher / #run_after docstring).
|
|
89
|
-
#
|
|
90
|
-
# Returns true on success, or false to halt the request (handler skipped).
|
|
91
|
-
def run_before(middleware_classes, request, response)
|
|
92
|
-
# The response object the CALLER holds. A hook may hand back a
|
|
93
|
-
# different Response; on a halt that object IS the answer, so its state
|
|
94
|
-
# is adopted onto this one before returning — otherwise the dispatcher
|
|
95
|
-
# would serve the object it still has a reference to and the
|
|
96
|
-
# short-circuit would silently vanish.
|
|
97
|
-
origin = response
|
|
98
|
-
|
|
99
|
-
# 1. Block-based before handlers (pattern-matched). These are a
|
|
100
|
-
# Ruby-only surface (Python/PHP/Node have no block form) and keep
|
|
101
|
-
# their historical "false halts" contract: a block's value is its
|
|
102
|
-
# last expression, so reading a returned Response as a
|
|
103
|
-
# short-circuit would fire on any block ending in a chainable
|
|
104
|
-
# response call.
|
|
105
|
-
before_handlers.each do |entry|
|
|
106
|
-
next unless matches_pattern?(request.path, entry[:pattern])
|
|
107
|
-
|
|
108
|
-
begin
|
|
109
|
-
result = entry[:handler].call(request, response)
|
|
110
|
-
rescue StandardError, ScriptError => error
|
|
111
|
-
middleware_500(response, "before handler", error)
|
|
112
|
-
return false
|
|
113
|
-
end
|
|
114
|
-
return false if result == false
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# 2. Class-based middleware: call every before_* method (definition order)
|
|
118
|
-
middleware_classes.each do |klass|
|
|
119
|
-
before_methods_for(klass).each do |method_name|
|
|
120
|
-
begin
|
|
121
|
-
result = klass.send(method_name, request, response)
|
|
122
|
-
rescue StandardError, ScriptError => error
|
|
123
|
-
middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
|
|
124
|
-
return false
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
halt, request, response = apply_before_result(result, request, response)
|
|
128
|
-
next unless halt
|
|
129
|
-
|
|
130
|
-
adopt_response(origin, response) unless response.equal?(origin)
|
|
131
|
-
return false
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
true
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
# Run all "after" hooks: block-based handlers, then class-based after_*
|
|
139
|
-
# methods (in definition order).
|
|
140
|
-
#
|
|
141
|
-
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
142
|
-
# middleware classes explicitly.
|
|
143
|
-
#
|
|
144
|
-
# AFTER-ON-4xx RULE (M2, documented + consistent across all 4 frameworks):
|
|
145
|
-
# after_* ALWAYS run even when a before_* short-circuited with status >= 400
|
|
146
|
-
# and the handler was skipped — so they can still add headers / logging.
|
|
147
|
-
# The dispatcher calls #run_after unconditionally after the before/handler
|
|
148
|
-
# block (including on the 4xx / throw halt path).
|
|
149
|
-
#
|
|
150
|
-
# M2 — every after_* call is wrapped: a THROW is LOGGED and turns the
|
|
151
|
-
# response into a clean 500, then the REMAINING after_* still run (they
|
|
152
|
-
# may add headers/logging). Never an unhandled crash.
|
|
153
|
-
#
|
|
154
|
-
# RETURN VALUES: an after_* hook shapes the response the same way a
|
|
155
|
-
# before_* one does — a returned Tina4::Response BECOMES the response, a
|
|
156
|
-
# returned [request, response] pair rebinds both. What it CANNOT do is
|
|
157
|
-
# halt: the handler has already run, so there is nothing left to skip,
|
|
158
|
-
# and stopping the remaining after_* would contradict the AFTER-ON-4xx
|
|
159
|
-
# resilience rule above (they exist to add headers/logging on every path).
|
|
160
|
-
# So `false` and a >= 400 status are inert here by design.
|
|
161
|
-
def run_after(middleware_classes, request, response)
|
|
162
|
-
origin = response
|
|
163
|
-
|
|
164
|
-
# 1. Block-based after handlers (pattern-matched)
|
|
165
|
-
after_handlers.each do |entry|
|
|
166
|
-
next unless matches_pattern?(request.path, entry[:pattern])
|
|
167
|
-
|
|
168
|
-
begin
|
|
169
|
-
entry[:handler].call(request, response)
|
|
170
|
-
rescue StandardError, ScriptError => error
|
|
171
|
-
middleware_500(response, "after handler", error)
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
# 2. Class-based middleware: call every after_* method (definition order)
|
|
176
|
-
middleware_classes.each do |klass|
|
|
177
|
-
after_methods_for(klass).each do |method_name|
|
|
178
|
-
begin
|
|
179
|
-
result = klass.send(method_name, request, response)
|
|
180
|
-
rescue StandardError, ScriptError => error
|
|
181
|
-
middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
|
|
182
|
-
next
|
|
183
|
-
end
|
|
184
|
-
if result.is_a?(Tina4::Response)
|
|
185
|
-
response = result
|
|
186
|
-
elsif result.is_a?(Array) && result.length == 2
|
|
187
|
-
request, response = result
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
adopt_response(origin, response) unless response.equal?(origin)
|
|
193
|
-
response
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
# Deterministic clean 500 for a middleware that threw. Logs the cause
|
|
197
|
-
# (NEVER silent) then sets the response to the canonical error shape —
|
|
198
|
-
# byte-identical to the Python master ({"error":"Internal Server Error",
|
|
199
|
-
# "status":500} + status 500). Returns the response for chaining.
|
|
200
|
-
def middleware_500(response, label, error)
|
|
201
|
-
begin
|
|
202
|
-
Tina4::Log.error(
|
|
203
|
-
"Middleware #{label} raised #{error.class.name}: #{error.message}"
|
|
204
|
-
)
|
|
205
|
-
rescue StandardError
|
|
206
|
-
begin
|
|
207
|
-
$stderr.puts("Middleware #{label} raised #{error.class.name}: #{error.message}")
|
|
208
|
-
$stderr.flush
|
|
209
|
-
rescue StandardError
|
|
210
|
-
# never let logging break the worker
|
|
211
|
-
end
|
|
212
|
-
end
|
|
213
|
-
response.json({ error: "Internal Server Error", status: 500 }, 500)
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
# The `false` row of the return-value table, on its own.
|
|
217
|
-
#
|
|
218
|
-
# A middleware that halts by returning false keeps the response it set;
|
|
219
|
-
# only a response still left default/empty becomes a 403. Public because
|
|
220
|
-
# per-route "filter" middleware (a 2-arg callable returning false, see
|
|
221
|
-
# Tina4::Route#run_middleware) must obey the SAME row as a before_* hook,
|
|
222
|
-
# and the rule should exist exactly once.
|
|
223
|
-
def refuse(request, response)
|
|
224
|
-
forbid(request, response) if default_response?(response)
|
|
225
|
-
response
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
private
|
|
229
|
-
|
|
230
|
-
# ── THE BEFORE-HOOK RETURN-VALUE TABLE ────────────────────────────────
|
|
231
|
-
#
|
|
232
|
-
# Interpret ONE before_* hook's return value. Identical in Python, PHP,
|
|
233
|
-
# Ruby and Node, and applied at EVERY scope — global (Router.use) and
|
|
234
|
-
# per-route (route.middleware) both land here.
|
|
235
|
-
#
|
|
236
|
-
# a Tina4::Response SHORT-CIRCUIT. That object IS the response, at
|
|
237
|
-
# ANY status. This is the PRIMARY rule: it is the
|
|
238
|
-
# only one that can express a 302 redirect.
|
|
239
|
-
# [request, response] rebind both, continue
|
|
240
|
-
# false SHORT-CIRCUIT. Send the response AS SET; only
|
|
241
|
-
# when it is still default/empty does it become a
|
|
242
|
-
# 403. (Per-route middleware used to answer a
|
|
243
|
-
# halt with a HARDCODED 403 that threw away
|
|
244
|
-
# whatever the middleware had set — that is gone.)
|
|
245
|
-
# nil / anything else continue
|
|
246
|
-
#
|
|
247
|
-
# LEGACY COMPATIBILITY PATH (retained, deliberately NOT the main
|
|
248
|
-
# mechanism): after the hook returns, a response status >= 400 also
|
|
249
|
-
# short-circuits, even when the hook returned nil. Middleware written
|
|
250
|
-
# before the Response rule existed signals refusal that way, so it stays
|
|
251
|
-
# honoured — but it cannot express a 3xx redirect, which is exactly why
|
|
252
|
-
# the Response rule above is primary and this one is the fallback.
|
|
253
|
-
#
|
|
254
|
-
# This check used to be nested INSIDE the "returned a 2-element Array"
|
|
255
|
-
# branch, so a hook that set 403 and returned nil was ignored and the
|
|
256
|
-
# handler RAN — an auth middleware that refused without returning the pair
|
|
257
|
-
# was a no-op. Python, PHP and Node all check the status unconditionally
|
|
258
|
-
# after the call; Rails short-circuits on the response STATE, not on what
|
|
259
|
-
# the filter returned. Now it is unconditional here too.
|
|
260
|
-
#
|
|
261
|
-
# Returns [halt?, request, response].
|
|
262
|
-
def apply_before_result(result, request, response)
|
|
263
|
-
if result.is_a?(Tina4::Response)
|
|
264
|
-
return [true, request, result]
|
|
265
|
-
elsif result.is_a?(Array) && result.length == 2
|
|
266
|
-
request, response = result
|
|
267
|
-
elsif result == false
|
|
268
|
-
refuse(request, response)
|
|
269
|
-
return [true, request, response]
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
status = status_of(response)
|
|
273
|
-
return [true, request, response] if status.is_a?(Integer) && status >= 400
|
|
274
|
-
|
|
275
|
-
[false, request, response]
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
# Read a response's status defensively — a middleware may hand back any
|
|
279
|
-
# response-shaped object. Mirrors the Python master's
|
|
280
|
-
# `getattr(response, "status_code", None) or getattr(response, "status", 0)`.
|
|
281
|
-
def status_of(response)
|
|
282
|
-
return response.status_code if response.respond_to?(:status_code)
|
|
283
|
-
return response.status if response.respond_to?(:status)
|
|
284
|
-
|
|
285
|
-
nil
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
# Has this response been left untouched? Only then does a `false` return
|
|
289
|
-
# get turned into a 403 — a middleware that already answered keeps its
|
|
290
|
-
# own answer.
|
|
291
|
-
def default_response?(response)
|
|
292
|
-
status = status_of(response)
|
|
293
|
-
return false unless status.nil? || status == 200
|
|
294
|
-
|
|
295
|
-
body = response.respond_to?(:body) ? response.body : nil
|
|
296
|
-
body.to_s.empty?
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
# The canonical refusal (ERR-DEC-01/ERR-DEC-02): routed through the SAME
|
|
300
|
-
# negotiated renderer 404/500 use, so a middleware refusal looks like
|
|
301
|
-
# every other error page - a user template if the app ships one, the
|
|
302
|
-
# framework's 403.twig otherwise, negotiated JSON for an API client -
|
|
303
|
-
# instead of the old bare/un-negotiated `{"error":"Forbidden","status":403}`.
|
|
304
|
-
def forbid(request, response)
|
|
305
|
-
request_id = Tina4::Log.get_request_id || ""
|
|
306
|
-
accept = request.respond_to?(:headers) ? (request.headers["accept"] || "") : ""
|
|
307
|
-
|
|
308
|
-
if Tina4::Template.wants_json?(accept)
|
|
309
|
-
set_response_json(response, error_json_body(request_id))
|
|
310
|
-
return response
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
path = request.respond_to?(:path) ? request.path.to_s : ""
|
|
314
|
-
html = begin
|
|
315
|
-
Tina4::Template.render_error(403, { "path" => CGI.escapeHTML(path), "request_id" => request_id })
|
|
316
|
-
rescue StandardError
|
|
317
|
-
nil
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
if html && response.respond_to?(:html)
|
|
321
|
-
response.html(html, 403)
|
|
322
|
-
else
|
|
323
|
-
set_response_json(response, error_json_body(request_id))
|
|
324
|
-
end
|
|
325
|
-
response
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
# The canonical JSON error envelope (ERR-DEC-02) - the SAME shape
|
|
329
|
-
# Python/PHP/Node build: {error: true, code, message, status, request_id}.
|
|
330
|
-
def error_json_body(request_id)
|
|
331
|
-
{ error: true, code: "FORBIDDEN", message: "Forbidden", status: 403, request_id: request_id }
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
def set_response_json(response, body)
|
|
335
|
-
if response.respond_to?(:json)
|
|
336
|
-
response.json(body, 403)
|
|
337
|
-
elsif response.respond_to?(:status_code=)
|
|
338
|
-
response.status_code = 403
|
|
339
|
-
end
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
# Copy a response's state onto the object the CALLER still holds.
|
|
343
|
-
#
|
|
344
|
-
# Ruby passes references, so a hook that MUTATES the response it was given
|
|
345
|
-
# needs nothing from us. This exists for the hook that hands back a
|
|
346
|
-
# DIFFERENT Response object: the contract says that object IS the
|
|
347
|
-
# response, and the dispatcher only ever serves the one it passed in.
|
|
348
|
-
# Applied on the halt paths, where the response is the answer.
|
|
349
|
-
def adopt_response(target, source)
|
|
350
|
-
return target unless target.is_a?(Tina4::Response) && source.is_a?(Tina4::Response)
|
|
351
|
-
|
|
352
|
-
target.status_code = source.status_code
|
|
353
|
-
target.headers = source.headers
|
|
354
|
-
target.body = source.body
|
|
355
|
-
target.cookies = source.cookies
|
|
356
|
-
target
|
|
357
|
-
end
|
|
358
|
-
|
|
359
|
-
# Human-readable label for a middleware (class name, or the class of an
|
|
360
|
-
# instance) used in the logged 500 message.
|
|
361
|
-
def class_label(klass)
|
|
362
|
-
if klass.is_a?(Class) || klass.is_a?(Module)
|
|
363
|
-
klass.name || klass.to_s
|
|
364
|
-
else
|
|
365
|
-
klass.class.name || klass.class.to_s
|
|
366
|
-
end
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
def matches_pattern?(path, pattern)
|
|
370
|
-
return true if pattern.nil?
|
|
371
|
-
case pattern
|
|
372
|
-
when String
|
|
373
|
-
path.start_with?(pattern)
|
|
374
|
-
when Regexp
|
|
375
|
-
pattern.match?(path)
|
|
376
|
-
else
|
|
377
|
-
true
|
|
378
|
-
end
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
# Collect all class methods matching before_* in DEFINITION order.
|
|
382
|
-
def before_methods_for(klass)
|
|
383
|
-
discover_methods(klass, "before_")
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
# Collect all class methods matching after_* in DEFINITION order.
|
|
387
|
-
def after_methods_for(klass)
|
|
388
|
-
discover_methods(klass, "after_")
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
# ----------------------------------------------------------------------
|
|
392
|
-
# MIDDLEWARE ORDERING (M1) — within a class, before_*/after_* methods run
|
|
393
|
-
# in SOURCE-DEFINITION order, NOT alphabetical. Cross-class order is the
|
|
394
|
-
# natural iteration of the registered middleware list (registration
|
|
395
|
-
# order). before_* run before the handler, after_* after.
|
|
396
|
-
#
|
|
397
|
-
# WHY source line numbers, not instance_methods(false): in Ruby/PRISM
|
|
398
|
-
# `instance_methods(false)` is NOT a reliable definition-order report —
|
|
399
|
-
# once a method NAME (symbol) has been defined on any other class first,
|
|
400
|
-
# that name can sort ahead in a later class's list. So we sort the
|
|
401
|
-
# matching methods by their `source_location` line number, which IS the
|
|
402
|
-
# true source-definition order and is immune to the symbol-table quirk.
|
|
403
|
-
# (Methods with no source_location — e.g. C-defined — sort to the front
|
|
404
|
-
# deterministically by name.) We walk the ancestry base→derived so
|
|
405
|
-
# inherited middleware methods run before a subclass's own, de-duping
|
|
406
|
-
# overrides to their first (base) position. Mirrors the Python master's
|
|
407
|
-
# Middleware._discover_methods MRO walk (which leans on __dict__ insertion
|
|
408
|
-
# order — the equivalent of source-definition order).
|
|
409
|
-
def discover_methods(klass, prefix)
|
|
410
|
-
target = klass.is_a?(Class) || klass.is_a?(Module) ? klass.singleton_class : klass.class
|
|
411
|
-
seen = {}
|
|
412
|
-
names = []
|
|
413
|
-
target.ancestors.reverse_each do |ancestor|
|
|
414
|
-
matched = begin
|
|
415
|
-
ancestor.instance_methods(false).select do |name|
|
|
416
|
-
name.to_s.start_with?(prefix) &&
|
|
417
|
-
!seen.key?(name) &&
|
|
418
|
-
klass.respond_to?(name)
|
|
419
|
-
end
|
|
420
|
-
rescue StandardError
|
|
421
|
-
[]
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
ordered = matched.sort_by.with_index do |name, idx|
|
|
425
|
-
line = begin
|
|
426
|
-
loc = ancestor.instance_method(name).source_location
|
|
427
|
-
loc ? loc[1] : -1
|
|
428
|
-
rescue StandardError
|
|
429
|
-
-1
|
|
430
|
-
end
|
|
431
|
-
# Tie-break on the symbol-table index so the result is total/stable.
|
|
432
|
-
[line, idx]
|
|
433
|
-
end
|
|
434
|
-
|
|
435
|
-
ordered.each do |name|
|
|
436
|
-
seen[name] = true
|
|
437
|
-
names << name
|
|
438
|
-
end
|
|
439
|
-
end
|
|
440
|
-
names
|
|
441
|
-
end
|
|
442
|
-
end
|
|
443
|
-
end
|
|
444
|
-
|
|
445
|
-
# ---------------------------------------------------------------------------
|
|
446
|
-
# Built-in class-based middleware
|
|
447
|
-
# ---------------------------------------------------------------------------
|
|
448
|
-
|
|
449
|
-
# CorsClassMiddleware -- sets CORS headers from env vars on every response.
|
|
450
|
-
#
|
|
451
|
-
# A thin adapter over Tina4::CorsMiddleware, which owns the whole policy.
|
|
452
|
-
# It used to be a SECOND, independent implementation of the same rules and
|
|
453
|
-
# the two had already drifted: this copy had no wildcard/credentials guard,
|
|
454
|
-
# fell back to the Referer header (a full URL, not an origin), and on an
|
|
455
|
-
# allow-list MISS returned `allowed.first` - stamping some OTHER allowed
|
|
456
|
-
# origin onto the response of an origin that was not allowed at all. One
|
|
457
|
-
# feature, one implementation.
|
|
458
|
-
class CorsClassMiddleware
|
|
459
|
-
class << self
|
|
460
|
-
def before_cors(request, response)
|
|
461
|
-
env = request.respond_to?(:env) && request.env ? request.env : {}
|
|
462
|
-
origin = request.headers["origin"] if request.respond_to?(:headers)
|
|
463
|
-
env = env.merge("HTTP_ORIGIN" => origin) if origin
|
|
464
|
-
|
|
465
|
-
Tina4::CorsMiddleware.apply_headers(response.headers, env)
|
|
466
|
-
|
|
467
|
-
[request, response]
|
|
468
|
-
end
|
|
469
|
-
end
|
|
470
|
-
end
|
|
471
|
-
|
|
472
|
-
# RateLimiterMiddleware -- tracks requests per IP, returns 429 when exceeded.
|
|
473
|
-
# Config via env: TINA4_RATE_LIMIT (default 100), TINA4_RATE_WINDOW (default 60s).
|
|
474
|
-
class RateLimiterMiddleware
|
|
475
|
-
@store = {}
|
|
476
|
-
@mutex = Mutex.new
|
|
477
|
-
@last_cleanup = Time.now
|
|
478
|
-
|
|
479
|
-
class << self
|
|
480
|
-
def before_rate_limit(request, response)
|
|
481
|
-
limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
|
|
482
|
-
window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
|
|
483
|
-
ip = request.ip || "unknown"
|
|
484
|
-
now = Time.now
|
|
485
|
-
|
|
486
|
-
cleanup_if_needed(now, window)
|
|
487
|
-
|
|
488
|
-
@mutex.synchronize do
|
|
489
|
-
@store[ip] ||= []
|
|
490
|
-
entries = @store[ip]
|
|
491
|
-
|
|
492
|
-
# Sliding window -- drop expired timestamps
|
|
493
|
-
cutoff = now - window
|
|
494
|
-
entries.reject! { |t| t < cutoff }
|
|
495
|
-
|
|
496
|
-
if entries.length >= limit
|
|
497
|
-
oldest = entries.first
|
|
498
|
-
retry_after = [(oldest + window - now).ceil, 1].max
|
|
499
|
-
|
|
500
|
-
response.headers["X-RateLimit-Limit"] = limit.to_s
|
|
501
|
-
response.headers["X-RateLimit-Remaining"] = "0"
|
|
502
|
-
response.headers["X-RateLimit-Reset"] = (oldest + window).to_i.to_s
|
|
503
|
-
response.headers["Retry-After"] = retry_after.to_s
|
|
504
|
-
response.json({ error: "Too Many Requests", retry_after: retry_after }, 429)
|
|
505
|
-
|
|
506
|
-
return [request, response]
|
|
507
|
-
end
|
|
508
|
-
|
|
509
|
-
entries << now
|
|
510
|
-
|
|
511
|
-
response.headers["X-RateLimit-Limit"] = limit.to_s
|
|
512
|
-
response.headers["X-RateLimit-Remaining"] = (limit - entries.length).to_s
|
|
513
|
-
response.headers["X-RateLimit-Reset"] = (now + window).to_i.to_s
|
|
514
|
-
end
|
|
515
|
-
|
|
516
|
-
[request, response]
|
|
517
|
-
end
|
|
518
|
-
|
|
519
|
-
def check(ip)
|
|
520
|
-
limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
|
|
521
|
-
window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
|
|
522
|
-
now = Time.now
|
|
523
|
-
|
|
524
|
-
@mutex.synchronize do
|
|
525
|
-
@store[ip] ||= []
|
|
526
|
-
entries = @store[ip]
|
|
527
|
-
entries.reject! { |t| t < now - window }
|
|
528
|
-
|
|
529
|
-
remaining = [limit - entries.length, 0].max
|
|
530
|
-
reset_at = entries.empty? ? window : (entries.first + window - now).ceil
|
|
531
|
-
|
|
532
|
-
if entries.length >= limit
|
|
533
|
-
return [false, { limit: limit, remaining: 0, reset: reset_at, window: window }]
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
entries << now
|
|
537
|
-
[true, { limit: limit, remaining: remaining - 1, reset: window, window: window }]
|
|
538
|
-
end
|
|
539
|
-
end
|
|
540
|
-
|
|
541
|
-
# Allow resetting state (useful in tests)
|
|
542
|
-
def reset!
|
|
543
|
-
@mutex.synchronize { @store.clear }
|
|
544
|
-
end
|
|
545
|
-
|
|
546
|
-
private
|
|
547
|
-
|
|
548
|
-
def cleanup_if_needed(now, window)
|
|
549
|
-
return if now - @last_cleanup < window
|
|
550
|
-
|
|
551
|
-
@mutex.synchronize do
|
|
552
|
-
return if now - @last_cleanup < window
|
|
553
|
-
|
|
554
|
-
cutoff = now - window
|
|
555
|
-
@store.delete_if do |_ip, entries|
|
|
556
|
-
entries.reject! { |t| t < cutoff }
|
|
557
|
-
entries.empty?
|
|
558
|
-
end
|
|
559
|
-
@last_cleanup = now
|
|
560
|
-
end
|
|
561
|
-
end
|
|
562
|
-
end
|
|
563
|
-
end
|
|
564
|
-
|
|
565
|
-
# RequestLoggerMiddleware -- logs method, path, and elapsed time for every request.
|
|
566
|
-
class RequestLoggerMiddleware
|
|
567
|
-
@request_times = {}
|
|
568
|
-
@mutex = Mutex.new
|
|
569
|
-
|
|
570
|
-
class << self
|
|
571
|
-
def before_log(request, response)
|
|
572
|
-
request_key = "#{request.object_id}"
|
|
573
|
-
@mutex.synchronize do
|
|
574
|
-
@request_times[request_key] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
575
|
-
end
|
|
576
|
-
[request, response]
|
|
577
|
-
end
|
|
578
|
-
|
|
579
|
-
def after_log(request, response)
|
|
580
|
-
request_key = "#{request.object_id}"
|
|
581
|
-
start_time = @mutex.synchronize { @request_times.delete(request_key) }
|
|
582
|
-
|
|
583
|
-
if start_time
|
|
584
|
-
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(3)
|
|
585
|
-
else
|
|
586
|
-
elapsed_ms = 0.0
|
|
587
|
-
end
|
|
588
|
-
|
|
589
|
-
# v3.13.14: dropped the "[RequestLogger]" prefix for format parity
|
|
590
|
-
# with Python/PHP/Node — the line is just METHOD PATH -> STATUS (Nms).
|
|
591
|
-
Tina4::Log.info("#{request.method} #{request.path} -> #{response.status_code} (#{elapsed_ms}ms)")
|
|
592
|
-
[request, response]
|
|
593
|
-
end
|
|
594
|
-
|
|
595
|
-
def reset!
|
|
596
|
-
@mutex.synchronize { @request_times.clear }
|
|
597
|
-
end
|
|
598
|
-
end
|
|
599
|
-
end
|
|
600
|
-
|
|
601
|
-
# CsrfMiddleware -- validates form tokens on state-changing requests.
|
|
602
|
-
#
|
|
603
|
-
# OFF by default: the middleware is NOT attached unless TINA4_CSRF is truthy
|
|
604
|
-
# (true/1/yes/on -- see .attach_from_env, called once at boot in
|
|
605
|
-
# Tina4.initialize!) OR it is registered explicitly via
|
|
606
|
-
# Router.use(CsrfMiddleware). Once attached, TINA4_CSRF=false (or 0/no) is the
|
|
607
|
-
# kill switch that disables enforcement again.
|
|
608
|
-
#
|
|
609
|
-
# Behaviour (identical to the Python master's CsrfMiddleware.before_csrf):
|
|
610
|
-
# - Skips GET, HEAD, OPTIONS (safe methods).
|
|
611
|
-
# - Skips a public write route (.no_auth / auth: false -- auth_required
|
|
612
|
-
# false). The matched Route is attached to the request before this
|
|
613
|
-
# post-match middleware runs, so a genuinely public endpoint (login,
|
|
614
|
-
# webhook) is not gated.
|
|
615
|
-
# - Fails CLOSED: with TINA4_SECRET unset the signing secret resolves to
|
|
616
|
-
# blank (there is NO built-in default), and a blank HMAC key is publicly
|
|
617
|
-
# reproducible -- so no token can be trusted and every write is rejected
|
|
618
|
-
# (403). This is the SEC-01 no-default-secret guarantee.
|
|
619
|
-
# - Skips a request carrying a valid Authorization: Bearer token (API clients).
|
|
620
|
-
# - Reads request.body["formToken"] then the X-Form-Token header.
|
|
621
|
-
# - Rejects a token sent in the query string (403 + a logged warning) -- a
|
|
622
|
-
# URL leaks through logs, referers and history.
|
|
623
|
-
# - Validates the token with Auth.valid_token using the resolved secret, and
|
|
624
|
-
# enforces that the token's "type" claim is "form" -- a non-form JWT in the
|
|
625
|
-
# formToken slot is rejected even when its signature verifies.
|
|
626
|
-
# - If the token carries a session_id, it must match the request session's id.
|
|
627
|
-
# - Every rejection is HTTP 403 with the CSRF_INVALID envelope
|
|
628
|
-
# ({error:true, code:"CSRF_INVALID", message:, status:403}) via
|
|
629
|
-
# response.error -- byte-identical to Python/PHP/Node.
|
|
630
|
-
class CsrfMiddleware
|
|
631
|
-
class << self
|
|
632
|
-
def before_csrf(request, response)
|
|
633
|
-
# 1. Kill switch -- TINA4_CSRF in {false,0,no} disables all CSRF checks,
|
|
634
|
-
# even when the middleware is attached explicitly. Unset = enforced.
|
|
635
|
-
csrf_env = ENV["TINA4_CSRF"].to_s.strip.downcase
|
|
636
|
-
return [request, response] if %w[false 0 no].include?(csrf_env)
|
|
637
|
-
|
|
638
|
-
# 2. Safe HTTP methods never change state -- skip.
|
|
639
|
-
method = (request.method || "GET").upcase
|
|
640
|
-
return [request, response] if %w[GET HEAD OPTIONS].include?(method)
|
|
641
|
-
|
|
642
|
-
# 3. Public write routes (.no_auth / auth: false) skip CSRF -- a
|
|
643
|
-
# genuinely public endpoint has no session to protect. The matched
|
|
644
|
-
# Route is attached to the request before this post-match middleware
|
|
645
|
-
# runs (DispatchPipeline#prepare_route_request); a public write route
|
|
646
|
-
# has auth_required == false -- the SAME signal the auth gate reads.
|
|
647
|
-
# (Reading request.handler, as this once did, was dead code: the live
|
|
648
|
-
# request never carries a handler, so the no_auth skip never fired.)
|
|
649
|
-
route = request.respond_to?(:route) ? request.route : nil
|
|
650
|
-
if route.respond_to?(:auth_required) && route.auth_required == false
|
|
651
|
-
return [request, response]
|
|
652
|
-
end
|
|
653
|
-
|
|
654
|
-
# 4/5. Resolve the signing secret ONCE, fail-closed. TINA4_SECRET unset
|
|
655
|
-
# resolves to blank (there is NO built-in default); a blank HMAC key
|
|
656
|
-
# is publicly reproducible, so a token signed with it -- or with the
|
|
657
|
-
# retired public 'tina4-default-secret' -- is a forgery. Reject every
|
|
658
|
-
# write rather than validate against a guessable key. SEC-01.
|
|
659
|
-
secret = Tina4::Auth.hmac_secret
|
|
660
|
-
if secret.to_s.empty?
|
|
661
|
-
return [request, response.error(
|
|
662
|
-
"CSRF_INVALID",
|
|
663
|
-
"CSRF token cannot be validated: TINA4_SECRET is not set",
|
|
664
|
-
403
|
|
665
|
-
)]
|
|
666
|
-
end
|
|
667
|
-
|
|
668
|
-
# 6. A valid Bearer JWT means an API client authenticating per request --
|
|
669
|
-
# not subject to the cookie-replay attack CSRF defends against.
|
|
670
|
-
headers = request.respond_to?(:headers) ? request.headers : {}
|
|
671
|
-
auth_header = (headers["authorization"] || headers["Authorization"] || "").to_s
|
|
672
|
-
if auth_header.start_with?("Bearer ")
|
|
673
|
-
bearer_token = auth_header[7..].to_s.strip
|
|
674
|
-
return [request, response] if !bearer_token.empty? && Tina4::Auth.valid_token(bearer_token)
|
|
675
|
-
end
|
|
676
|
-
|
|
677
|
-
# 7. A token in the query string leaks through logs/referers/history --
|
|
678
|
-
# reject it. Read the QUERY STRING only, never request.params, which
|
|
679
|
-
# merges the body (a legit body token would false-trip this check).
|
|
680
|
-
query = request.respond_to?(:query) ? request.query : {}
|
|
681
|
-
query = {} unless query.is_a?(Hash)
|
|
682
|
-
if !query["formToken"].to_s.empty?
|
|
683
|
-
Tina4::Log.warning("[CSRF] Token found in query string — rejected for security")
|
|
684
|
-
return [request, response.error(
|
|
685
|
-
"CSRF_INVALID",
|
|
686
|
-
"Form token must not be sent in the URL query string",
|
|
687
|
-
403
|
|
688
|
-
)]
|
|
689
|
-
end
|
|
690
|
-
|
|
691
|
-
# 8. Extract the token: body first, then the X-Form-Token header.
|
|
692
|
-
token = nil
|
|
693
|
-
body = request.respond_to?(:body) ? request.body : nil
|
|
694
|
-
token = body["formToken"] if body.is_a?(Hash)
|
|
695
|
-
if token.nil? || token.to_s.empty?
|
|
696
|
-
token = headers["X-Form-Token"] || headers["x-form-token"]
|
|
697
|
-
end
|
|
698
|
-
|
|
699
|
-
# 9. Missing token -- reject.
|
|
700
|
-
if token.nil? || token.to_s.empty?
|
|
701
|
-
return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
|
|
702
|
-
end
|
|
703
|
-
|
|
704
|
-
# 10. Validate signature + expiry with the resolved secret. valid_token
|
|
705
|
-
# returns the verified payload Hash (or nil) in 3.13.0+.
|
|
706
|
-
payload = Tina4::Auth.valid_token(token.to_s)
|
|
707
|
-
unless payload
|
|
708
|
-
return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
|
|
709
|
-
end
|
|
710
|
-
|
|
711
|
-
# 11. Enforce the form-token TYPE -- a valid signature is not enough. A
|
|
712
|
-
# non-form JWT (e.g. an auth/session token) must never be accepted in
|
|
713
|
-
# the formToken slot.
|
|
714
|
-
payload = {} unless payload.is_a?(Hash)
|
|
715
|
-
if payload["type"] != "form"
|
|
716
|
-
return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
|
|
717
|
-
end
|
|
718
|
-
|
|
719
|
-
# 12. Session binding -- a token minted for one session cannot be replayed
|
|
720
|
-
# against another. Read the request session's OWN id: Tina4::Session
|
|
721
|
-
# exposes get_session_id (NOT session_id -- reading session_id then
|
|
722
|
-
# session.get("session_id") looked up a DATA key, never the id, so
|
|
723
|
-
# binding silently never fired against a real session). A plain Hash
|
|
724
|
-
# session exposes "session_id".
|
|
725
|
-
token_session_id = payload["session_id"]
|
|
726
|
-
if token_session_id
|
|
727
|
-
session = request.respond_to?(:session) ? request.session : nil
|
|
728
|
-
current_session_id =
|
|
729
|
-
if session.nil?
|
|
730
|
-
nil
|
|
731
|
-
elsif session.respond_to?(:session_id)
|
|
732
|
-
session.session_id
|
|
733
|
-
elsif session.respond_to?(:get_session_id)
|
|
734
|
-
session.get_session_id
|
|
735
|
-
elsif session.is_a?(Hash)
|
|
736
|
-
session["session_id"]
|
|
737
|
-
end
|
|
738
|
-
|
|
739
|
-
if current_session_id && token_session_id != current_session_id
|
|
740
|
-
return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
|
|
741
|
-
end
|
|
742
|
-
end
|
|
743
|
-
|
|
744
|
-
# 13. All checks passed.
|
|
745
|
-
[request, response]
|
|
746
|
-
end
|
|
747
|
-
|
|
748
|
-
# Auto-attach CsrfMiddleware when TINA4_CSRF is enabled in the environment.
|
|
749
|
-
#
|
|
750
|
-
# CSRF is OFF by default: with TINA4_CSRF unset the middleware is never
|
|
751
|
-
# attached, so a default app has no CSRF gate. A truthy value
|
|
752
|
-
# (true/1/yes/on, case-insensitive) attaches it globally so every
|
|
753
|
-
# state-changing route is gated -- the env flag is the switch, no code
|
|
754
|
-
# change needed. Idempotent (Middleware.use de-dupes). Returns true when the
|
|
755
|
-
# middleware is now attached. Mirrors the Python master's
|
|
756
|
-
# attach_csrf_from_env; the framework calls it once at boot
|
|
757
|
-
# (Tina4.initialize!). A false/0/no value still lets an explicit
|
|
758
|
-
# Router.use(CsrfMiddleware) opt-in be disabled at runtime by the kill
|
|
759
|
-
# switch in before_csrf.
|
|
760
|
-
def attach_from_env
|
|
761
|
-
value = ENV["TINA4_CSRF"].to_s.strip.downcase
|
|
762
|
-
if %w[true 1 yes on].include?(value)
|
|
763
|
-
Tina4::Middleware.use(Tina4::CsrfMiddleware)
|
|
764
|
-
return true
|
|
765
|
-
end
|
|
766
|
-
false
|
|
767
|
-
end
|
|
768
|
-
end
|
|
769
|
-
end
|
|
770
|
-
|
|
771
|
-
# SecurityHeadersMiddleware -- injects security headers on every response.
|
|
772
|
-
# Config via env:
|
|
773
|
-
# TINA4_FRAME_OPTIONS — X-Frame-Options (default: SAMEORIGIN)
|
|
774
|
-
# TINA4_HSTS — Strict-Transport-Security max-age (default: "" = off)
|
|
775
|
-
# TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
|
|
776
|
-
# TINA4_REFERRER_POLICY — Referrer-Policy (default: strict-origin-when-cross-origin)
|
|
777
|
-
# TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
|
|
778
|
-
class SecurityHeadersMiddleware
|
|
779
|
-
class << self
|
|
780
|
-
# Register this middleware in the default chain (secure-by-default).
|
|
781
|
-
#
|
|
782
|
-
# Unlike CSRF (opt-in via TINA4_CSRF) this is UNCONDITIONAL: a default app
|
|
783
|
-
# ships the security headers with no opt-in -- the SECHDR-DEC-01 posture
|
|
784
|
-
# that closes the SECHDR-OFF-BY-DEFAULT gap (the middleware existed with good
|
|
785
|
-
# defaults but was never registered). Idempotent (Middleware.use de-dupes).
|
|
786
|
-
# The framework calls it once at boot (Tina4.initialize!). Returns true.
|
|
787
|
-
def attach
|
|
788
|
-
Tina4::Middleware.use(self)
|
|
789
|
-
true
|
|
790
|
-
end
|
|
791
|
-
|
|
792
|
-
def before_security(request, response)
|
|
793
|
-
response.headers["X-Frame-Options"] = ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"
|
|
794
|
-
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
795
|
-
|
|
796
|
-
# HSTS is HTTPS-only (SECHDR-DEC-02): a downgrade-protection header on a
|
|
797
|
-
# plain-HTTP response is inert at best and ships a bad max-age on an
|
|
798
|
-
# unencrypted scheme at worst. Emit it ONLY when TINA4_HSTS is set AND the
|
|
799
|
-
# request is HTTPS -- Request.secure_scheme? honours x-forwarded-proto
|
|
800
|
-
# (first hop) then rack.url_scheme, the same source of truth the session
|
|
801
|
-
# cookie's Secure flag uses. Defensive env lookup keeps a non-Request from
|
|
802
|
-
# turning every response into a 500 now that this runs on every request.
|
|
803
|
-
hsts = ENV["TINA4_HSTS"] || ""
|
|
804
|
-
env = request.respond_to?(:env) ? request.env : {}
|
|
805
|
-
if !hsts.empty? && Tina4::Request.secure_scheme?(env)
|
|
806
|
-
response.headers["Strict-Transport-Security"] = "max-age=#{hsts}; includeSubDomains"
|
|
807
|
-
end
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
response.headers["
|
|
811
|
-
response.headers["
|
|
812
|
-
response.headers["
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
class Middleware
|
|
7
|
+
class << self
|
|
8
|
+
def before_handlers
|
|
9
|
+
@before_handlers ||= []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def after_handlers
|
|
13
|
+
@after_handlers ||= []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Registry of class-based middleware (registered via Router.use)
|
|
17
|
+
def global_middleware
|
|
18
|
+
@global_middleware ||= []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Parity alias matching Python/PHP/Node orchestrators.
|
|
22
|
+
def get_global
|
|
23
|
+
global_middleware.dup
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def before(pattern = nil, &block)
|
|
27
|
+
before_handlers << { pattern: pattern, handler: block }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def after(pattern = nil, &block)
|
|
31
|
+
after_handlers << { pattern: pattern, handler: block }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Register a class-based middleware globally.
|
|
35
|
+
# The class should define static before_* and/or after_* methods.
|
|
36
|
+
def use(klass)
|
|
37
|
+
global_middleware << klass unless global_middleware.include?(klass)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Global middleware that runs BEFORE route matching.
|
|
41
|
+
#
|
|
42
|
+
# A middleware opts in by declaring `def self.pre_match?; true; end`.
|
|
43
|
+
#
|
|
44
|
+
# NOT `before_match?` - the hook discovery treats every `before_*` method
|
|
45
|
+
# as a middleware hook and calls it with (request, response), so that name
|
|
46
|
+
# made the flag itself run as middleware and 500 the request.
|
|
47
|
+
# Everything else stays where it has always run - after matching - so
|
|
48
|
+
# this is additive and no existing middleware changes behaviour.
|
|
49
|
+
#
|
|
50
|
+
# The split exists because the two groups need opposite things. CORS must
|
|
51
|
+
# run before matching so its headers survive a short-circuited 401/403;
|
|
52
|
+
# a browser that gets a 401 without them reports a CORS error and the real
|
|
53
|
+
# status is invisible. CSRF must run AFTER, because it reads the matched
|
|
54
|
+
# route's metadata to honour a route marked no_auth - PHP shipped exactly
|
|
55
|
+
# that bypass as dead code once, because the metadata was not set yet.
|
|
56
|
+
def pre_match_middleware
|
|
57
|
+
global_middleware.select { |k| k.respond_to?(:pre_match?) && k.pre_match? }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Global middleware that runs after matching, once the matched route's
|
|
61
|
+
# metadata is readable. This is the default.
|
|
62
|
+
def post_match_middleware
|
|
63
|
+
global_middleware.reject { |k| k.respond_to?(:pre_match?) && k.pre_match? }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clear!
|
|
67
|
+
@before_handlers = []
|
|
68
|
+
@after_handlers = []
|
|
69
|
+
@global_middleware = []
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Run all "before" hooks: block-based handlers, then class-based before_*
|
|
73
|
+
# methods (in definition order).
|
|
74
|
+
#
|
|
75
|
+
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
76
|
+
# middleware classes explicitly.
|
|
77
|
+
#
|
|
78
|
+
# THE RETURN-VALUE CONTRACT is #apply_before_result below — one table,
|
|
79
|
+
# applied to EVERY before_* hook at EVERY scope. Per-route middleware
|
|
80
|
+
# comes through this same method (Tina4::Route#run_middleware), so there
|
|
81
|
+
# is exactly one implementation of the table, not two.
|
|
82
|
+
#
|
|
83
|
+
# M2 — visible-but-resilient: every before_* call is wrapped so a THROW
|
|
84
|
+
# never crashes the worker. On a throw the error is LOGGED and the
|
|
85
|
+
# response becomes a clean 500 ({"error":"Internal Server Error",
|
|
86
|
+
# "status":500}), then processing halts (handler skipped) — deterministic,
|
|
87
|
+
# never an unhandled exception. after_* still run on either halt path
|
|
88
|
+
# (see the dispatcher / #run_after docstring).
|
|
89
|
+
#
|
|
90
|
+
# Returns true on success, or false to halt the request (handler skipped).
|
|
91
|
+
def run_before(middleware_classes, request, response)
|
|
92
|
+
# The response object the CALLER holds. A hook may hand back a
|
|
93
|
+
# different Response; on a halt that object IS the answer, so its state
|
|
94
|
+
# is adopted onto this one before returning — otherwise the dispatcher
|
|
95
|
+
# would serve the object it still has a reference to and the
|
|
96
|
+
# short-circuit would silently vanish.
|
|
97
|
+
origin = response
|
|
98
|
+
|
|
99
|
+
# 1. Block-based before handlers (pattern-matched). These are a
|
|
100
|
+
# Ruby-only surface (Python/PHP/Node have no block form) and keep
|
|
101
|
+
# their historical "false halts" contract: a block's value is its
|
|
102
|
+
# last expression, so reading a returned Response as a
|
|
103
|
+
# short-circuit would fire on any block ending in a chainable
|
|
104
|
+
# response call.
|
|
105
|
+
before_handlers.each do |entry|
|
|
106
|
+
next unless matches_pattern?(request.path, entry[:pattern])
|
|
107
|
+
|
|
108
|
+
begin
|
|
109
|
+
result = entry[:handler].call(request, response)
|
|
110
|
+
rescue StandardError, ScriptError => error
|
|
111
|
+
middleware_500(response, "before handler", error)
|
|
112
|
+
return false
|
|
113
|
+
end
|
|
114
|
+
return false if result == false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# 2. Class-based middleware: call every before_* method (definition order)
|
|
118
|
+
middleware_classes.each do |klass|
|
|
119
|
+
before_methods_for(klass).each do |method_name|
|
|
120
|
+
begin
|
|
121
|
+
result = klass.send(method_name, request, response)
|
|
122
|
+
rescue StandardError, ScriptError => error
|
|
123
|
+
middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
|
|
124
|
+
return false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
halt, request, response = apply_before_result(result, request, response)
|
|
128
|
+
next unless halt
|
|
129
|
+
|
|
130
|
+
adopt_response(origin, response) unless response.equal?(origin)
|
|
131
|
+
return false
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Run all "after" hooks: block-based handlers, then class-based after_*
|
|
139
|
+
# methods (in definition order).
|
|
140
|
+
#
|
|
141
|
+
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
142
|
+
# middleware classes explicitly.
|
|
143
|
+
#
|
|
144
|
+
# AFTER-ON-4xx RULE (M2, documented + consistent across all 4 frameworks):
|
|
145
|
+
# after_* ALWAYS run even when a before_* short-circuited with status >= 400
|
|
146
|
+
# and the handler was skipped — so they can still add headers / logging.
|
|
147
|
+
# The dispatcher calls #run_after unconditionally after the before/handler
|
|
148
|
+
# block (including on the 4xx / throw halt path).
|
|
149
|
+
#
|
|
150
|
+
# M2 — every after_* call is wrapped: a THROW is LOGGED and turns the
|
|
151
|
+
# response into a clean 500, then the REMAINING after_* still run (they
|
|
152
|
+
# may add headers/logging). Never an unhandled crash.
|
|
153
|
+
#
|
|
154
|
+
# RETURN VALUES: an after_* hook shapes the response the same way a
|
|
155
|
+
# before_* one does — a returned Tina4::Response BECOMES the response, a
|
|
156
|
+
# returned [request, response] pair rebinds both. What it CANNOT do is
|
|
157
|
+
# halt: the handler has already run, so there is nothing left to skip,
|
|
158
|
+
# and stopping the remaining after_* would contradict the AFTER-ON-4xx
|
|
159
|
+
# resilience rule above (they exist to add headers/logging on every path).
|
|
160
|
+
# So `false` and a >= 400 status are inert here by design.
|
|
161
|
+
def run_after(middleware_classes, request, response)
|
|
162
|
+
origin = response
|
|
163
|
+
|
|
164
|
+
# 1. Block-based after handlers (pattern-matched)
|
|
165
|
+
after_handlers.each do |entry|
|
|
166
|
+
next unless matches_pattern?(request.path, entry[:pattern])
|
|
167
|
+
|
|
168
|
+
begin
|
|
169
|
+
entry[:handler].call(request, response)
|
|
170
|
+
rescue StandardError, ScriptError => error
|
|
171
|
+
middleware_500(response, "after handler", error)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# 2. Class-based middleware: call every after_* method (definition order)
|
|
176
|
+
middleware_classes.each do |klass|
|
|
177
|
+
after_methods_for(klass).each do |method_name|
|
|
178
|
+
begin
|
|
179
|
+
result = klass.send(method_name, request, response)
|
|
180
|
+
rescue StandardError, ScriptError => error
|
|
181
|
+
middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
|
|
182
|
+
next
|
|
183
|
+
end
|
|
184
|
+
if result.is_a?(Tina4::Response)
|
|
185
|
+
response = result
|
|
186
|
+
elsif result.is_a?(Array) && result.length == 2
|
|
187
|
+
request, response = result
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
adopt_response(origin, response) unless response.equal?(origin)
|
|
193
|
+
response
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Deterministic clean 500 for a middleware that threw. Logs the cause
|
|
197
|
+
# (NEVER silent) then sets the response to the canonical error shape —
|
|
198
|
+
# byte-identical to the Python master ({"error":"Internal Server Error",
|
|
199
|
+
# "status":500} + status 500). Returns the response for chaining.
|
|
200
|
+
def middleware_500(response, label, error)
|
|
201
|
+
begin
|
|
202
|
+
Tina4::Log.error(
|
|
203
|
+
"Middleware #{label} raised #{error.class.name}: #{error.message}"
|
|
204
|
+
)
|
|
205
|
+
rescue StandardError
|
|
206
|
+
begin
|
|
207
|
+
$stderr.puts("Middleware #{label} raised #{error.class.name}: #{error.message}")
|
|
208
|
+
$stderr.flush
|
|
209
|
+
rescue StandardError
|
|
210
|
+
# never let logging break the worker
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
response.json({ error: "Internal Server Error", status: 500 }, 500)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The `false` row of the return-value table, on its own.
|
|
217
|
+
#
|
|
218
|
+
# A middleware that halts by returning false keeps the response it set;
|
|
219
|
+
# only a response still left default/empty becomes a 403. Public because
|
|
220
|
+
# per-route "filter" middleware (a 2-arg callable returning false, see
|
|
221
|
+
# Tina4::Route#run_middleware) must obey the SAME row as a before_* hook,
|
|
222
|
+
# and the rule should exist exactly once.
|
|
223
|
+
def refuse(request, response)
|
|
224
|
+
forbid(request, response) if default_response?(response)
|
|
225
|
+
response
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
# ── THE BEFORE-HOOK RETURN-VALUE TABLE ────────────────────────────────
|
|
231
|
+
#
|
|
232
|
+
# Interpret ONE before_* hook's return value. Identical in Python, PHP,
|
|
233
|
+
# Ruby and Node, and applied at EVERY scope — global (Router.use) and
|
|
234
|
+
# per-route (route.middleware) both land here.
|
|
235
|
+
#
|
|
236
|
+
# a Tina4::Response SHORT-CIRCUIT. That object IS the response, at
|
|
237
|
+
# ANY status. This is the PRIMARY rule: it is the
|
|
238
|
+
# only one that can express a 302 redirect.
|
|
239
|
+
# [request, response] rebind both, continue
|
|
240
|
+
# false SHORT-CIRCUIT. Send the response AS SET; only
|
|
241
|
+
# when it is still default/empty does it become a
|
|
242
|
+
# 403. (Per-route middleware used to answer a
|
|
243
|
+
# halt with a HARDCODED 403 that threw away
|
|
244
|
+
# whatever the middleware had set — that is gone.)
|
|
245
|
+
# nil / anything else continue
|
|
246
|
+
#
|
|
247
|
+
# LEGACY COMPATIBILITY PATH (retained, deliberately NOT the main
|
|
248
|
+
# mechanism): after the hook returns, a response status >= 400 also
|
|
249
|
+
# short-circuits, even when the hook returned nil. Middleware written
|
|
250
|
+
# before the Response rule existed signals refusal that way, so it stays
|
|
251
|
+
# honoured — but it cannot express a 3xx redirect, which is exactly why
|
|
252
|
+
# the Response rule above is primary and this one is the fallback.
|
|
253
|
+
#
|
|
254
|
+
# This check used to be nested INSIDE the "returned a 2-element Array"
|
|
255
|
+
# branch, so a hook that set 403 and returned nil was ignored and the
|
|
256
|
+
# handler RAN — an auth middleware that refused without returning the pair
|
|
257
|
+
# was a no-op. Python, PHP and Node all check the status unconditionally
|
|
258
|
+
# after the call; Rails short-circuits on the response STATE, not on what
|
|
259
|
+
# the filter returned. Now it is unconditional here too.
|
|
260
|
+
#
|
|
261
|
+
# Returns [halt?, request, response].
|
|
262
|
+
def apply_before_result(result, request, response)
|
|
263
|
+
if result.is_a?(Tina4::Response)
|
|
264
|
+
return [true, request, result]
|
|
265
|
+
elsif result.is_a?(Array) && result.length == 2
|
|
266
|
+
request, response = result
|
|
267
|
+
elsif result == false
|
|
268
|
+
refuse(request, response)
|
|
269
|
+
return [true, request, response]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
status = status_of(response)
|
|
273
|
+
return [true, request, response] if status.is_a?(Integer) && status >= 400
|
|
274
|
+
|
|
275
|
+
[false, request, response]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Read a response's status defensively — a middleware may hand back any
|
|
279
|
+
# response-shaped object. Mirrors the Python master's
|
|
280
|
+
# `getattr(response, "status_code", None) or getattr(response, "status", 0)`.
|
|
281
|
+
def status_of(response)
|
|
282
|
+
return response.status_code if response.respond_to?(:status_code)
|
|
283
|
+
return response.status if response.respond_to?(:status)
|
|
284
|
+
|
|
285
|
+
nil
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Has this response been left untouched? Only then does a `false` return
|
|
289
|
+
# get turned into a 403 — a middleware that already answered keeps its
|
|
290
|
+
# own answer.
|
|
291
|
+
def default_response?(response)
|
|
292
|
+
status = status_of(response)
|
|
293
|
+
return false unless status.nil? || status == 200
|
|
294
|
+
|
|
295
|
+
body = response.respond_to?(:body) ? response.body : nil
|
|
296
|
+
body.to_s.empty?
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The canonical refusal (ERR-DEC-01/ERR-DEC-02): routed through the SAME
|
|
300
|
+
# negotiated renderer 404/500 use, so a middleware refusal looks like
|
|
301
|
+
# every other error page - a user template if the app ships one, the
|
|
302
|
+
# framework's 403.twig otherwise, negotiated JSON for an API client -
|
|
303
|
+
# instead of the old bare/un-negotiated `{"error":"Forbidden","status":403}`.
|
|
304
|
+
def forbid(request, response)
|
|
305
|
+
request_id = Tina4::Log.get_request_id || ""
|
|
306
|
+
accept = request.respond_to?(:headers) ? (request.headers["accept"] || "") : ""
|
|
307
|
+
|
|
308
|
+
if Tina4::Template.wants_json?(accept)
|
|
309
|
+
set_response_json(response, error_json_body(request_id))
|
|
310
|
+
return response
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
path = request.respond_to?(:path) ? request.path.to_s : ""
|
|
314
|
+
html = begin
|
|
315
|
+
Tina4::Template.render_error(403, { "path" => CGI.escapeHTML(path), "request_id" => request_id })
|
|
316
|
+
rescue StandardError
|
|
317
|
+
nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
if html && response.respond_to?(:html)
|
|
321
|
+
response.html(html, 403)
|
|
322
|
+
else
|
|
323
|
+
set_response_json(response, error_json_body(request_id))
|
|
324
|
+
end
|
|
325
|
+
response
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# The canonical JSON error envelope (ERR-DEC-02) - the SAME shape
|
|
329
|
+
# Python/PHP/Node build: {error: true, code, message, status, request_id}.
|
|
330
|
+
def error_json_body(request_id)
|
|
331
|
+
{ error: true, code: "FORBIDDEN", message: "Forbidden", status: 403, request_id: request_id }
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def set_response_json(response, body)
|
|
335
|
+
if response.respond_to?(:json)
|
|
336
|
+
response.json(body, 403)
|
|
337
|
+
elsif response.respond_to?(:status_code=)
|
|
338
|
+
response.status_code = 403
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Copy a response's state onto the object the CALLER still holds.
|
|
343
|
+
#
|
|
344
|
+
# Ruby passes references, so a hook that MUTATES the response it was given
|
|
345
|
+
# needs nothing from us. This exists for the hook that hands back a
|
|
346
|
+
# DIFFERENT Response object: the contract says that object IS the
|
|
347
|
+
# response, and the dispatcher only ever serves the one it passed in.
|
|
348
|
+
# Applied on the halt paths, where the response is the answer.
|
|
349
|
+
def adopt_response(target, source)
|
|
350
|
+
return target unless target.is_a?(Tina4::Response) && source.is_a?(Tina4::Response)
|
|
351
|
+
|
|
352
|
+
target.status_code = source.status_code
|
|
353
|
+
target.headers = source.headers
|
|
354
|
+
target.body = source.body
|
|
355
|
+
target.cookies = source.cookies
|
|
356
|
+
target
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Human-readable label for a middleware (class name, or the class of an
|
|
360
|
+
# instance) used in the logged 500 message.
|
|
361
|
+
def class_label(klass)
|
|
362
|
+
if klass.is_a?(Class) || klass.is_a?(Module)
|
|
363
|
+
klass.name || klass.to_s
|
|
364
|
+
else
|
|
365
|
+
klass.class.name || klass.class.to_s
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def matches_pattern?(path, pattern)
|
|
370
|
+
return true if pattern.nil?
|
|
371
|
+
case pattern
|
|
372
|
+
when String
|
|
373
|
+
path.start_with?(pattern)
|
|
374
|
+
when Regexp
|
|
375
|
+
pattern.match?(path)
|
|
376
|
+
else
|
|
377
|
+
true
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Collect all class methods matching before_* in DEFINITION order.
|
|
382
|
+
def before_methods_for(klass)
|
|
383
|
+
discover_methods(klass, "before_")
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Collect all class methods matching after_* in DEFINITION order.
|
|
387
|
+
def after_methods_for(klass)
|
|
388
|
+
discover_methods(klass, "after_")
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# ----------------------------------------------------------------------
|
|
392
|
+
# MIDDLEWARE ORDERING (M1) — within a class, before_*/after_* methods run
|
|
393
|
+
# in SOURCE-DEFINITION order, NOT alphabetical. Cross-class order is the
|
|
394
|
+
# natural iteration of the registered middleware list (registration
|
|
395
|
+
# order). before_* run before the handler, after_* after.
|
|
396
|
+
#
|
|
397
|
+
# WHY source line numbers, not instance_methods(false): in Ruby/PRISM
|
|
398
|
+
# `instance_methods(false)` is NOT a reliable definition-order report —
|
|
399
|
+
# once a method NAME (symbol) has been defined on any other class first,
|
|
400
|
+
# that name can sort ahead in a later class's list. So we sort the
|
|
401
|
+
# matching methods by their `source_location` line number, which IS the
|
|
402
|
+
# true source-definition order and is immune to the symbol-table quirk.
|
|
403
|
+
# (Methods with no source_location — e.g. C-defined — sort to the front
|
|
404
|
+
# deterministically by name.) We walk the ancestry base→derived so
|
|
405
|
+
# inherited middleware methods run before a subclass's own, de-duping
|
|
406
|
+
# overrides to their first (base) position. Mirrors the Python master's
|
|
407
|
+
# Middleware._discover_methods MRO walk (which leans on __dict__ insertion
|
|
408
|
+
# order — the equivalent of source-definition order).
|
|
409
|
+
def discover_methods(klass, prefix)
|
|
410
|
+
target = klass.is_a?(Class) || klass.is_a?(Module) ? klass.singleton_class : klass.class
|
|
411
|
+
seen = {}
|
|
412
|
+
names = []
|
|
413
|
+
target.ancestors.reverse_each do |ancestor|
|
|
414
|
+
matched = begin
|
|
415
|
+
ancestor.instance_methods(false).select do |name|
|
|
416
|
+
name.to_s.start_with?(prefix) &&
|
|
417
|
+
!seen.key?(name) &&
|
|
418
|
+
klass.respond_to?(name)
|
|
419
|
+
end
|
|
420
|
+
rescue StandardError
|
|
421
|
+
[]
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
ordered = matched.sort_by.with_index do |name, idx|
|
|
425
|
+
line = begin
|
|
426
|
+
loc = ancestor.instance_method(name).source_location
|
|
427
|
+
loc ? loc[1] : -1
|
|
428
|
+
rescue StandardError
|
|
429
|
+
-1
|
|
430
|
+
end
|
|
431
|
+
# Tie-break on the symbol-table index so the result is total/stable.
|
|
432
|
+
[line, idx]
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
ordered.each do |name|
|
|
436
|
+
seen[name] = true
|
|
437
|
+
names << name
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
names
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# ---------------------------------------------------------------------------
|
|
446
|
+
# Built-in class-based middleware
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
# CorsClassMiddleware -- sets CORS headers from env vars on every response.
|
|
450
|
+
#
|
|
451
|
+
# A thin adapter over Tina4::CorsMiddleware, which owns the whole policy.
|
|
452
|
+
# It used to be a SECOND, independent implementation of the same rules and
|
|
453
|
+
# the two had already drifted: this copy had no wildcard/credentials guard,
|
|
454
|
+
# fell back to the Referer header (a full URL, not an origin), and on an
|
|
455
|
+
# allow-list MISS returned `allowed.first` - stamping some OTHER allowed
|
|
456
|
+
# origin onto the response of an origin that was not allowed at all. One
|
|
457
|
+
# feature, one implementation.
|
|
458
|
+
class CorsClassMiddleware
|
|
459
|
+
class << self
|
|
460
|
+
def before_cors(request, response)
|
|
461
|
+
env = request.respond_to?(:env) && request.env ? request.env : {}
|
|
462
|
+
origin = request.headers["origin"] if request.respond_to?(:headers)
|
|
463
|
+
env = env.merge("HTTP_ORIGIN" => origin) if origin
|
|
464
|
+
|
|
465
|
+
Tina4::CorsMiddleware.apply_headers(response.headers, env)
|
|
466
|
+
|
|
467
|
+
[request, response]
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# RateLimiterMiddleware -- tracks requests per IP, returns 429 when exceeded.
|
|
473
|
+
# Config via env: TINA4_RATE_LIMIT (default 100), TINA4_RATE_WINDOW (default 60s).
|
|
474
|
+
class RateLimiterMiddleware
|
|
475
|
+
@store = {}
|
|
476
|
+
@mutex = Mutex.new
|
|
477
|
+
@last_cleanup = Time.now
|
|
478
|
+
|
|
479
|
+
class << self
|
|
480
|
+
def before_rate_limit(request, response)
|
|
481
|
+
limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
|
|
482
|
+
window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
|
|
483
|
+
ip = request.ip || "unknown"
|
|
484
|
+
now = Time.now
|
|
485
|
+
|
|
486
|
+
cleanup_if_needed(now, window)
|
|
487
|
+
|
|
488
|
+
@mutex.synchronize do
|
|
489
|
+
@store[ip] ||= []
|
|
490
|
+
entries = @store[ip]
|
|
491
|
+
|
|
492
|
+
# Sliding window -- drop expired timestamps
|
|
493
|
+
cutoff = now - window
|
|
494
|
+
entries.reject! { |t| t < cutoff }
|
|
495
|
+
|
|
496
|
+
if entries.length >= limit
|
|
497
|
+
oldest = entries.first
|
|
498
|
+
retry_after = [(oldest + window - now).ceil, 1].max
|
|
499
|
+
|
|
500
|
+
response.headers["X-RateLimit-Limit"] = limit.to_s
|
|
501
|
+
response.headers["X-RateLimit-Remaining"] = "0"
|
|
502
|
+
response.headers["X-RateLimit-Reset"] = (oldest + window).to_i.to_s
|
|
503
|
+
response.headers["Retry-After"] = retry_after.to_s
|
|
504
|
+
response.json({ error: "Too Many Requests", retry_after: retry_after }, 429)
|
|
505
|
+
|
|
506
|
+
return [request, response]
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
entries << now
|
|
510
|
+
|
|
511
|
+
response.headers["X-RateLimit-Limit"] = limit.to_s
|
|
512
|
+
response.headers["X-RateLimit-Remaining"] = (limit - entries.length).to_s
|
|
513
|
+
response.headers["X-RateLimit-Reset"] = (now + window).to_i.to_s
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
[request, response]
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def check(ip)
|
|
520
|
+
limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
|
|
521
|
+
window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
|
|
522
|
+
now = Time.now
|
|
523
|
+
|
|
524
|
+
@mutex.synchronize do
|
|
525
|
+
@store[ip] ||= []
|
|
526
|
+
entries = @store[ip]
|
|
527
|
+
entries.reject! { |t| t < now - window }
|
|
528
|
+
|
|
529
|
+
remaining = [limit - entries.length, 0].max
|
|
530
|
+
reset_at = entries.empty? ? window : (entries.first + window - now).ceil
|
|
531
|
+
|
|
532
|
+
if entries.length >= limit
|
|
533
|
+
return [false, { limit: limit, remaining: 0, reset: reset_at, window: window }]
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
entries << now
|
|
537
|
+
[true, { limit: limit, remaining: remaining - 1, reset: window, window: window }]
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Allow resetting state (useful in tests)
|
|
542
|
+
def reset!
|
|
543
|
+
@mutex.synchronize { @store.clear }
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
private
|
|
547
|
+
|
|
548
|
+
def cleanup_if_needed(now, window)
|
|
549
|
+
return if now - @last_cleanup < window
|
|
550
|
+
|
|
551
|
+
@mutex.synchronize do
|
|
552
|
+
return if now - @last_cleanup < window
|
|
553
|
+
|
|
554
|
+
cutoff = now - window
|
|
555
|
+
@store.delete_if do |_ip, entries|
|
|
556
|
+
entries.reject! { |t| t < cutoff }
|
|
557
|
+
entries.empty?
|
|
558
|
+
end
|
|
559
|
+
@last_cleanup = now
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# RequestLoggerMiddleware -- logs method, path, and elapsed time for every request.
|
|
566
|
+
class RequestLoggerMiddleware
|
|
567
|
+
@request_times = {}
|
|
568
|
+
@mutex = Mutex.new
|
|
569
|
+
|
|
570
|
+
class << self
|
|
571
|
+
def before_log(request, response)
|
|
572
|
+
request_key = "#{request.object_id}"
|
|
573
|
+
@mutex.synchronize do
|
|
574
|
+
@request_times[request_key] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
575
|
+
end
|
|
576
|
+
[request, response]
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def after_log(request, response)
|
|
580
|
+
request_key = "#{request.object_id}"
|
|
581
|
+
start_time = @mutex.synchronize { @request_times.delete(request_key) }
|
|
582
|
+
|
|
583
|
+
if start_time
|
|
584
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(3)
|
|
585
|
+
else
|
|
586
|
+
elapsed_ms = 0.0
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# v3.13.14: dropped the "[RequestLogger]" prefix for format parity
|
|
590
|
+
# with Python/PHP/Node — the line is just METHOD PATH -> STATUS (Nms).
|
|
591
|
+
Tina4::Log.info("#{request.method} #{request.path} -> #{response.status_code} (#{elapsed_ms}ms)")
|
|
592
|
+
[request, response]
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def reset!
|
|
596
|
+
@mutex.synchronize { @request_times.clear }
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# CsrfMiddleware -- validates form tokens on state-changing requests.
|
|
602
|
+
#
|
|
603
|
+
# OFF by default: the middleware is NOT attached unless TINA4_CSRF is truthy
|
|
604
|
+
# (true/1/yes/on -- see .attach_from_env, called once at boot in
|
|
605
|
+
# Tina4.initialize!) OR it is registered explicitly via
|
|
606
|
+
# Router.use(CsrfMiddleware). Once attached, TINA4_CSRF=false (or 0/no) is the
|
|
607
|
+
# kill switch that disables enforcement again.
|
|
608
|
+
#
|
|
609
|
+
# Behaviour (identical to the Python master's CsrfMiddleware.before_csrf):
|
|
610
|
+
# - Skips GET, HEAD, OPTIONS (safe methods).
|
|
611
|
+
# - Skips a public write route (.no_auth / auth: false -- auth_required
|
|
612
|
+
# false). The matched Route is attached to the request before this
|
|
613
|
+
# post-match middleware runs, so a genuinely public endpoint (login,
|
|
614
|
+
# webhook) is not gated.
|
|
615
|
+
# - Fails CLOSED: with TINA4_SECRET unset the signing secret resolves to
|
|
616
|
+
# blank (there is NO built-in default), and a blank HMAC key is publicly
|
|
617
|
+
# reproducible -- so no token can be trusted and every write is rejected
|
|
618
|
+
# (403). This is the SEC-01 no-default-secret guarantee.
|
|
619
|
+
# - Skips a request carrying a valid Authorization: Bearer token (API clients).
|
|
620
|
+
# - Reads request.body["formToken"] then the X-Form-Token header.
|
|
621
|
+
# - Rejects a token sent in the query string (403 + a logged warning) -- a
|
|
622
|
+
# URL leaks through logs, referers and history.
|
|
623
|
+
# - Validates the token with Auth.valid_token using the resolved secret, and
|
|
624
|
+
# enforces that the token's "type" claim is "form" -- a non-form JWT in the
|
|
625
|
+
# formToken slot is rejected even when its signature verifies.
|
|
626
|
+
# - If the token carries a session_id, it must match the request session's id.
|
|
627
|
+
# - Every rejection is HTTP 403 with the CSRF_INVALID envelope
|
|
628
|
+
# ({error:true, code:"CSRF_INVALID", message:, status:403}) via
|
|
629
|
+
# response.error -- byte-identical to Python/PHP/Node.
|
|
630
|
+
class CsrfMiddleware
|
|
631
|
+
class << self
|
|
632
|
+
def before_csrf(request, response)
|
|
633
|
+
# 1. Kill switch -- TINA4_CSRF in {false,0,no} disables all CSRF checks,
|
|
634
|
+
# even when the middleware is attached explicitly. Unset = enforced.
|
|
635
|
+
csrf_env = ENV["TINA4_CSRF"].to_s.strip.downcase
|
|
636
|
+
return [request, response] if %w[false 0 no].include?(csrf_env)
|
|
637
|
+
|
|
638
|
+
# 2. Safe HTTP methods never change state -- skip.
|
|
639
|
+
method = (request.method || "GET").upcase
|
|
640
|
+
return [request, response] if %w[GET HEAD OPTIONS].include?(method)
|
|
641
|
+
|
|
642
|
+
# 3. Public write routes (.no_auth / auth: false) skip CSRF -- a
|
|
643
|
+
# genuinely public endpoint has no session to protect. The matched
|
|
644
|
+
# Route is attached to the request before this post-match middleware
|
|
645
|
+
# runs (DispatchPipeline#prepare_route_request); a public write route
|
|
646
|
+
# has auth_required == false -- the SAME signal the auth gate reads.
|
|
647
|
+
# (Reading request.handler, as this once did, was dead code: the live
|
|
648
|
+
# request never carries a handler, so the no_auth skip never fired.)
|
|
649
|
+
route = request.respond_to?(:route) ? request.route : nil
|
|
650
|
+
if route.respond_to?(:auth_required) && route.auth_required == false
|
|
651
|
+
return [request, response]
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# 4/5. Resolve the signing secret ONCE, fail-closed. TINA4_SECRET unset
|
|
655
|
+
# resolves to blank (there is NO built-in default); a blank HMAC key
|
|
656
|
+
# is publicly reproducible, so a token signed with it -- or with the
|
|
657
|
+
# retired public 'tina4-default-secret' -- is a forgery. Reject every
|
|
658
|
+
# write rather than validate against a guessable key. SEC-01.
|
|
659
|
+
secret = Tina4::Auth.hmac_secret
|
|
660
|
+
if secret.to_s.empty?
|
|
661
|
+
return [request, response.error(
|
|
662
|
+
"CSRF_INVALID",
|
|
663
|
+
"CSRF token cannot be validated: TINA4_SECRET is not set",
|
|
664
|
+
403
|
|
665
|
+
)]
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# 6. A valid Bearer JWT means an API client authenticating per request --
|
|
669
|
+
# not subject to the cookie-replay attack CSRF defends against.
|
|
670
|
+
headers = request.respond_to?(:headers) ? request.headers : {}
|
|
671
|
+
auth_header = (headers["authorization"] || headers["Authorization"] || "").to_s
|
|
672
|
+
if auth_header.start_with?("Bearer ")
|
|
673
|
+
bearer_token = auth_header[7..].to_s.strip
|
|
674
|
+
return [request, response] if !bearer_token.empty? && Tina4::Auth.valid_token(bearer_token)
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# 7. A token in the query string leaks through logs/referers/history --
|
|
678
|
+
# reject it. Read the QUERY STRING only, never request.params, which
|
|
679
|
+
# merges the body (a legit body token would false-trip this check).
|
|
680
|
+
query = request.respond_to?(:query) ? request.query : {}
|
|
681
|
+
query = {} unless query.is_a?(Hash)
|
|
682
|
+
if !query["formToken"].to_s.empty?
|
|
683
|
+
Tina4::Log.warning("[CSRF] Token found in query string — rejected for security")
|
|
684
|
+
return [request, response.error(
|
|
685
|
+
"CSRF_INVALID",
|
|
686
|
+
"Form token must not be sent in the URL query string",
|
|
687
|
+
403
|
|
688
|
+
)]
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# 8. Extract the token: body first, then the X-Form-Token header.
|
|
692
|
+
token = nil
|
|
693
|
+
body = request.respond_to?(:body) ? request.body : nil
|
|
694
|
+
token = body["formToken"] if body.is_a?(Hash)
|
|
695
|
+
if token.nil? || token.to_s.empty?
|
|
696
|
+
token = headers["X-Form-Token"] || headers["x-form-token"]
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# 9. Missing token -- reject.
|
|
700
|
+
if token.nil? || token.to_s.empty?
|
|
701
|
+
return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# 10. Validate signature + expiry with the resolved secret. valid_token
|
|
705
|
+
# returns the verified payload Hash (or nil) in 3.13.0+.
|
|
706
|
+
payload = Tina4::Auth.valid_token(token.to_s)
|
|
707
|
+
unless payload
|
|
708
|
+
return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# 11. Enforce the form-token TYPE -- a valid signature is not enough. A
|
|
712
|
+
# non-form JWT (e.g. an auth/session token) must never be accepted in
|
|
713
|
+
# the formToken slot.
|
|
714
|
+
payload = {} unless payload.is_a?(Hash)
|
|
715
|
+
if payload["type"] != "form"
|
|
716
|
+
return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
# 12. Session binding -- a token minted for one session cannot be replayed
|
|
720
|
+
# against another. Read the request session's OWN id: Tina4::Session
|
|
721
|
+
# exposes get_session_id (NOT session_id -- reading session_id then
|
|
722
|
+
# session.get("session_id") looked up a DATA key, never the id, so
|
|
723
|
+
# binding silently never fired against a real session). A plain Hash
|
|
724
|
+
# session exposes "session_id".
|
|
725
|
+
token_session_id = payload["session_id"]
|
|
726
|
+
if token_session_id
|
|
727
|
+
session = request.respond_to?(:session) ? request.session : nil
|
|
728
|
+
current_session_id =
|
|
729
|
+
if session.nil?
|
|
730
|
+
nil
|
|
731
|
+
elsif session.respond_to?(:session_id)
|
|
732
|
+
session.session_id
|
|
733
|
+
elsif session.respond_to?(:get_session_id)
|
|
734
|
+
session.get_session_id
|
|
735
|
+
elsif session.is_a?(Hash)
|
|
736
|
+
session["session_id"]
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
if current_session_id && token_session_id != current_session_id
|
|
740
|
+
return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
|
|
741
|
+
end
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
# 13. All checks passed.
|
|
745
|
+
[request, response]
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# Auto-attach CsrfMiddleware when TINA4_CSRF is enabled in the environment.
|
|
749
|
+
#
|
|
750
|
+
# CSRF is OFF by default: with TINA4_CSRF unset the middleware is never
|
|
751
|
+
# attached, so a default app has no CSRF gate. A truthy value
|
|
752
|
+
# (true/1/yes/on, case-insensitive) attaches it globally so every
|
|
753
|
+
# state-changing route is gated -- the env flag is the switch, no code
|
|
754
|
+
# change needed. Idempotent (Middleware.use de-dupes). Returns true when the
|
|
755
|
+
# middleware is now attached. Mirrors the Python master's
|
|
756
|
+
# attach_csrf_from_env; the framework calls it once at boot
|
|
757
|
+
# (Tina4.initialize!). A false/0/no value still lets an explicit
|
|
758
|
+
# Router.use(CsrfMiddleware) opt-in be disabled at runtime by the kill
|
|
759
|
+
# switch in before_csrf.
|
|
760
|
+
def attach_from_env
|
|
761
|
+
value = ENV["TINA4_CSRF"].to_s.strip.downcase
|
|
762
|
+
if %w[true 1 yes on].include?(value)
|
|
763
|
+
Tina4::Middleware.use(Tina4::CsrfMiddleware)
|
|
764
|
+
return true
|
|
765
|
+
end
|
|
766
|
+
false
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# SecurityHeadersMiddleware -- injects security headers on every response.
|
|
772
|
+
# Config via env:
|
|
773
|
+
# TINA4_FRAME_OPTIONS — X-Frame-Options (default: SAMEORIGIN)
|
|
774
|
+
# TINA4_HSTS — Strict-Transport-Security max-age (default: "" = off)
|
|
775
|
+
# TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
|
|
776
|
+
# TINA4_REFERRER_POLICY — Referrer-Policy (default: strict-origin-when-cross-origin)
|
|
777
|
+
# TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
|
|
778
|
+
class SecurityHeadersMiddleware
|
|
779
|
+
class << self
|
|
780
|
+
# Register this middleware in the default chain (secure-by-default).
|
|
781
|
+
#
|
|
782
|
+
# Unlike CSRF (opt-in via TINA4_CSRF) this is UNCONDITIONAL: a default app
|
|
783
|
+
# ships the security headers with no opt-in -- the SECHDR-DEC-01 posture
|
|
784
|
+
# that closes the SECHDR-OFF-BY-DEFAULT gap (the middleware existed with good
|
|
785
|
+
# defaults but was never registered). Idempotent (Middleware.use de-dupes).
|
|
786
|
+
# The framework calls it once at boot (Tina4.initialize!). Returns true.
|
|
787
|
+
def attach
|
|
788
|
+
Tina4::Middleware.use(self)
|
|
789
|
+
true
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
def before_security(request, response)
|
|
793
|
+
response.headers["X-Frame-Options"] = ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"
|
|
794
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
795
|
+
|
|
796
|
+
# HSTS is HTTPS-only (SECHDR-DEC-02): a downgrade-protection header on a
|
|
797
|
+
# plain-HTTP response is inert at best and ships a bad max-age on an
|
|
798
|
+
# unencrypted scheme at worst. Emit it ONLY when TINA4_HSTS is set AND the
|
|
799
|
+
# request is HTTPS -- Request.secure_scheme? honours x-forwarded-proto
|
|
800
|
+
# (first hop) then rack.url_scheme, the same source of truth the session
|
|
801
|
+
# cookie's Secure flag uses. Defensive env lookup keeps a non-Request from
|
|
802
|
+
# turning every response into a 500 now that this runs on every request.
|
|
803
|
+
hsts = ENV["TINA4_HSTS"] || ""
|
|
804
|
+
env = request.respond_to?(:env) ? request.env : {}
|
|
805
|
+
if !hsts.empty? && Tina4::Request.secure_scheme?(env)
|
|
806
|
+
response.headers["Strict-Transport-Security"] = "max-age=#{hsts}; includeSubDomains"
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
warn_csp_default_once if ENV["TINA4_CSP"].nil?
|
|
810
|
+
response.headers["Content-Security-Policy"] = ENV["TINA4_CSP"] || "default-src 'self'"
|
|
811
|
+
response.headers["Referrer-Policy"] = ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"
|
|
812
|
+
response.headers["X-XSS-Protection"] = "0"
|
|
813
|
+
response.headers["Permissions-Policy"] = ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"
|
|
814
|
+
|
|
815
|
+
[request, response]
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
# Warn once per process that the default CSP is in force (TINA4_CSP unset).
|
|
819
|
+
#
|
|
820
|
+
# Secure-by-default keeps `default-src 'self'` (SECHDR-DEC-01), but that
|
|
821
|
+
# default is invisible: it blocks runtime-injected inline styles, cross-origin
|
|
822
|
+
# fonts/scripts/CDNs, `data:` URIs, and cross-origin WebSocket/XHR (a separate
|
|
823
|
+
# API or LiveKit host) -- and the failure surfaces only in the browser at
|
|
824
|
+
# runtime, long after a deploy has gone green. So the framework says so once,
|
|
825
|
+
# naming the escape hatch. It NEVER fails the boot or a request -- logging a
|
|
826
|
+
# heads-up must not be the reason the server or a request dies. Fires only when
|
|
827
|
+
# TINA4_CSP is ABSENT; setting it (even to empty) is an explicit opt-in.
|
|
828
|
+
def warn_csp_default_once
|
|
829
|
+
return if @csp_default_warned
|
|
830
|
+
|
|
831
|
+
@csp_default_warned = true
|
|
832
|
+
message = "TINA4_CSP is not set, so Tina4 is serving the default Content-Security-Policy " \
|
|
833
|
+
"\"default-src 'self'\" on every response. That default blocks runtime-injected " \
|
|
834
|
+
"inline styles, cross-origin fonts/scripts/CDNs, data: URIs, and cross-origin " \
|
|
835
|
+
"WebSocket/XHR (e.g. a separate API or LiveKit host). If your app uses any of " \
|
|
836
|
+
"these, set TINA4_CSP to a policy that allows them (see https://tina4.com); to " \
|
|
837
|
+
"silence this notice without changing behaviour, set TINA4_CSP=\"default-src 'self'\"."
|
|
838
|
+
begin
|
|
839
|
+
Tina4::Log.warning(message)
|
|
840
|
+
rescue StandardError
|
|
841
|
+
# Logging must never break a request.
|
|
842
|
+
warn(message)
|
|
843
|
+
end
|
|
844
|
+
end
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
end
|