verikloak-bff 0.1.0 → 0.1.2

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: 3034b92c69f7147be681038b8f91a5a0c4ec1d577144a4372cad33b6645adb3a
4
- data.tar.gz: a52b1ccc320fee7d5526f612fa509218a5b2232ce678d6d6e03a42d5c0122df0
3
+ metadata.gz: 6901b11146849ea25306d9f78b3cf7d8903dce4374efcf1322a7aed6a3d52872
4
+ data.tar.gz: 9a9cd3f59250aa57599de7e973009dedff8d075a395632eef1a19a889fb6257c
5
5
  SHA512:
6
- metadata.gz: a9e31d538da66f06b1f18412b41520a3c1a5f0f5e3adf88af19f4aa68451420717002c11dc639a2f1dbc9441b64b68b74de2729d2ef8a5af805d862b7568dd75
7
- data.tar.gz: cb2f118b465d7cb4a64f0aa9eb77cb81ec0bd5a82ae2b33c1e9992b1b518e5690dee84f7c5ae69189b83e0394de784d559c57daef41154c24f396390cb0d8bab
6
+ metadata.gz: eed7006bf01e9a51b4824d8e4fdd2a759210270fdf5fdb07617a21b5b6e40862232dc162f8bec8e65fdb89cea59443eee7a030c7fdd2e335446c182b2745936a
7
+ data.tar.gz: d89ca6f1f4032198704a0167d1c3cad7b8b2b1adc68948bb06b0abbf45c4f51157e2d00f3906ad070c4c59c56ed7ddb31bd8f8a58cd06884304b890889685b91
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.1.2] - 2025-09-21
11
+
12
+ ### Added
13
+ - Configuration option `claims_consistency_mode` supporting `:log_only` so deployments can record mismatches without rejecting requests.
14
+
15
+ ### Changed
16
+ - Sanitize log payload strings (including JWT tags) before invoking hooks or emitting to loggers to mitigate log forging attempts.
17
+
18
+ ### Documentation
19
+ - Document trusted proxy hygiene, sanitized logging hooks, and the new log-only mode in the README and Rails guide.
20
+
21
+ ## [0.1.1] - 2025-09-15
22
+
23
+ ### Changed
24
+ - Centralize `MAX_TOKEN_BYTES` in `Verikloak::BFF::Constants` and refactor usages in `HeaderGuard` and `ConsistencyChecks` to avoid duplication.
25
+
26
+ ### Fixed
27
+ - Preserve full token content when forwarded header includes control characters (e.g., `Bearer tok\r\nmal`) by adjusting Bearer parsing in `ForwardedToken.normalize_forwarded`; combined with existing sanitization, Authorization now normalizes to `Bearer tokmal`.
28
+
29
+ ### Tests
30
+ - Add boundary tests for token size limits in `ConsistencyChecks` and `HeaderGuard`.
31
+
10
32
  ## [0.1.0] - 2025-09-14
11
33
 
12
34
  ### Added
data/README.md CHANGED
@@ -42,17 +42,18 @@ See `examples/rack.ru` for a tiny Rack app demo.
42
42
 
43
43
  | Key | Type | Default | Description |
44
44
  |----------------------------- |--------------------------------------|--------------|-------------|
45
- | `trusted_proxies` | Array[String/Regexp/Proc] | `[]` | Allowlist for proxy peers (by IP/CIDR/regex/proc). |
45
+ | `trusted_proxies` | Array[String/Regexp/Proc] | *(required)* | Allowlist for proxy peers (by IP/CIDR/regex/proc). |
46
46
  | `prefer_forwarded` | Boolean | `true` | Prefer `X-Forwarded-Access-Token` over `Authorization`. |
47
47
  | `require_forwarded_header` | Boolean | `false` | Reject when no `X-Forwarded-Access-Token` (blocks direct access). |
48
48
  | `enforce_header_consistency` | Boolean | `true` | If both headers exist, require identical token values. |
49
49
  | `enforce_claims_consistency` | Hash | `{}` | Mapping of header→claim to compare (e.g., `{ email: :email, user: :sub, groups: :realm_roles }`). |
50
+ | `claims_consistency_mode` | Symbol (`:enforce`/`:log_only`) | `:enforce` | When `:log_only`, mismatches are logged but the request continues (still require downstream JWT verification). |
50
51
  | `strip_suspicious_headers` | Boolean | `true` | Remove external `X-Auth-Request-*` before passing downstream. |
51
52
  | `xff_strategy` | Symbol (`:rightmost`/`:leftmost`) | `:rightmost` | Which peer to pick from `X-Forwarded-For`. |
52
53
  | `peer_preference` | Symbol (`:remote_then_xff`/`:xff_only`) | `:remote_then_xff` | Whether to prefer `REMOTE_ADDR` before falling back to XFF. |
53
54
  | `clock_skew_leeway` | Integer (seconds) | `30` | Reserved for small exp/nbf skew handled by core verifier. |
54
55
  | `logger` | `Logger` or `nil` | `nil` | Logger for audit tags (`rid`, `sub`, `kid`, `iss/aud`). |
55
- | `token_header_priority` | Array[String] | see code | When Authorization is empty and no token chosen, seed it from these env headers in order. `HTTP_AUTHORIZATION` is ignored as a source; forwarded header is considered only from trusted peers. |
56
+ | `token_header_priority` | Array[String] | `['HTTP_X_FORWARDED_ACCESS_TOKEN']` | When Authorization is empty and no token chosen, seed it from these env headers in order. `HTTP_AUTHORIZATION` is ignored as a source; forwarded header is considered only from trusted peers. |
56
57
  | `forwarded_header_name` | String | `HTTP_X_FORWARDED_ACCESS_TOKEN` | Env key for forwarded access token. |
57
58
  | `auth_request_headers` | Hash | see code | Mapping for `X-Auth-Request-*` env keys: `{ email, user, groups }`. |
58
59
 
@@ -69,6 +70,9 @@ For full reverse proxy examples (Nginx auth_request / oauth2-proxy), see [docs/r
69
70
  - Set `peer_preference: :remote_then_xff` (default) to evaluate trust using the direct peer first, then fall back to the nearest (rightmost) `X-Forwarded-For` value.
70
71
  - If you run only behind a single, known proxy chain and want to rely solely on XFF ordering, use `peer_preference: :xff_only` and control position with `xff_strategy`.
71
72
 
73
+ - Trusted proxy hygiene
74
+ - Keep `trusted_proxies` as specific as possible (individual IPs, tight CIDR ranges, or regexes). Review the list whenever proxy topology changes to avoid unintentionally widening the trust boundary.
75
+
72
76
  - Header name customization
73
77
  - Forwarded-access-token header can be changed via:
74
78
  - `forwarded_header_name: 'HTTP_X_CUSTOM_FORWARD_TOKEN'`
@@ -81,9 +85,12 @@ For full reverse proxy examples (Nginx auth_request / oauth2-proxy), see [docs/r
81
85
 
82
86
  - Observability helpers
83
87
  - Downstream can inspect `env['verikloak.bff.token']` (chosen token, unverified) and `env['verikloak.bff.selected_peer']` (peer IP selected for trust decisions).
84
- - Provide a structured log hook with `log_with: ->(payload) { logger.info(payload.to_json) }` to consume the same fields emitted to `logger`.
88
+ - Provide a structured log hook with `log_with: ->(payload) { logger.info(payload.to_json) }` to consume the same fields emitted to `logger`. Payload strings are sanitized (control characters removed) before hooks and loggers run to mitigate log forging.
85
89
  - Caution: avoid logging the entire Rack `env` in application logs. Treat `env['verikloak.bff.token']` as sensitive; never emit raw tokens or PII (e.g., emails) to logs.
86
90
 
91
+ - Claims consistency modes
92
+ - Default `:enforce` mode rejects requests with mismatches. Switch to `claims_consistency_mode: :log_only` when you only need observability signals; downstream services must continue verifying JWT signatures, issuer, audience, and expirations.
93
+
87
94
  ## Development (for contributors)
88
95
  Clone and install dependencies:
89
96
 
@@ -30,7 +30,8 @@ module Verikloak
30
30
  :enforce_header_consistency, :enforce_claims_consistency,
31
31
  :strip_suspicious_headers, :xff_strategy, :clock_skew_leeway,
32
32
  :logger, :token_header_priority, :peer_preference,
33
- :forwarded_header_name, :auth_request_headers, :log_with
33
+ :forwarded_header_name, :auth_request_headers, :log_with,
34
+ :claims_consistency_mode
34
35
 
35
36
  # enforce_claims_consistency example:
36
37
  # { email: :email, user: :sub, groups: :realm_roles }
@@ -40,6 +41,7 @@ module Verikloak
40
41
  @require_forwarded_header = false
41
42
  @enforce_header_consistency = true
42
43
  @enforce_claims_consistency = {}
44
+ @claims_consistency_mode = :enforce
43
45
  @strip_suspicious_headers = true
44
46
  @xff_strategy = :rightmost
45
47
  @peer_preference = :remote_then_xff
@@ -53,10 +55,9 @@ module Verikloak
53
55
  groups: 'HTTP_X_AUTH_REQUEST_GROUPS'
54
56
  }
55
57
  # When Authorization is empty and no chosen token exists, try these env headers (in order)
56
- # to seed Authorization, similar to verikloak-rails behavior. HTTP_AUTHORIZATION is ignored as a source.
58
+ # to seed Authorization, similar to verikloak-rails behavior. HTTP_AUTHORIZATION is always ignored as a source.
57
59
  @token_header_priority = %w[
58
60
  HTTP_X_FORWARDED_ACCESS_TOKEN
59
- HTTP_AUTHORIZATION
60
61
  ]
61
62
  end
62
63
  end
@@ -7,6 +7,7 @@
7
7
 
8
8
  require 'json'
9
9
  require 'jwt' # used only to parse segments safely without verify
10
+ require 'verikloak/bff/constants'
10
11
 
11
12
  module Verikloak
12
13
  module BFF
@@ -19,14 +20,12 @@ module Verikloak
19
20
  #
20
21
  # @param token [String, nil]
21
22
  # @return [Hash] claims or empty hash on error
23
+
22
24
  def decode_claims(token)
23
25
  return {} unless token
26
+ return {} if token.bytesize > Constants::MAX_TOKEN_BYTES
24
27
 
25
- parts = token.split('.')
26
- return {} unless parts.size >= 2
27
-
28
- payload = JWT::Base64.url_decode(parts[1])
29
- JSON.parse(payload)
28
+ JWT.decode(token, nil, false).first
30
29
  rescue StandardError
31
30
  {}
32
31
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verikloak
4
+ module BFF
5
+ module Constants
6
+ MAX_TOKEN_BYTES = 4096
7
+ end
8
+ end
9
+ end
@@ -45,7 +45,7 @@ module Verikloak
45
45
  return nil unless raw
46
46
 
47
47
  token = raw.to_s.strip
48
- return ::Regexp.last_match(1) if token =~ /^Bearer\s+(.+)$/i
48
+ token = token.sub(/^Bearer\s+/i, '') if token =~ /^Bearer\s+/i
49
49
 
50
50
  token.empty? ? nil : token
51
51
  end
@@ -57,7 +57,7 @@ module Verikloak
57
57
  # @param token [String]
58
58
  # @return [String]
59
59
  def ensure_bearer(token)
60
- s = token.to_s.strip
60
+ s = sanitize(token)
61
61
  # Case-insensitive 'Bearer' with spaces/tabs after
62
62
  if s =~ /\ABearer[ \t]+/i
63
63
  rest = s.sub(/\ABearer[ \t]+/i, '')
@@ -86,6 +86,14 @@ module Verikloak
86
86
  env[AUTH_HEADER] = ensure_bearer(token)
87
87
  end
88
88
 
89
+ # Remove CRLF and other control characters to prevent header injection.
90
+ #
91
+ # @param token [String]
92
+ # @return [String]
93
+ def sanitize(token)
94
+ token.to_s.gsub(/[[:cntrl:]]/, '').strip
95
+ end
96
+
89
97
  # Remove potentially forged X-Auth-Request-* headers before passing
90
98
  # downstream when not emitted by a trusted proxy.
91
99
  #
@@ -11,16 +11,74 @@
11
11
  # headers against JWT claims, and normalizes the request into
12
12
  # `HTTP_AUTHORIZATION: Bearer <token>` for the downstream verifier.
13
13
  require 'rack'
14
+ require 'rack/utils'
14
15
  require 'json'
15
16
  require 'jwt'
17
+ require 'digest'
16
18
  require 'verikloak/bff/configuration'
17
19
  require 'verikloak/bff/errors'
18
20
  require 'verikloak/bff/proxy_trust'
19
21
  require 'verikloak/bff/forwarded_token'
20
22
  require 'verikloak/bff/consistency_checks'
23
+ require 'verikloak/bff/constants'
21
24
 
22
25
  module Verikloak
23
26
  module BFF
27
+ # Internal helpers that sanitize tokens and log payloads for HeaderGuard.
28
+ module HeaderGuardSanitizer
29
+ LOG_CONTROL_CHARS = /[[:cntrl:]]/
30
+
31
+ module_function
32
+
33
+ def token_tags(token)
34
+ return {} unless token
35
+
36
+ payload, header = decode_unverified(token)
37
+ aud = payload['aud']
38
+ aud = aud.join(' ') if aud.is_a?(Array)
39
+ {
40
+ sub: sanitize_log_field(payload['sub']&.to_s),
41
+ iss: sanitize_log_field(payload['iss']&.to_s),
42
+ aud: sanitize_log_field(aud&.to_s),
43
+ kid: sanitize_log_field(header['kid']&.to_s)
44
+ }.compact
45
+ rescue StandardError
46
+ {}
47
+ end
48
+
49
+ def decode_unverified(token)
50
+ return [{}, {}] if token.nil? || token.bytesize > Constants::MAX_TOKEN_BYTES
51
+
52
+ JWT.decode(token, nil, false)
53
+ rescue StandardError
54
+ [{}, {}]
55
+ end
56
+
57
+ def sanitize_payload(payload)
58
+ payload.transform_values { |value| sanitize_log_field(value) }.compact
59
+ end
60
+
61
+ def sanitize_log_field(value)
62
+ case value
63
+ when nil
64
+ nil
65
+ when String
66
+ sanitized = sanitize_string(value)
67
+ sanitized.empty? ? nil : sanitized
68
+ when Array
69
+ sanitized = value.map { |item| item.is_a?(String) ? sanitize_string(item) : item }
70
+ sanitized.reject! { |item| item.nil? || (item.is_a?(String) && item.empty?) }
71
+ sanitized.empty? ? nil : sanitized
72
+ else
73
+ value
74
+ end
75
+ end
76
+
77
+ def sanitize_string(value)
78
+ value.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '').gsub(LOG_CONTROL_CHARS, '')
79
+ end
80
+ end
81
+
24
82
  # Rack middleware that enforces BFF boundary and header/claims consistency.
25
83
  class HeaderGuard
26
84
  # Accept both Rack 2 and Rack 3 builder call styles:
@@ -38,6 +96,10 @@ module Verikloak
38
96
  combined.merge!(opts) if opts.is_a?(Hash)
39
97
  combined.merge!(opts_kw) if opts_kw && !opts_kw.empty?
40
98
  apply_overrides!(combined)
99
+
100
+ return unless @config.trusted_proxies.nil? || @config.trusted_proxies.empty?
101
+
102
+ raise ArgumentError, 'trusted_proxies must be configured'
41
103
  end
42
104
 
43
105
  # Process a Rack request.
@@ -99,45 +161,6 @@ module Verikloak
99
161
  end
100
162
  end
101
163
 
102
- # Extract selected JWT tags for audit logging (best-effort, unverified).
103
- #
104
- # @param token [String, nil]
105
- # @return [Hash] subset of {sub, iss, aud, kid}
106
- def token_tags(token)
107
- return {} unless token
108
-
109
- payload, header = decode_unverified(token)
110
- {
111
- sub: payload['sub'],
112
- iss: payload['iss'],
113
- aud: (payload['aud'].is_a?(Array) ? payload['aud'].join(' ') : payload['aud']).to_s,
114
- kid: header['kid']
115
- }.compact
116
- rescue StandardError
117
- {}
118
- end
119
-
120
- # Decode JWT header/payload without validation (for logging only).
121
- #
122
- # @param token [String]
123
- # @return [Array(Hash, Hash)] [payload, header]
124
- def decode_unverified(token)
125
- parts = token.to_s.split('.')
126
- return [{}, {}] unless parts.size >= 2
127
-
128
- payload = begin
129
- JSON.parse(::JWT::Base64.url_decode(parts[1]))
130
- rescue StandardError
131
- {}
132
- end
133
- header = begin
134
- JSON.parse(::JWT::Base64.url_decode(parts[0]))
135
- rescue StandardError
136
- {}
137
- end
138
- [payload, header]
139
- end
140
-
141
164
  # Extract request id for logging from common headers.
142
165
  #
143
166
  # @param env [Hash]
@@ -162,16 +185,17 @@ module Verikloak
162
185
  def log_event(env, kind, **attrs)
163
186
  lg = logger(env)
164
187
  payload = { event: 'bff.header_guard', kind: kind, rid: request_id(env) }.merge(attrs).compact
188
+ sanitized = HeaderGuardSanitizer.sanitize_payload(payload)
165
189
  if @config.log_with.respond_to?(:call)
166
190
  begin
167
- @config.log_with.call(payload)
191
+ @config.log_with.call(sanitized)
168
192
  rescue StandardError
169
193
  # ignore log hook failures
170
194
  end
171
195
  end
172
196
  return unless lg
173
197
 
174
- msg = payload.map { |k, v| v.nil? || v.to_s.empty? ? nil : "#{k}=#{v}" }.compact.join(' ')
198
+ msg = sanitized.map { |k, v| v.nil? || v.to_s.empty? ? nil : "#{k}=#{v}" }.compact.join(' ')
175
199
  level = (kind == :ok ? :info : :warn)
176
200
  lg.public_send(level, msg)
177
201
  rescue StandardError
@@ -203,7 +227,10 @@ module Verikloak
203
227
  def enforce_header_consistency!(env, auth_token, fwd_token)
204
228
  return unless @config.enforce_header_consistency
205
229
  return unless auth_token && fwd_token
206
- return if auth_token == fwd_token
230
+
231
+ digest_a = ::Digest::SHA256.hexdigest(auth_token)
232
+ digest_b = ::Digest::SHA256.hexdigest(fwd_token)
233
+ return if Rack::Utils.secure_compare(digest_a, digest_b)
207
234
 
208
235
  log_event(env, :mismatch, reason: 'authorization_vs_forwarded')
209
236
  raise HeaderMismatchError
@@ -219,9 +246,21 @@ module Verikloak
219
246
 
220
247
  field = res.last
221
248
  log_event(env, :claims_mismatch, field: field.to_s)
249
+ return if claims_consistency_log_only?
250
+
222
251
  raise ClaimsMismatchError, field
223
252
  end
224
253
 
254
+ # Determine whether claims mismatches should only be logged.
255
+ #
256
+ # @return [Boolean]
257
+ def claims_consistency_log_only?
258
+ mode = @config.claims_consistency_mode || :enforce
259
+ mode = mode.to_sym if mode.is_a?(String)
260
+ mode = :enforce unless %i[enforce log_only].include?(mode)
261
+ mode == :log_only
262
+ end
263
+
225
264
  # Set normalized Authorization header and emit success log.
226
265
  #
227
266
  # @param env [Hash]
@@ -232,7 +271,7 @@ module Verikloak
232
271
  return unless chosen
233
272
 
234
273
  ForwardedToken.set_authorization!(env, chosen)
235
- log_event(env, :ok, source: token_source(auth_token, fwd_token), **token_tags(chosen))
274
+ log_event(env, :ok, source: token_source(auth_token, fwd_token), **HeaderGuardSanitizer.token_tags(chosen))
236
275
  end
237
276
 
238
277
  # Build a minimal RFC6750-style error response.
@@ -23,7 +23,7 @@ module Verikloak
23
23
  # @example CIDR + Regex allowlist
24
24
  # ProxyTrust.trusted?(env, ["10.0.0.0/8", /^192\.168\./], :rightmost)
25
25
  def trusted?(env, trusted, strategy = :rightmost)
26
- return true if trusted.nil? || trusted.empty?
26
+ return false if trusted.nil? || trusted.empty?
27
27
 
28
28
  # Rails-aligned: prefer REMOTE_ADDR; fallback to nearest XFF entry
29
29
  remote = (env['REMOTE_ADDR'] || '').to_s.strip
@@ -108,7 +108,7 @@ module Verikloak
108
108
  # @param trusted [Array<String, Regexp, Proc>, nil]
109
109
  # @return [Boolean]
110
110
  def self.from_trusted_proxy?(env, trusted)
111
- return true if trusted.nil? || trusted.empty?
111
+ return false if trusted.nil? || trusted.empty?
112
112
 
113
113
  ip = (env['REMOTE_ADDR'] || '').to_s.strip
114
114
  ip = env['HTTP_X_FORWARDED_FOR'].to_s.split(',').last.to_s.strip if ip.empty? && env['HTTP_X_FORWARDED_FOR']
@@ -5,6 +5,6 @@
5
5
  # @return [String]
6
6
  module Verikloak
7
7
  module BFF
8
- VERSION = '0.1.0'
8
+ VERSION = '0.1.2'
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: verikloak-bff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - taiyaky
@@ -13,16 +13,22 @@ dependencies:
13
13
  name: jwt
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '2.7'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '4.0'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
23
- - - "~>"
26
+ - - ">="
24
27
  - !ruby/object:Gem::Version
25
28
  version: '2.7'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '4.0'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: rack
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -76,6 +82,7 @@ files:
76
82
  - lib/verikloak/bff.rb
77
83
  - lib/verikloak/bff/configuration.rb
78
84
  - lib/verikloak/bff/consistency_checks.rb
85
+ - lib/verikloak/bff/constants.rb
79
86
  - lib/verikloak/bff/errors.rb
80
87
  - lib/verikloak/bff/forwarded_token.rb
81
88
  - lib/verikloak/bff/header_guard.rb
@@ -88,7 +95,7 @@ metadata:
88
95
  source_code_uri: https://github.com/taiyaky/verikloak-bff
89
96
  changelog_uri: https://github.com/taiyaky/verikloak-bff/blob/main/CHANGELOG.md
90
97
  bug_tracker_uri: https://github.com/taiyaky/verikloak-bff/issues
91
- documentation_uri: https://rubydoc.info/gems/verikloak-bff/0.1.0
98
+ documentation_uri: https://rubydoc.info/gems/verikloak-bff/0.1.2
92
99
  rubygems_mfa_required: 'true'
93
100
  rdoc_options: []
94
101
  require_paths: