tina4ruby 3.13.120 → 3.13.122

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.
@@ -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
- response.headers["Content-Security-Policy"] = ENV["TINA4_CSP"] || "default-src 'self'"
810
- response.headers["Referrer-Policy"] = ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"
811
- response.headers["X-XSS-Protection"] = "0"
812
- response.headers["Permissions-Policy"] = ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"
813
-
814
- [request, response]
815
- end
816
- end
817
- end
818
- end
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