Class: Idempo::RequestFingerprint
- Inherits:
-
Object
- Object
- Idempo::RequestFingerprint
- Defined in:
- lib/idempo/request_fingerprint.rb
Constant Summary collapse
- RAILS_SESSION_COOKIE_PATTERN =
/\A_[a-z0-9_]+_session\z/i
Class Method Summary collapse
-
.call(idempotency_key, rack_request) ⇒ Object
Maintains backward compatibility: Idempo::RequestFingerprint can be passed directly as the compute_fingerprint_via: value (the default) since it responds to .call.
Instance Method Summary collapse
- #call(idempotency_key, rack_request) ⇒ Object
- #extract_rails_session_cookie(rack_request) ⇒ Object
-
#extract_user_identity(rack_request) ⇒ Object
Extracts a value identifying the user from the request.
- #read_and_rewind(source_io, to_destination_io) ⇒ Object
Class Method Details
.call(idempotency_key, rack_request) ⇒ Object
Maintains backward compatibility: Idempo::RequestFingerprint can be passed directly as the compute_fingerprint_via: value (the default) since it responds to .call.
8 9 10 |
# File 'lib/idempo/request_fingerprint.rb', line 8 def self.call(idempotency_key, rack_request) new.call(idempotency_key, rack_request) end |
Instance Method Details
#call(idempotency_key, rack_request) ⇒ Object
12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
# File 'lib/idempo/request_fingerprint.rb', line 12 def call(idempotency_key, rack_request) d = Digest::SHA256.new d << idempotency_key << "\n" d << rack_request.url << "\n" d << rack_request.request_method << "\n" d << extract_user_identity(rack_request).to_s << "\n" # Under Rack 3.0 the rack.input may or may not be rewindable (this is done to support # streaming HTTP request bodies). If we know a request body is rewindable we can read it # out in full and add it to the request fingerprint. If the request body cannot be # rewound, we can't really rely on it as it can be fairly large (and we want the # downstream app to read the request body, not us). if rack_request.env["rack.input"].respond_to?(:rewind) read_and_rewind(rack_request.env["rack.input"], d) end Base64.strict_encode64(d.digest) end |
#extract_rails_session_cookie(rack_request) ⇒ Object
79 80 81 82 83 84 |
# File 'lib/idempo/request_fingerprint.rb', line 79 def (rack_request) rack_request..each do |name, value| return value if name.match?(RAILS_SESSION_COOKIE_PATTERN) end nil end |
#extract_user_identity(rack_request) ⇒ Object
Extracts a value identifying the user from the request. This value gets included in the request fingerprint hash. Without user identity in the fingerprint, two different users sending the same idempotency key to the same endpoint would receive each other's cached responses — leaking sensitive data across user boundaries (similar to the Railway CDN caching incident of March 2026, where responses keyed only on method+URL were served to the wrong users).
The default implementation tries two strategies, in order:
-
If an Authorization header is present (Bearer token, Basic auth, etc.), its full value is used. This is the common case for API applications. Different tokens produce different fingerprints, so requests from different users are naturally separated.
-
If no Authorization header is present, we look for a Rails-style session cookie (matching the pattern
_<appname>_session). This covers the common case of Rails applications using cookie-based authentication, where the Authorization header is typically empty for all users. The encrypted session cookie value differs per user session, so it serves as a user identity signal. The cookie value is stable from the client's perspective across retries (the client resends the same cookie string until it receives a Set-Cookie with a new value), which is what matters for idempotency — the retry sends the same fingerprint as the original.
If neither signal is available (no Authorization header and no Rails session cookie), the fingerprint will only contain the idempotency key, URL, method, and body. This is acceptable for unauthenticated endpoints but DANGEROUS for authenticated endpoints using other identity mechanisms (custom headers like X-API-Key, non-Rails session cookies, etc.).
To handle those cases, subclass and override this method:
class MyFingerprint < Idempo::RequestFingerprint
private
def extract_user_identity(rack_request)
rack_request.get_header("HTTP_X_API_KEY")
end
end
use Idempo, compute_fingerprint_via: MyFingerprint.new
73 74 75 76 77 |
# File 'lib/idempo/request_fingerprint.rb', line 73 def extract_user_identity(rack_request) auth = rack_request.get_header("HTTP_AUTHORIZATION").to_s return auth unless auth.empty? (rack_request) end |
#read_and_rewind(source_io, to_destination_io) ⇒ Object
86 87 88 89 90 91 92 |
# File 'lib/idempo/request_fingerprint.rb', line 86 def read_and_rewind(source_io, to_destination_io) while (chunk = source_io.read(1024 * 65)) to_destination_io << chunk end ensure source_io.rewind end |