Class: Tina4::CsrfMiddleware

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/middleware.rb

Overview

CsrfMiddleware -- validates form tokens on state-changing requests.

OFF by default: the middleware is NOT attached unless TINA4_CSRF is truthy (true/1/yes/on -- see .attach_from_env, called once at boot in Tina4.initialize!) OR it is registered explicitly via Router.use(CsrfMiddleware). Once attached, TINA4_CSRF=false (or 0/no) is the kill switch that disables enforcement again.

Behaviour (identical to the Python master's CsrfMiddleware.before_csrf):

- Skips GET, HEAD, OPTIONS (safe methods).
- Skips a public write route (.no_auth / auth: false -- auth_required
false). The matched Route is attached to the request before this
post-match middleware runs, so a genuinely public endpoint (login,
webhook) is not gated.
- Fails CLOSED: with TINA4_SECRET unset the signing secret resolves to
blank (there is NO built-in default), and a blank HMAC key is publicly
reproducible -- so no token can be trusted and every write is rejected
(403). This is the SEC-01 no-default-secret guarantee.
- Skips a request carrying a valid Authorization: Bearer token (API clients).
- Reads request.body["formToken"] then the X-Form-Token header.
- Rejects a token sent in the query string (403 + a logged warning) -- a
URL leaks through logs, referers and history.
- Validates the token with Auth.valid_token using the resolved secret, and
enforces that the token's "type" claim is "form" -- a non-form JWT in the
formToken slot is rejected even when its signature verifies.
- If the token carries a session_id, it must match the request session's id.
- Every rejection is HTTP 403 with the CSRF_INVALID envelope
({error:true, code:"CSRF_INVALID", message:, status:403}) via
response.error -- byte-identical to Python/PHP/Node.

Class Method Summary collapse

Class Method Details

.attach_from_envObject

Auto-attach CsrfMiddleware when TINA4_CSRF is enabled in the environment.

CSRF is OFF by default: with TINA4_CSRF unset the middleware is never attached, so a default app has no CSRF gate. A truthy value (true/1/yes/on, case-insensitive) attaches it globally so every state-changing route is gated -- the env flag is the switch, no code change needed. Idempotent (Middleware.use de-dupes). Returns true when the middleware is now attached. Mirrors the Python master's attach_csrf_from_env; the framework calls it once at boot (Tina4.initialize!). A false/0/no value still lets an explicit Router.use(CsrfMiddleware) opt-in be disabled at runtime by the kill switch in before_csrf.



760
761
762
763
764
765
766
767
# File 'lib/tina4/middleware.rb', line 760

def attach_from_env
  value = ENV["TINA4_CSRF"].to_s.strip.downcase
  if %w[true 1 yes on].include?(value)
    Tina4::Middleware.use(Tina4::CsrfMiddleware)
    return true
  end
  false
end

.before_csrf(request, response) ⇒ Object



632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
# File 'lib/tina4/middleware.rb', line 632

def before_csrf(request, response)
  # 1. Kill switch -- TINA4_CSRF in {false,0,no} disables all CSRF checks,
  #    even when the middleware is attached explicitly. Unset = enforced.
  csrf_env = ENV["TINA4_CSRF"].to_s.strip.downcase
  return [request, response] if %w[false 0 no].include?(csrf_env)

  # 2. Safe HTTP methods never change state -- skip.
  method = (request.method || "GET").upcase
  return [request, response] if %w[GET HEAD OPTIONS].include?(method)

  # 3. Public write routes (.no_auth / auth: false) skip CSRF -- a
  #    genuinely public endpoint has no session to protect. The matched
  #    Route is attached to the request before this post-match middleware
  #    runs (DispatchPipeline#prepare_route_request); a public write route
  #    has auth_required == false -- the SAME signal the auth gate reads.
  #    (Reading request.handler, as this once did, was dead code: the live
  #    request never carries a handler, so the no_auth skip never fired.)
  route = request.respond_to?(:route) ? request.route : nil
  if route.respond_to?(:auth_required) && route.auth_required == false
    return [request, response]
  end

  # 4/5. Resolve the signing secret ONCE, fail-closed. TINA4_SECRET unset
  #      resolves to blank (there is NO built-in default); a blank HMAC key
  #      is publicly reproducible, so a token signed with it -- or with the
  #      retired public 'tina4-default-secret' -- is a forgery. Reject every
  #      write rather than validate against a guessable key. SEC-01.
  secret = Tina4::Auth.hmac_secret
  if secret.to_s.empty?
    return [request, response.error(
      "CSRF_INVALID",
      "CSRF token cannot be validated: TINA4_SECRET is not set",
      403
    )]
  end

  # 6. A valid Bearer JWT means an API client authenticating per request --
  #    not subject to the cookie-replay attack CSRF defends against.
  headers = request.respond_to?(:headers) ? request.headers : {}
  auth_header = (headers["authorization"] || headers["Authorization"] || "").to_s
  if auth_header.start_with?("Bearer ")
    bearer_token = auth_header[7..].to_s.strip
    return [request, response] if !bearer_token.empty? && Tina4::Auth.valid_token(bearer_token)
  end

  # 7. A token in the query string leaks through logs/referers/history --
  #    reject it. Read the QUERY STRING only, never request.params, which
  #    merges the body (a legit body token would false-trip this check).
  query = request.respond_to?(:query) ? request.query : {}
  query = {} unless query.is_a?(Hash)
  if !query["formToken"].to_s.empty?
    Tina4::Log.warning("[CSRF] Token found in query string — rejected for security")
    return [request, response.error(
      "CSRF_INVALID",
      "Form token must not be sent in the URL query string",
      403
    )]
  end

  # 8. Extract the token: body first, then the X-Form-Token header.
  token = nil
  body = request.respond_to?(:body) ? request.body : nil
  token = body["formToken"] if body.is_a?(Hash)
  if token.nil? || token.to_s.empty?
    token = headers["X-Form-Token"] || headers["x-form-token"]
  end

  # 9. Missing token -- reject.
  if token.nil? || token.to_s.empty?
    return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
  end

  # 10. Validate signature + expiry with the resolved secret. valid_token
  #     returns the verified payload Hash (or nil) in 3.13.0+.
  payload = Tina4::Auth.valid_token(token.to_s)
  unless payload
    return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
  end

  # 11. Enforce the form-token TYPE -- a valid signature is not enough. A
  #     non-form JWT (e.g. an auth/session token) must never be accepted in
  #     the formToken slot.
  payload = {} unless payload.is_a?(Hash)
  if payload["type"] != "form"
    return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
  end

  # 12. Session binding -- a token minted for one session cannot be replayed
  #     against another. Read the request session's OWN id: Tina4::Session
  #     exposes get_session_id (NOT session_id -- reading session_id then
  #     session.get("session_id") looked up a DATA key, never the id, so
  #     binding silently never fired against a real session). A plain Hash
  #     session exposes "session_id".
  token_session_id = payload["session_id"]
  if token_session_id
    session = request.respond_to?(:session) ? request.session : nil
    current_session_id =
      if session.nil?
        nil
      elsif session.respond_to?(:session_id)
        session.session_id
      elsif session.respond_to?(:get_session_id)
        session.get_session_id
      elsif session.is_a?(Hash)
        session["session_id"]
      end

    if current_session_id && token_session_id != current_session_id
      return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
    end
  end

  # 13. All checks passed.
  [request, response]
end