idempo 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3db7fbb3c612bae5675890f118986c3d026e49c73f3c74c24f15fea51e6a67c7
4
- data.tar.gz: 65a193261af6e424fe8f7e3437d28e02c20ddb4c9ae199c9556f39676474b8ac
3
+ metadata.gz: 45bef4736adac3732c5b4d29013ca996f3801f3cb66bbb55d1dc554faeef2f99
4
+ data.tar.gz: 61e7a98fd4593390dc446d2f1656dde637a5e88612f6439104bdc3a5de3f6828
5
5
  SHA512:
6
- metadata.gz: bd32562481a479070ed2806ef3201d370417fd9acfbbb8499d1aefb2f1000f278ee3dff47ccb0ac640f33001be8c41cccba7c498980d07c893dee519e53f539b
7
- data.tar.gz: df3b05f901ac7e49bb82bfde9a522e2a179b96953db10751b7814858597a5a0af0aa82ff0fd6824e3a11cae6bdf4ebc84004a35b09cfae7a39958114a307eef8
6
+ metadata.gz: 5e594aaf41686081d0bb007513d4dcbe436df1d814e4d14106d2b709e766dcad4c13264aba3c6478d80a362a5e7f87e38a1890224e140f8e9d9b9765180e3f01
7
+ data.tar.gz: 8e7557b7aaf0312edf59b86a536fda9cc703adc639f3ffb8e49e18e73b2dec1e451564cafdd720482cb7b66cf6a02031afb4eada933c78f11344ec1db24518e3
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## 1.4.0
2
+
3
+ - `RequestFingerprint` is now a class instead of a module, with an overridable `extract_user_identity` method.
4
+ The default implementation uses the `Authorization` header when present, and falls back to the Rails session
5
+ cookie (`_<appname>_session`) when it is not. This prevents cross-user response leakage for apps using
6
+ cookie-based authentication. For custom auth mechanisms, subclass `RequestFingerprint` and override
7
+ `extract_user_identity`. Backward compatible — the class still works as the default `compute_fingerprint_via:` value.
8
+
9
+ ## 1.3.1
10
+
11
+ - Instead of retaining the ActiveRecord connection in Idempo operations, check it out temporarily from the AR pool.
12
+ This should improve interop with fibers/Async.
13
+
1
14
  ## 1.3.0
2
15
 
3
16
  - Streamline integration with both Rack 2 and 3, add tests for request fingerprinting.
@@ -2,26 +2,15 @@
2
2
  # which includes some form of expiration. This means that every time a request is made, the `Authorization`
3
3
  # HTTP header may have a different value, and thus the request fingerprint could change every time,
4
4
  # even though the idempotency key is the same.
5
- # For this case, a custom fingerprinting function can be used. For example, if the bearer token is
6
- # generated in JWT format by the client, it may include the `iss` (issuer) claim, identifying the
7
- # specific device. This identifier can then be used instead of the entire Authorization header.
5
+ # For this case, you can subclass Idempo::RequestFingerprint and override `extract_user_identity`.
6
+ # For example, if the bearer token is generated in JWT format by the client, it may include the
7
+ # `iss` (issuer) claim, identifying the specific device. This identifier can then be used instead
8
+ # of the entire Authorization header.
8
9
 
9
- module FingerprinterWithIssuerClaim
10
- def self.call(idempotency_key, rack_request)
11
- d = Digest::SHA256.new
12
- d << idempotency_key << "\n"
13
- d << rack_request.url << "\n"
14
- d << rack_request.request_method << "\n"
15
- d << extract_jwt_iss_claim(rack_request) << "\n"
16
- while (chunk = rack_request.env["rack.input"].read(1024 * 65))
17
- d << chunk
18
- end
19
- Base64.strict_encode64(d.digest)
20
- ensure
21
- rack_request.env["rack.input"].rewind
22
- end
10
+ class JwtIssFingerprint < Idempo::RequestFingerprint
11
+ private
23
12
 
24
- def self.extract_jwt_iss_claim(rack_request)
13
+ def extract_user_identity(rack_request)
25
14
  header_value = rack_request.get_header("HTTP_AUTHORIZATION").to_s
26
15
  return header_value unless header_value.start_with?("Bearer ")
27
16
 
@@ -39,3 +28,5 @@ module FingerprinterWithIssuerClaim
39
28
  SecureRandom.bytes(32)
40
29
  end
41
30
  end
31
+
32
+ use Idempo, compute_fingerprint_via: JwtIssFingerprint.new
@@ -82,13 +82,15 @@ class Idempo::ActiveRecordBackend
82
82
  # process) and then once we have that - the DB lock.
83
83
  @memory_lock.with(request_key) do
84
84
  db_safe_key = Digest::SHA1.base64digest(request_key)
85
- database_lock = lock_implementation_for_connection(model.connection)
86
- raise Idempo::ConcurrentRequest unless database_lock.acquire(model.connection, request_key)
85
+ ActiveRecord::Base.connection_pool.with_connection do |connection|
86
+ database_lock = lock_implementation_for_connection(connection)
87
+ raise Idempo::ConcurrentRequest unless database_lock.acquire(connection, request_key)
87
88
 
88
- begin
89
- yield(Store.new(db_safe_key, model))
90
- ensure
91
- database_lock.release(model.connection, request_key)
89
+ begin
90
+ yield(Store.new(db_safe_key, model))
91
+ ensure
92
+ database_lock.release(connection, request_key)
93
+ end
92
94
  end
93
95
  end
94
96
  end
@@ -1,10 +1,20 @@
1
- module Idempo::RequestFingerprint
1
+ # frozen_string_literal: true
2
+
3
+ class Idempo::RequestFingerprint
4
+ RAILS_SESSION_COOKIE_PATTERN = /\A_[a-z0-9_]+_session\z/i
5
+
6
+ # Maintains backward compatibility: Idempo::RequestFingerprint can be passed
7
+ # directly as the compute_fingerprint_via: value (the default) since it responds to .call.
2
8
  def self.call(idempotency_key, rack_request)
9
+ new.call(idempotency_key, rack_request)
10
+ end
11
+
12
+ def call(idempotency_key, rack_request)
3
13
  d = Digest::SHA256.new
4
14
  d << idempotency_key << "\n"
5
15
  d << rack_request.url << "\n"
6
16
  d << rack_request.request_method << "\n"
7
- d << rack_request.get_header("HTTP_AUTHORIZATION").to_s << "\n"
17
+ d << extract_user_identity(rack_request).to_s << "\n"
8
18
 
9
19
  # Under Rack 3.0 the rack.input may or may not be rewindable (this is done to support
10
20
  # streaming HTTP request bodies). If we know a request body is rewindable we can read it
@@ -18,7 +28,62 @@ module Idempo::RequestFingerprint
18
28
  Base64.strict_encode64(d.digest)
19
29
  end
20
30
 
21
- def self.read_and_rewind(source_io, to_destination_io)
31
+ # Extracts a value identifying the user from the request. This value gets
32
+ # included in the request fingerprint hash. Without user identity in the
33
+ # fingerprint, two different users sending the same idempotency key to the
34
+ # same endpoint would receive each other's cached responses — leaking
35
+ # sensitive data across user boundaries (similar to the Railway CDN caching
36
+ # incident of March 2026, where responses keyed only on method+URL were
37
+ # served to the wrong users).
38
+ #
39
+ # The default implementation tries two strategies, in order:
40
+ #
41
+ # 1. If an Authorization header is present (Bearer token, Basic auth, etc.),
42
+ # its full value is used. This is the common case for API applications.
43
+ # Different tokens produce different fingerprints, so requests from
44
+ # different users are naturally separated.
45
+ #
46
+ # 2. If no Authorization header is present, we look for a Rails-style session
47
+ # cookie (matching the pattern `_<appname>_session`). This covers the
48
+ # common case of Rails applications using cookie-based authentication,
49
+ # where the Authorization header is typically empty for all users. The
50
+ # encrypted session cookie value differs per user session, so it serves
51
+ # as a user identity signal. The cookie value is stable from the client's
52
+ # perspective across retries (the client resends the same cookie string
53
+ # until it receives a Set-Cookie with a new value), which is what matters
54
+ # for idempotency — the retry sends the same fingerprint as the original.
55
+ #
56
+ # If neither signal is available (no Authorization header and no Rails
57
+ # session cookie), the fingerprint will only contain the idempotency key,
58
+ # URL, method, and body. This is acceptable for unauthenticated endpoints
59
+ # but DANGEROUS for authenticated endpoints using other identity mechanisms
60
+ # (custom headers like X-API-Key, non-Rails session cookies, etc.).
61
+ #
62
+ # To handle those cases, subclass and override this method:
63
+ #
64
+ # class MyFingerprint < Idempo::RequestFingerprint
65
+ # private
66
+ # def extract_user_identity(rack_request)
67
+ # rack_request.get_header("HTTP_X_API_KEY")
68
+ # end
69
+ # end
70
+ #
71
+ # use Idempo, compute_fingerprint_via: MyFingerprint.new
72
+ #
73
+ def extract_user_identity(rack_request)
74
+ auth = rack_request.get_header("HTTP_AUTHORIZATION").to_s
75
+ return auth unless auth.empty?
76
+ extract_rails_session_cookie(rack_request)
77
+ end
78
+
79
+ def extract_rails_session_cookie(rack_request)
80
+ rack_request.cookies.each do |name, value|
81
+ return value if name.match?(RAILS_SESSION_COOKIE_PATTERN)
82
+ end
83
+ nil
84
+ end
85
+
86
+ def read_and_rewind(source_io, to_destination_io)
22
87
  while (chunk = source_io.read(1024 * 65))
23
88
  to_destination_io << chunk
24
89
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Idempo
4
- VERSION = "1.3.0"
4
+ VERSION = "1.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idempo
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2024-11-29 00:00:00.000000000 Z
12
+ date: 2026-04-06 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: msgpack