verikloak-bff 0.3.0 → 0.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: 8a79baf28ee05a8774b36916c71281bd72cd35825148413c15e9f865b85416cb
4
- data.tar.gz: 739244544590c0f306eff4d587c27c312adc4c081646a45d2ec4a58e83ce6abb
3
+ metadata.gz: 3c5f40a546e7c2d93204ed332332ef0a7856f40b01b1b6ad3ca593ad97fc782d
4
+ data.tar.gz: 82d641a1d023617ccfdd313228a70e85d5cb314d0de410822789b12210a5b260
5
5
  SHA512:
6
- metadata.gz: 713b4e27ee284e38917a6fd4880414cdb8792fbffd4f7713112d2fa619f5ecf3120379a4a0b29eb115ce075c6373e0a4b25f1bca38bd8e8af0ff683944dcd8fd
7
- data.tar.gz: ed258207b16fdb79fb079656393e0f7fd4bc1a650d942c6c19060c0aeaf45f8e63fa393a115bb8f80ea87f9c0d84cd025bb55a5ffb5259a981007211c896b786
6
+ metadata.gz: aff00531a727ad605b8b33fc18e0bea74dfebcef456bc3d9f43f1b6274772b00f7694765dffb30cf0be7fd4682d861685117a52fe821cb44405377daad9b7268
7
+ data.tar.gz: 37acc8610e94b2606c958217b58f1842b47f8b277f57773f096c37b33099545c98b10f984c8102cc3880e7c4f5148140ca7fa0c5213ecfded1174377834cca0f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.4.0] - 2026-02-15
11
+
12
+ ### Security
13
+ - **Log value truncation**: `sanitize_string` now truncates values exceeding 256 characters (`MAX_LOG_FIELD_LENGTH`) to prevent log injection / memory abuse
14
+
15
+ ### Fixed
16
+ - **`MAX_TOKEN_BYTES`**: Raised from 4096 to 8192 to match core gem — prevents behavioural inconsistency (false rejection / inspection bypass) for tokens between 4 KB and 8 KB
17
+ - **IPv4-mapped IPv6 normalisation**: `ProxyTrust.ip_or_nil` now calls `IPAddr#native` so that `::ffff:172.17.0.1` correctly matches `172.17.0.0/16` in Docker/Kubernetes environments
18
+ - **`apply_overrides!` hardening**: Rejects keys starting with `_` or containing `!` to prevent accidental invocation of non-accessor methods (consistent with verikloak-rails `BffConfigurator`)
19
+ - **`ForwardedToken::FORWARDED_HEADER`**: Now references `Verikloak::HeaderSources::DEFAULT_FORWARDED_HEADER` instead of duplicating the string, eliminating maintenance drift risk
20
+
21
+ ### Changed
22
+ - Error responses now delegate to `Verikloak::ErrorResponse.build` for RFC 6750-compliant JSON output
23
+ - Error class hierarchy unified: `Verikloak::BFF::Error` now inherits from `Verikloak::Error`
24
+ - **BREAKING**: Minimum `verikloak` dependency raised to `>= 0.4.0`
25
+ - Dev dependency `rspec` pinned to `~> 3.13`, `rubocop-rspec` pinned to `~> 3.9`
26
+
27
+ ### Inherited from verikloak 0.4.0
28
+ The following security improvements are provided by the core `verikloak` gem and become available through the dependency bump. They are **not implemented in verikloak-bff** itself:
29
+ - Faraday 2.14.1 security update (CVE-2026-25765)
30
+ - Header injection protection via `Verikloak::ErrorResponse.sanitize_header_value`
31
+ - JWT token size limit (`MAX_TOKEN_BYTES = 8192`)
32
+ - HTTPS enforcement and SSRF protection in OIDC discovery
33
+ - URL path-traversal normalisation
34
+
35
+ ---
36
+
10
37
  ## [0.3.0] - 2025-01-01
11
38
 
12
39
  ### Added
data/README.md CHANGED
@@ -113,6 +113,7 @@ For full reverse proxy examples (Nginx auth_request / oauth2-proxy), see [docs/r
113
113
 
114
114
  - Trusted proxy hygiene
115
115
  - 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.
116
+ - IPv4-mapped IPv6 addresses (`::ffff:172.17.0.1`) are automatically normalised to native IPv4 so that plain IPv4 CIDR ranges (e.g. `172.17.0.0/16`) match correctly in Docker / Kubernetes environments.
116
117
 
117
118
  - Header name customization
118
119
  - Forwarded-access-token header can be changed via:
@@ -36,7 +36,31 @@ module Verikloak
36
36
  return true if mapping.nil? || mapping.empty?
37
37
 
38
38
  claims = decode_claims(token)
39
+ enforce_claims(env, claims, mapping, headers_map)
40
+ end
41
+
42
+ # Enforce consistency using pre-decoded claims (avoids redundant JWT parsing).
43
+ #
44
+ # @param env [Hash]
45
+ # @param claims [Hash] pre-decoded JWT claims
46
+ # @param mapping [Hash]
47
+ # @param headers_map [Hash{Symbol=>String}, nil]
48
+ # @return [true, Array(:error, Symbol)]
49
+ def enforce_with_claims(env, claims, mapping, headers_map = nil)
50
+ return true if mapping.nil? || mapping.empty?
39
51
 
52
+ claims = {} unless claims.is_a?(Hash)
53
+ enforce_claims(env, claims, mapping, headers_map)
54
+ end
55
+
56
+ # Shared implementation for enforce! and enforce_with_claims.
57
+ #
58
+ # @param env [Hash]
59
+ # @param claims [Hash]
60
+ # @param mapping [Hash]
61
+ # @param headers_map [Hash{Symbol=>String}, nil]
62
+ # @return [true, Array(:error, Symbol)]
63
+ def enforce_claims(env, claims, mapping, headers_map)
40
64
  mapping.each do |header_key, claim_key|
41
65
  hdr_val = extract_header_value(env, header_key, headers_map)
42
66
  next if hdr_val.nil? # no header → skip comparison
@@ -2,8 +2,20 @@
2
2
 
3
3
  module Verikloak
4
4
  module BFF
5
+ # Shared constants for the BFF middleware layer.
6
+ # Centralised here so that every module (HeaderGuard, JwtUtils,
7
+ # ConsistencyChecks, etc.) references the same values.
5
8
  module Constants
6
- MAX_TOKEN_BYTES = 4096
9
+ # Maximum JWT byte size accepted for unverified decoding.
10
+ # Tokens exceeding this limit are treated as opaque (no claim inspection).
11
+ MAX_TOKEN_BYTES = 8192
12
+
13
+ # Regex matching Unicode control characters, used by log sanitisation.
14
+ LOG_CONTROL_CHARS = /[[:cntrl:]]/
15
+
16
+ # Maximum length for individual log field values to prevent log flooding
17
+ # from oversized or malicious JWT claims.
18
+ MAX_LOG_FIELD_LENGTH = 256
7
19
  end
8
20
  end
9
21
  end
@@ -3,15 +3,17 @@
3
3
  # Error types emitted by verikloak-bff. These map to RFC6750-style responses
4
4
  # with stable error codes for easy handling at clients and logs.
5
5
 
6
+ require 'verikloak/errors'
7
+
6
8
  module Verikloak
7
9
  module BFF
8
10
  # Base error class with HTTP status and short code.
11
+ # Inherits from {Verikloak::Error} so that `rescue Verikloak::Error` catches all
12
+ # Verikloak gem errors uniformly.
9
13
  #
10
14
  # @attr_reader [String] code
11
15
  # @attr_reader [Integer] http_status
12
- class Error < StandardError
13
- attr_reader :code, :http_status
14
-
16
+ class Error < Verikloak::Error
15
17
  # Build a BFF error with a stable code and HTTP status.
16
18
  #
17
19
  # @param message [String, nil]
@@ -19,9 +21,7 @@ module Verikloak
19
21
  # @param http_status [Integer]
20
22
  # @return [void]
21
23
  def initialize(message = nil, code: 'bff_error', http_status: 401)
22
- super(message || code)
23
- @code = code
24
- @http_status = http_status
24
+ super(message || code, code: code, http_status: http_status)
25
25
  end
26
26
  end
27
27
 
@@ -4,14 +4,16 @@
4
4
  #
5
5
  # @see .extract
6
6
 
7
+ require 'verikloak/header_sources'
8
+
7
9
  module Verikloak
8
10
  module BFF
9
11
  # Helpers to extract and normalize forwarded/access token headers.
10
12
  module ForwardedToken
11
13
  module_function
12
14
 
13
- FORWARDED_HEADER = 'HTTP_X_FORWARDED_ACCESS_TOKEN'
14
- AUTH_HEADER = 'HTTP_AUTHORIZATION'
15
+ FORWARDED_HEADER = Verikloak::HeaderSources::DEFAULT_FORWARDED_HEADER
16
+ AUTH_HEADER = Verikloak::HeaderSources::AUTHORIZATION_HEADER
15
17
 
16
18
  # Extract normalized tokens from the Rack env.
17
19
  #
@@ -27,7 +27,8 @@ module Verikloak
27
27
  module BFF
28
28
  # Internal helpers that sanitize tokens and log payloads for HeaderGuard.
29
29
  module HeaderGuardSanitizer
30
- LOG_CONTROL_CHARS = /[[:cntrl:]]/
30
+ LOG_CONTROL_CHARS = Constants::LOG_CONTROL_CHARS
31
+ MAX_LOG_FIELD_LENGTH = Constants::MAX_LOG_FIELD_LENGTH
31
32
 
32
33
  module_function
33
34
 
@@ -40,15 +41,31 @@ module Verikloak
40
41
  return {} unless token
41
42
 
42
43
  payload, header = decode_unverified(token)
44
+ token_tags_from_decoded(payload, header)
45
+ rescue StandardError => e
46
+ warn("[verikloak-bff] token_tags failed: #{e.class}: #{e.message}") if $DEBUG
47
+ {}
48
+ end
49
+
50
+ # Build sanitized log tags from pre-decoded JWT payload and header.
51
+ # Avoids redundant decoding when the caller already has decoded data.
52
+ #
53
+ # @param payload [Hash]
54
+ # @param header [Hash]
55
+ # @return [Hash{Symbol=>Object}]
56
+ def token_tags_from_decoded(payload, header)
57
+ return {} unless payload.is_a?(Hash)
58
+
43
59
  aud = payload['aud']
44
60
  aud = aud.join(' ') if aud.is_a?(Array)
45
61
  {
46
62
  sub: sanitize_log_field(payload['sub']&.to_s),
47
63
  iss: sanitize_log_field(payload['iss']&.to_s),
48
64
  aud: sanitize_log_field(aud&.to_s),
49
- kid: sanitize_log_field(header['kid']&.to_s)
65
+ kid: sanitize_log_field(header&.dig('kid')&.to_s)
50
66
  }.compact
51
- rescue StandardError
67
+ rescue StandardError => e
68
+ warn("[verikloak-bff] token_tags_from_decoded failed: #{e.class}: #{e.message}") if $DEBUG
52
69
  {}
53
70
  end
54
71
 
@@ -89,12 +106,14 @@ module Verikloak
89
106
  end
90
107
  end
91
108
 
92
- # Remove control characters and invalid UTF-8 from a string.
109
+ # Remove control characters, invalid UTF-8, and truncate to {MAX_LOG_FIELD_LENGTH}.
93
110
  #
94
111
  # @param value [#to_s]
95
112
  # @return [String]
96
113
  def sanitize_string(value)
97
- value.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '').gsub(LOG_CONTROL_CHARS, '')
114
+ sanitized = value.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '').gsub(LOG_CONTROL_CHARS,
115
+ '')
116
+ sanitized.length > MAX_LOG_FIELD_LENGTH ? "#{sanitized[0, MAX_LOG_FIELD_LENGTH]}..." : sanitized
98
117
  end
99
118
 
100
119
  # Describe the source of the chosen token for logging purposes.
@@ -129,26 +148,38 @@ module Verikloak
129
148
  # @param attrs [Hash]
130
149
  # @return [void]
131
150
  def log_event(env, config, kind, **attrs)
132
- lg = config.logger || env['rack.logger']
133
151
  payload = { event: 'bff.header_guard', kind: kind, rid: request_id(env) }.merge(attrs).compact
134
152
  sanitized = sanitize_payload(payload)
135
- if config.log_with.respond_to?(:call)
136
- begin
137
- config.log_with.call(sanitized)
138
- rescue StandardError
139
- # ignore log hook failures
140
- end
141
- end
142
- return unless lg
153
+ invoke_log_hook(config, sanitized)
154
+ emit_to_logger(config.logger || env['rack.logger'], sanitized, kind)
155
+ rescue StandardError => e
156
+ warn("[verikloak-bff] log_event failed: #{e.class}: #{e.message}") if $DEBUG
157
+ end
158
+
159
+ # @param config [Configuration]
160
+ # @param sanitized [Hash]
161
+ # @return [void]
162
+ def invoke_log_hook(config, sanitized)
163
+ return unless config.log_with.respond_to?(:call)
164
+
165
+ config.log_with.call(sanitized)
166
+ rescue StandardError => e
167
+ warn("[verikloak-bff] log_with hook failed: #{e.class}: #{e.message}") if $DEBUG
168
+ end
169
+
170
+ # @param logger [Logger, nil]
171
+ # @param sanitized [Hash]
172
+ # @param kind [Symbol]
173
+ # @return [void]
174
+ def emit_to_logger(logger, sanitized, kind)
175
+ return unless logger
143
176
 
144
177
  msg = sanitized.map { |k, v| v.nil? || v.to_s.empty? ? nil : "#{k}=#{v}" }.compact.join(' ')
145
- level = (kind == :ok ? :info : :warn)
146
- lg.public_send(level, msg)
147
- rescue StandardError
148
- # no-op on logging errors
178
+ logger.public_send(kind == :ok ? :info : :warn, msg)
149
179
  end
150
180
 
151
181
  # Build a minimal RFC6750-style error response.
182
+ # Delegates to {Verikloak::ErrorResponse} for consistent formatting across gems.
152
183
  #
153
184
  # @param env [Hash]
154
185
  # @param config [Configuration]
@@ -156,10 +187,23 @@ module Verikloak
156
187
  # @return [Array(Integer, Hash, Array<String>)]
157
188
  def respond_with_error(env, config, error)
158
189
  log_event(env, config, :error, code: error.code)
159
- body = { error: error.code, message: error.message }.to_json
160
- headers = { 'Content-Type' => 'application/json',
161
- 'WWW-Authenticate' => %(Bearer error="#{error.code}", error_description="#{error.message}") }
162
- [error.http_status, headers, [body]]
190
+ require 'verikloak/error_response'
191
+ status, headers, body = Verikloak::ErrorResponse.build(
192
+ code: error.code, message: error.message, status: error.http_status
193
+ )
194
+
195
+ # RFC 6750 §3.1: include WWW-Authenticate on 403 for client diagnostics
196
+ if status == 403 && !headers.key?('WWW-Authenticate')
197
+ sanitize = Verikloak::ErrorResponse.method(:sanitize_header_value)
198
+ headers['WWW-Authenticate'] = format(
199
+ 'Bearer realm="%<realm>s", error="%<code>s", error_description="%<msg>s"',
200
+ realm: sanitize.call('verikloak-bff'),
201
+ code: sanitize.call(error.code),
202
+ msg: sanitize.call(error.message)
203
+ )
204
+ end
205
+
206
+ [status, headers, body]
163
207
  end
164
208
  end
165
209
 
@@ -168,7 +212,7 @@ module Verikloak
168
212
  # Error raised when trusted_proxies is not configured and disabled is not explicitly set.
169
213
  class ConfigurationError < StandardError; end
170
214
 
171
- RequestTokens = Struct.new(:auth, :forwarded, :chosen)
215
+ RequestTokens = Struct.new(:auth, :forwarded, :chosen, :decoded_payload, :decoded_header)
172
216
 
173
217
  # Accept both Rack 2 and Rack 3 builder call styles:
174
218
  # - new(app, key: val)
@@ -251,6 +295,8 @@ module Verikloak
251
295
  end
252
296
 
253
297
  # Build token state by extracting, validating, and selecting the active token.
298
+ # Decodes the chosen token once (unverified) so that downstream stages
299
+ # (claims consistency, logging) can reuse the result without re-parsing.
254
300
  #
255
301
  # @param env [Hash]
256
302
  # @return [RequestTokens]
@@ -261,7 +307,8 @@ module Verikloak
261
307
  chosen = choose_token(auth_token, fwd_token)
262
308
  chosen = seed_authorization_if_needed(env, chosen)
263
309
 
264
- RequestTokens.new(auth_token, fwd_token, chosen)
310
+ payload, header = JwtUtils.decode_unverified(chosen)
311
+ RequestTokens.new(auth_token, fwd_token, chosen, payload, header)
265
312
  end
266
313
 
267
314
  # Apply header and claim consistency policies for the current request.
@@ -271,7 +318,7 @@ module Verikloak
271
318
  # @return [void]
272
319
  def enforce_token_policies!(env, tokens)
273
320
  enforce_header_consistency!(env, tokens.auth, tokens.forwarded)
274
- enforce_claims_consistency!(env, tokens.chosen)
321
+ enforce_claims_consistency!(env, tokens)
275
322
  end
276
323
 
277
324
  # Mutate the Rack env with normalized headers and logging hints.
@@ -281,18 +328,24 @@ module Verikloak
281
328
  # @return [void]
282
329
  def finalize_request!(env, tokens)
283
330
  ForwardedToken.strip_suspicious!(env, @config.auth_request_headers) if @config.strip_suspicious_headers
284
- normalize_authorization!(env, tokens.chosen, tokens.auth, tokens.forwarded)
331
+ normalize_authorization!(env, tokens)
285
332
  expose_env_hints(env, tokens.chosen)
286
333
  end
287
334
 
288
335
  # Apply per-instance configuration overrides.
336
+ # Keys starting with '_' or containing '!' are rejected to prevent
337
+ # accidental invocation of non-accessor methods.
289
338
  #
290
339
  # @param opts [Hash]
291
340
  # @return [void]
292
341
  def apply_overrides!(opts)
293
342
  cfg = @config
294
343
  opts.each do |k, v|
295
- cfg.public_send("#{k}=", v) if cfg.respond_to?("#{k}=")
344
+ key_s = k.to_s
345
+ next if key_s.start_with?('_') || key_s.include?('!')
346
+
347
+ writer = "#{key_s}="
348
+ cfg.public_send(writer, v) if cfg.respond_to?(writer)
296
349
  end
297
350
  end
298
351
 
@@ -347,11 +400,14 @@ module Verikloak
347
400
  # Enforce X-Auth-Request-* ↔ JWT claims mapping when configured.
348
401
  #
349
402
  # @param env [Hash]
350
- # @param chosen [String, nil]
403
+ # @param tokens [RequestTokens]
351
404
  # @return [void]
352
405
  # @raise [ClaimsMismatchError]
353
- def enforce_claims_consistency!(env, chosen)
354
- res = ConsistencyChecks.enforce!(env, chosen, @config.enforce_claims_consistency, @config.auth_request_headers)
406
+ def enforce_claims_consistency!(env, tokens)
407
+ res = ConsistencyChecks.enforce_with_claims(
408
+ env, tokens.decoded_payload,
409
+ @config.enforce_claims_consistency, @config.auth_request_headers
410
+ )
355
411
  return unless res.is_a?(Array) && res.first == :error
356
412
 
357
413
  field = res.last
@@ -372,18 +428,19 @@ module Verikloak
372
428
  end
373
429
 
374
430
  # Set normalized Authorization header and emit success log.
431
+ # Reuses pre-decoded token payload/header from {RequestTokens} to avoid
432
+ # redundant JWT parsing.
375
433
  #
376
434
  # @param env [Hash]
377
- # @param chosen [String, nil]
378
- # @param auth_token [String, nil]
379
- # @param fwd_token [String, nil]
435
+ # @param tokens [RequestTokens]
380
436
  # @return [void]
381
- def normalize_authorization!(env, chosen, auth_token, fwd_token)
382
- return unless chosen
437
+ def normalize_authorization!(env, tokens)
438
+ return unless tokens.chosen
383
439
 
384
- ForwardedToken.set_authorization!(env, chosen)
385
- source = HeaderGuardSanitizer.token_source(@config.prefer_forwarded, auth_token, fwd_token)
386
- HeaderGuardSanitizer.log_event(env, @config, :ok, source: source, **HeaderGuardSanitizer.token_tags(chosen))
440
+ ForwardedToken.set_authorization!(env, tokens.chosen)
441
+ source = HeaderGuardSanitizer.token_source(@config.prefer_forwarded, tokens.auth, tokens.forwarded)
442
+ tags = HeaderGuardSanitizer.token_tags_from_decoded(tokens.decoded_payload, tokens.decoded_header)
443
+ HeaderGuardSanitizer.log_event(env, @config, :ok, source: source, **tags)
387
444
  end
388
445
 
389
446
  # Resolve the first env header from which to source a bearer token.
@@ -54,11 +54,15 @@ module Verikloak
54
54
  end
55
55
 
56
56
  # Parse string to IPAddr or nil on failure.
57
+ # IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) are normalised to
58
+ # native IPv4 so that CIDR checks against plain IPv4 ranges succeed.
57
59
  #
58
60
  # @param str [String]
59
61
  # @return [IPAddr, nil]
60
62
  def ip_or_nil(str)
61
- IPAddr.new(str)
63
+ addr = IPAddr.new(str)
64
+ addr = addr.native if addr.respond_to?(:native)
65
+ addr
62
66
  rescue StandardError
63
67
  nil
64
68
  end
@@ -5,6 +5,6 @@
5
5
  # @return [String]
6
6
  module Verikloak
7
7
  module BFF
8
- VERSION = '0.3.0'
8
+ VERSION = '0.4.0'
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - taiyaky
@@ -55,7 +55,7 @@ dependencies:
55
55
  requirements:
56
56
  - - ">="
57
57
  - !ruby/object:Gem::Version
58
- version: 0.3.0
58
+ version: 0.4.0
59
59
  - - "<"
60
60
  - !ruby/object:Gem::Version
61
61
  version: 1.0.0
@@ -65,7 +65,7 @@ dependencies:
65
65
  requirements:
66
66
  - - ">="
67
67
  - !ruby/object:Gem::Version
68
- version: 0.3.0
68
+ version: 0.4.0
69
69
  - - "<"
70
70
  - !ruby/object:Gem::Version
71
71
  version: 1.0.0
@@ -101,7 +101,7 @@ metadata:
101
101
  source_code_uri: https://github.com/taiyaky/verikloak-bff
102
102
  changelog_uri: https://github.com/taiyaky/verikloak-bff/blob/main/CHANGELOG.md
103
103
  bug_tracker_uri: https://github.com/taiyaky/verikloak-bff/issues
104
- documentation_uri: https://rubydoc.info/gems/verikloak-bff/0.3.0
104
+ documentation_uri: https://rubydoc.info/gems/verikloak-bff/0.4.0
105
105
  rubygems_mfa_required: 'true'
106
106
  rdoc_options: []
107
107
  require_paths: