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×tamp=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.