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 +4 -4
- data/CHANGELOG.md +13 -0
- data/examples/jwt_iss_fingerprint.rb +9 -18
- data/lib/idempo/active_record_backend.rb +8 -6
- data/lib/idempo/request_fingerprint.rb +68 -3
- data/lib/idempo/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 45bef4736adac3732c5b4d29013ca996f3801f3cb66bbb55d1dc554faeef2f99
|
|
4
|
+
data.tar.gz: 61e7a98fd4593390dc446d2f1656dde637a5e88612f6439104bdc3a5de3f6828
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
6
|
-
# generated in JWT format by the client, it may include the
|
|
7
|
-
# specific device. This identifier can then be used instead
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/idempo/version.rb
CHANGED
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.
|
|
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:
|
|
12
|
+
date: 2026-04-06 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: msgpack
|