HIGH cross site request forgerysinatrahmac signatures

Cross Site Request Forgery in Sinatra with Hmac Signatures

Cross Site Request Forgery in Sinatra with Hmac Signatures — how this specific combination creates or exposes the vulnerability

Cross-Site Request Forgery (CSRF) in Sinatra when using HMAC signatures can arise when protection is applied only to form parameters or cookies but not to the signature over the full request context. A common pattern is to compute an HMAC over selected parameters (e.g., user_id, timestamp, action) and include the signature in a header or query parameter. If the server validates the signature but does not include the request method, the request path, or a per-request nonce, an attacker can trick a victim into performing a state-changing action via a crafted GET request or an image tag that includes the signed query parameters.

Consider a Sinatra endpoint that uses an HMAC to authorize sensitive actions, where the signature is computed over user_id and a timestamp but the HTTP method is not part of the signed string. An attacker can lure a victim who is already authenticated into visiting a URL such as https://api.example.com/transfer?user_id=attacker&timestamp=1700000000&signature=VALID_HMAC. Because GET requests are often allowed to carry query parameters and the server only validates the signature without verifying that the request is intentional and originated from the application’s own UI, the victim’s browser sends the request and the server performs the transfer. This is CSRF via an HMAC-signed query parameter: the signature itself is valid, but the lack of method binding and anti-replay controls makes the endpoint vulnerable.

Another scenario involves JSON payloads where the client computes an HMAC over a subset of fields and sends it in a custom header (e.g., X-API-Signature). If the server validates the signature but does not ensure that the request is not forged by an external site (for example, by checking X-Requested-With or origin/referrer in a secure way), a malicious site can embed a form with method POST and the same signed fields. Because the signature does not cover the entire request context (including headers that distinguish browser-initiated requests), the server treats the malicious POST as legitimate. This is particularly risky when the endpoint changes state (POST/PUT/DELETE) and relies only on HMAC over parameters without tying the signature to the request channel.

Sinatra applications that integrate HMAC signatures must treat the signature as one part of a broader CSRF mitigation strategy. Relying solely on HMACs that do not incorporate the request method, the full set of headers, or a per-session or per-request nonce leaves the application open to CSRF. Attackers do not need to break the HMAC; they simply need the victim to send a request with a valid signature that the server mistakenly trusts. Therefore, the combination of HMAC-based integrity and missing binding to the request’s origin, method, and intended context creates the vulnerability.

Hmac Signatures-Specific Remediation in Sinatra — concrete code fixes

To remediate CSRF in Sinatra when using HMAC signatures, include the request method, the request path, and a strong nonce or timestamp into the signed payload, and enforce strict validation on the server. Below are concrete, working examples that demonstrate a safer approach.

Example 1: Signing the full request context (method + path + body + timestamp)

This example signs the HTTP method, path, timestamp, and a JSON body to ensure that a signature cannot be reused across methods or paths and is bound to a short time window.

require 'sinatra'
require 'openssl'
require 'json'
require 'base64'

# Shared secret stored securely, e.g., via environment variable
SECRET = ENV.fetch('HMAC_SECRET') { 'development-secret-change-in-production' }

helpers do
  def generate_hmac(payload)
    OpenSSL::HMAC.hexdigest('sha256', SECRET, payload)
  end

  def signed_payload_for(method, path, timestamp, body_hash)
    [method.upcase, path, timestamp, body_hash].join('|')
  end

  def valid_signature?(method, path, timestamp, body_hash, received_signature)
    expected = generate_hmac(signed_payload_for(method, path, timestamp, body_hash))
    # Use secure compare to avoid timing attacks
    ActiveSupport::SecurityUtils.secure_compare(expected, received_signature)
  end
end

before do
  request.body.rewind if request.body
end

post '/transfer' do
  content_type :json

  required_params = %w[from to amount]
  missing = required_params.reject { |p| params[p] }
  halt 400, { error: "missing_params", message: "Missing: #{missing.join(', ')}" }.to_json if missing.any?

  timestamp = params['timestamp'] || request.env['HTTP_X_TIMESTAMP']
  signature = params['signature'] || request.env['HTTP_X_SIGNATURE']

  halt 401, { error: 'missing_timestamp', message: 'X-Timestamp header is required' }.to_json unless timestamp
  halt 401, { error: 'missing_signature', message: 'X-Signature header is required' }.to_json unless signature

  # Ensure timestamp is recent (e.g., within 2 minutes) to prevent replay
  now = Time.now.to_i
  request_ts = Integer(timestamp)
  halt 401, { error: 'stale_request', message: 'Timestamp too old' }.to_json unless (now - request_ts).between?(0, 120)

  # Compute body hash to bind the signature to the exact payload
  body_hash = Digest::SHA256.hexdigest(request.body.read)
  request.body.rewind

  path = '/transfer'
  method = request.request_method

  unless valid_signature?(method, path, request_ts, body_hash, signature)
    halt 403, { error: 'invalid_signature', message: 'Request signature verification failed' }.to_json
  end

  # Proceed with the transfer logic
  { status: 'ok', message: 'Transfer processed' }.to_json
end

Example 2: Including an anti-CSRF token in signed data for browser-initiated requests

When serving HTML/JS to browsers, include a per-request anti-CSRF token (e.g., synchronizer token pattern) and sign it together with the action. The server embeds the token in the page, and AJAX requests must include both the token and the signature over method + path + token.

require 'sinatra'
require 'openssl'
require 'securerandom'
require 'json'

SECRET = ENV.fetch('HMAC_SECRET') { 'development-secret-change-in-production' }

helpers do
  def generate_hmac(data)
    OpenSSL::HMAC.hexdigest('sha256', SECRET, data)
  end

  def csrf_token
    session[:csrf_token] ||= SecureRandom.hex(16)
  end

  def valid_csrf_request?
    token = request.env['HTTP_X_CSRF_TOKEN']
    sig   = request.env['HTTP_X_CSRF_SIGNATURE']
    return false unless token && sig
    # Ensure token matches session and signature covers method+path+token
    data = [request.request_method, request.path_info, token].join('|')
    expected = generate_hmac(data)
    ActiveSupport::SecurityUtils.secure_compare(expected, sig)
  end
end

get '/form' do
  # Serve a form that includes the CSRF token in a hidden field and expects the client to send it in a header
  <<~HTML
    <form id="actionForm">
      <input type="hidden" name="csrf_token" value="#{csrf_token}">
      <input type="text" name="account" placeholder="Account ID">
      <button type="submit">Submit</button>
    </form>
    <script>
      document.getElementById('actionForm').onsubmit = function(e) {
        e.preventDefault();
        const token = document.querySelector('input[name="csrf_token"]').value;
        fetch('/process', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-CSRF-Token': token,
            'X-CSRF-Signature': computeSignature('POST', '/process', token)
          },
          body: JSON.stringify({ account: document.querySelector('input[name="account"]').value })
        }).then(r => r.json()).then(console.log);
      };
      function computeSignature(method, path, token) {
        // In real code, the server should provide the signature or use a shared secret via a secure endpoint.
        // This example assumes a shared secret is embedded securely; avoid exposing the secret to client-side JS.
        return 'server-computed-signature';
      }
    </script>
  HTML
end

post '/process' do
  halt 403, { error: 'invalid_csrf' }.to_json unless valid_csrf_request?
  # Process the state-changing request safely
  { status: 'ok', message: 'Action processed' }.to_json
end

Key remediation practices

  • Include the HTTP method and request path in the signed string to prevent method smuggling or path confusion.
  • Bind signatures to a short timestamp or nonce to prevent replay attacks.
  • For browser-initiated requests, combine HMAC signatures with a per-session anti-CSRF token and validate both the token and the signature on the server.
  • Use a constant-time comparison (e.g., secure_compare) to avoid timing attacks when validating signatures.
  • Ensure sensitive endpoints reject GET requests for state changes; prefer POST/PUT/DELETE with proper authentication and signature checks.

Frequently Asked Questions

Why does including the HTTP method in the HMAC signature help prevent CSRF in Sinatra?
Including the HTTP method in the signed string ensures the signature is only valid for the intended action (e.g., POST). An attacker forging a GET request with valid query parameters cannot reuse the signature because the method differs, causing validation to fail and blocking the CSRF attack.
Can HMAC signatures alone fully prevent CSRF in Sinatra applications?
No. HMAC signatures provide integrity and authenticity of parameters, but they do not inherently bind the request to the user’s browser session or prevent cross-origin requests. Combine HMAC signatures with anti-CSRF tokens, strict referer/origin checks (where appropriate), and same-site cookie attributes to achieve robust CSRF protection.