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 -- only active when TINA4_CSRF=true in .env or when registered explicitly via Router.use(CsrfMiddleware).

Behaviour:

- Skips GET, HEAD, OPTIONS requests.
- Skips routes marked .no_auth.
- Skips requests with a valid Authorization: Bearer header (API clients).
- Checks request.body["formToken"] then request.headers["X-Form-Token"].
- Rejects if token found in request.query["formToken"] (log warning, 403).
- Validates token with Auth.valid_token using SECRET env var.
- If token payload has session_id, verifies it matches request.session.session_id.
- Returns 403 with response.json({error: "CSRF_INVALID", message: ...}, 403) on failure.

Class Method Summary collapse

Class Method Details

.before_csrf(request, response) ⇒ Object



584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
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
# File 'lib/tina4/middleware.rb', line 584

def before_csrf(request, response)
  # Allow disabling CSRF via env var
  csrf_env = ENV["TINA4_CSRF"].to_s.downcase
  return [request, response] if %w[false 0 no].include?(csrf_env)

  # Skip safe HTTP methods
  method = (request.method || "GET").upcase
  return [request, response] if %w[GET HEAD OPTIONS].include?(method)

  # Skip routes marked no_auth
  handler = request.respond_to?(:handler) ? request.handler : nil
  if handler
    no_auth = if handler.is_a?(Hash)
                handler[:no_auth] || handler[:noAuth]
              elsif handler.respond_to?(:no_auth)
                handler.no_auth
              end
    return [request, response] if no_auth
  end

  # Skip requests with valid Bearer token (API clients)
  headers = request.respond_to?(:headers) ? request.headers : {}
  auth_header = headers["authorization"] || headers["Authorization"] || ""
  if auth_header.start_with?("Bearer ")
    bearer_token = auth_header[7..].strip
    unless bearer_token.empty?
      return [request, response] if Tina4::Auth.valid_token(bearer_token)
    end
  end

  # Reject if token is in query string (security risk)
  query = if request.respond_to?(:params)
            request.params
          elsif request.respond_to?(:query)
            request.query
          else
            {}
          end
  query ||= {}

  if query.is_a?(Hash) && query["formToken"] && !query["formToken"].to_s.empty?
    Tina4::Log.warning("[CSRF] Token found in query string — rejected for security")
    response.json({ error: "CSRF_INVALID", message: "Form token must not be sent in the URL query string" }, 403)
    return [request, response]
  end

  # Extract token: body first, then header
  token = nil
  body = request.respond_to?(:body) ? request.body : nil
  body ||= {}
  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

  if token.nil? || token.to_s.empty?
    response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
    return [request, response]
  end

  # Validate the token
  unless Tina4::Auth.valid_token(token.to_s)
    response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
    return [request, response]
  end

  # Session binding — if token has session_id, verify it matches
  payload = Tina4::Auth.get_payload(token.to_s) || {}
  token_session_id = payload["session_id"]
  if token_session_id
    current_session_id = nil
    session = request.respond_to?(:session) ? request.session : nil
    if session
      current_session_id = if session.respond_to?(:session_id)
                             session.session_id
                           elsif session.is_a?(Hash)
                             session["session_id"]
                           elsif session.respond_to?(:get)
                             session.get("session_id")
                           end
    end

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

  [request, response]
end