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



324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
# File 'lib/tina4/middleware.rb', line 324

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