verikloak-bff 0.1.1 → 0.2.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 +20 -0
- data/README.md +10 -2
- data/lib/verikloak/bff/configuration.rb +43 -6
- data/lib/verikloak/bff/consistency_checks.rb +4 -3
- data/lib/verikloak/bff/errors.rb +15 -0
- data/lib/verikloak/bff/forwarded_token.rb +6 -0
- data/lib/verikloak/bff/header_guard.rb +110 -38
- data/lib/verikloak/bff/proxy_trust.rb +4 -0
- data/lib/verikloak/bff/version.rb +1 -1
- data/lib/verikloak/header_sources.rb +60 -0
- metadata +7 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 91439a22efdb87206de07fbff74c284cba33a05cb1c7392070c0b9f7c837734f
|
|
4
|
+
data.tar.gz: be08bb99047e72502cad67dbea7c41b916aef6d49fb103e3dbf53ab79082f6db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 135054bc3b1556597924c843a834d87c46c291da4d880ed63ef611cafb9705984694062f64d25a08f8d3f932f304cfb7277c4d25f25dfb3f35ad3d83f0768e47
|
|
7
|
+
data.tar.gz: 3358b1f9f3114e9a00a7d4edd283e5b2c81dd6166ec1a5718a581f5f83a7c1cf75d27f300da01d5dc25763f4569a50be2393203130c5a67adb00ffb1a42fc1d9
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2025-09-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `Verikloak::HeaderSources` module for shared header normalization (consumable by verikloak-rails and other adapters).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- `Configuration#token_header_priority=` now normalizes and deduplicates entries, reusing the shared helper and ignoring `HTTP_AUTHORIZATION` automatically.
|
|
17
|
+
- `forwarded_header_name` assignments trigger re-normalization of token priority lists to keep middleware aligned across gems.
|
|
18
|
+
|
|
19
|
+
## [0.1.2] - 2025-09-21
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- Configuration option `claims_consistency_mode` supporting `:log_only` so deployments can record mismatches without rejecting requests.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- Sanitize log payload strings (including JWT tags) before invoking hooks or emitting to loggers to mitigate log forging attempts.
|
|
26
|
+
|
|
27
|
+
### Documentation
|
|
28
|
+
- Document trusted proxy hygiene, sanitized logging hooks, and the new log-only mode in the README and Rails guide.
|
|
29
|
+
|
|
10
30
|
## [0.1.1] - 2025-09-15
|
|
11
31
|
|
|
12
32
|
### Changed
|
data/README.md
CHANGED
|
@@ -47,12 +47,13 @@ See `examples/rack.ru` for a tiny Rack app demo.
|
|
|
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] | `['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
|
|
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. Values are normalized via `Verikloak::HeaderSources`; `HTTP_AUTHORIZATION` is ignored as a source. |
|
|
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'`
|
|
@@ -78,12 +82,16 @@ For full reverse proxy examples (Nginx auth_request / oauth2-proxy), see [docs/r
|
|
|
78
82
|
- Authorization seeding from priority headers
|
|
79
83
|
- When no token is chosen and `HTTP_AUTHORIZATION` is empty, the middleware consults `token_header_priority` to seed Authorization.
|
|
80
84
|
- `HTTP_AUTHORIZATION` itself is never used as a source; forwarded headers are considered only from trusted peers.
|
|
85
|
+
- Other gems can `require 'verikloak/header_sources'` to reuse the same normalization helpers when sharing configuration defaults.
|
|
81
86
|
|
|
82
87
|
- Observability helpers
|
|
83
88
|
- 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`.
|
|
89
|
+
- 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
90
|
- 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
91
|
|
|
92
|
+
- Claims consistency modes
|
|
93
|
+
- 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.
|
|
94
|
+
|
|
87
95
|
## Development (for contributors)
|
|
88
96
|
Clone and install dependencies:
|
|
89
97
|
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
# @!attribute [rw] logger
|
|
23
23
|
# @return [Logger, nil] optional logger for audit tags
|
|
24
24
|
|
|
25
|
+
require 'verikloak/header_sources'
|
|
26
|
+
|
|
25
27
|
module Verikloak
|
|
26
28
|
module BFF
|
|
27
29
|
# Configuration for Verikloak::BFF middleware (trusted proxies, header policies, logging, etc.).
|
|
@@ -29,24 +31,31 @@ module Verikloak
|
|
|
29
31
|
attr_accessor :trusted_proxies, :prefer_forwarded, :require_forwarded_header,
|
|
30
32
|
:enforce_header_consistency, :enforce_claims_consistency,
|
|
31
33
|
:strip_suspicious_headers, :xff_strategy, :clock_skew_leeway,
|
|
32
|
-
:logger, :
|
|
33
|
-
:
|
|
34
|
+
:logger, :peer_preference, :auth_request_headers, :log_with,
|
|
35
|
+
:claims_consistency_mode
|
|
36
|
+
attr_reader :token_header_priority, :forwarded_header_name
|
|
34
37
|
|
|
35
38
|
# enforce_claims_consistency example:
|
|
36
39
|
# { email: :email, user: :sub, groups: :realm_roles }
|
|
40
|
+
#
|
|
41
|
+
# Initialize configuration with secure defaults for proxy trust, token
|
|
42
|
+
# handling, and logging behavior.
|
|
43
|
+
#
|
|
44
|
+
# @return [void]
|
|
37
45
|
def initialize
|
|
38
46
|
@trusted_proxies = []
|
|
39
47
|
@prefer_forwarded = true
|
|
40
48
|
@require_forwarded_header = false
|
|
41
49
|
@enforce_header_consistency = true
|
|
42
50
|
@enforce_claims_consistency = {}
|
|
51
|
+
@claims_consistency_mode = :enforce
|
|
43
52
|
@strip_suspicious_headers = true
|
|
44
53
|
@xff_strategy = :rightmost
|
|
45
54
|
@peer_preference = :remote_then_xff
|
|
46
55
|
@clock_skew_leeway = 30
|
|
47
56
|
@logger = nil
|
|
48
57
|
@log_with = nil
|
|
49
|
-
|
|
58
|
+
self.forwarded_header_name = Verikloak::HeaderSources::DEFAULT_FORWARDED_HEADER
|
|
50
59
|
@auth_request_headers = {
|
|
51
60
|
email: 'HTTP_X_AUTH_REQUEST_EMAIL',
|
|
52
61
|
user: 'HTTP_X_AUTH_REQUEST_USER',
|
|
@@ -54,9 +63,37 @@ module Verikloak
|
|
|
54
63
|
}
|
|
55
64
|
# When Authorization is empty and no chosen token exists, try these env headers (in order)
|
|
56
65
|
# to seed Authorization, similar to verikloak-rails behavior. HTTP_AUTHORIZATION is always ignored as a source.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
self.token_header_priority = Verikloak::HeaderSources.default_priority(forwarded_header: @forwarded_header_name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Override forwarded header name while re-normalizing the token priority list.
|
|
70
|
+
# When the forwarded header changes, downstream priority normalization must
|
|
71
|
+
# be refreshed because the forwarded value participates in that list.
|
|
72
|
+
#
|
|
73
|
+
# @param header [String, Symbol]
|
|
74
|
+
# @return [void]
|
|
75
|
+
def forwarded_header_name=(header)
|
|
76
|
+
@forwarded_header_name = Verikloak::HeaderSources.normalize_env_key(header)
|
|
77
|
+
renormalize_token_priority!
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Assign token header priority list using shared normalization logic.
|
|
81
|
+
#
|
|
82
|
+
# @param priority [Array<String, Symbol>, String, Symbol, nil]
|
|
83
|
+
# @return [void]
|
|
84
|
+
def token_header_priority=(priority)
|
|
85
|
+
normalized, = Verikloak::HeaderSources.normalize_priority(priority, forwarded_header: @forwarded_header_name)
|
|
86
|
+
@token_header_priority = normalized
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Re-apply token header normalization so it reflects the current forwarded header.
|
|
92
|
+
#
|
|
93
|
+
# @return [void]
|
|
94
|
+
def renormalize_token_priority!
|
|
95
|
+
# Refresh the normalized list so it reflects the new forwarded header name.
|
|
96
|
+
self.token_header_priority = @token_header_priority
|
|
60
97
|
end
|
|
61
98
|
end
|
|
62
99
|
end
|
|
@@ -15,12 +15,11 @@ module Verikloak
|
|
|
15
15
|
module ConsistencyChecks
|
|
16
16
|
module_function
|
|
17
17
|
|
|
18
|
-
#
|
|
19
|
-
#
|
|
18
|
+
# Decode the JWT payload without verifying the signature. Intended only
|
|
19
|
+
# for lightweight claim comparisons; full verification occurs downstream.
|
|
20
20
|
#
|
|
21
21
|
# @param token [String, nil]
|
|
22
22
|
# @return [Hash] claims or empty hash on error
|
|
23
|
-
|
|
24
23
|
def decode_claims(token)
|
|
25
24
|
return {} unless token
|
|
26
25
|
return {} if token.bytesize > Constants::MAX_TOKEN_BYTES
|
|
@@ -36,6 +35,7 @@ module Verikloak
|
|
|
36
35
|
# @param env [Hash]
|
|
37
36
|
# @param token [String, nil]
|
|
38
37
|
# @param mapping [Hash] e.g., { email: :email, user: :sub, groups: :realm_roles }
|
|
38
|
+
# @param headers_map [Hash{Symbol=>String}, nil] overrides for header keys
|
|
39
39
|
# @return [true, Array(:error, Symbol)] true or error tuple with failing field
|
|
40
40
|
def enforce!(env, token, mapping, headers_map = nil)
|
|
41
41
|
return true if mapping.nil? || mapping.empty?
|
|
@@ -63,6 +63,7 @@ module Verikloak
|
|
|
63
63
|
#
|
|
64
64
|
# @param env [Hash]
|
|
65
65
|
# @param key [Symbol]
|
|
66
|
+
# @param headers_map [Hash{Symbol=>String}, nil]
|
|
66
67
|
# @return [String, nil]
|
|
67
68
|
def extract_header_value(env, key, headers_map = nil)
|
|
68
69
|
return env[headers_map[key]] if headers_map && headers_map[key]
|
data/lib/verikloak/bff/errors.rb
CHANGED
|
@@ -12,6 +12,12 @@ module Verikloak
|
|
|
12
12
|
class Error < StandardError
|
|
13
13
|
attr_reader :code, :http_status
|
|
14
14
|
|
|
15
|
+
# Build a BFF error with a stable code and HTTP status.
|
|
16
|
+
#
|
|
17
|
+
# @param message [String, nil]
|
|
18
|
+
# @param code [String]
|
|
19
|
+
# @param http_status [Integer]
|
|
20
|
+
# @return [void]
|
|
15
21
|
def initialize(message = nil, code: 'bff_error', http_status: 401)
|
|
16
22
|
super(message || code)
|
|
17
23
|
@code = code
|
|
@@ -21,6 +27,8 @@ module Verikloak
|
|
|
21
27
|
|
|
22
28
|
# Raised when a request did not pass through a trusted proxy peer.
|
|
23
29
|
class UntrustedProxyError < Error
|
|
30
|
+
# @param msg [String]
|
|
31
|
+
# @return [void]
|
|
24
32
|
def initialize(msg = 'request did not pass through a trusted proxy')
|
|
25
33
|
super(msg, code: 'untrusted_proxy', http_status: 401)
|
|
26
34
|
end
|
|
@@ -28,6 +36,8 @@ module Verikloak
|
|
|
28
36
|
|
|
29
37
|
# Raised when require_forwarded_header is enabled but the forwarded token is absent.
|
|
30
38
|
class MissingForwardedTokenError < Error
|
|
39
|
+
# @param msg [String]
|
|
40
|
+
# @return [void]
|
|
31
41
|
def initialize(msg = 'missing X-Forwarded-Access-Token')
|
|
32
42
|
super(msg, code: 'missing_forwarded_token', http_status: 401)
|
|
33
43
|
end
|
|
@@ -35,6 +45,8 @@ module Verikloak
|
|
|
35
45
|
|
|
36
46
|
# Raised when Authorization and X-Forwarded-Access-Token both exist and differ.
|
|
37
47
|
class HeaderMismatchError < Error
|
|
48
|
+
# @param msg [String]
|
|
49
|
+
# @return [void]
|
|
38
50
|
def initialize(msg = 'authorization and forwarded token mismatch')
|
|
39
51
|
super(msg, code: 'header_mismatch', http_status: 401)
|
|
40
52
|
end
|
|
@@ -42,6 +54,9 @@ module Verikloak
|
|
|
42
54
|
|
|
43
55
|
# Raised when X-Auth-Request-* headers conflict with JWT claims.
|
|
44
56
|
class ClaimsMismatchError < Error
|
|
57
|
+
# @param field [Symbol, String]
|
|
58
|
+
# @param msg [String, nil]
|
|
59
|
+
# @return [void]
|
|
45
60
|
def initialize(field, msg = nil)
|
|
46
61
|
super(msg || "claims/header mismatch for #{field}", code: 'claims_mismatch', http_status: 403)
|
|
47
62
|
end
|
|
@@ -16,6 +16,8 @@ module Verikloak
|
|
|
16
16
|
# Extract normalized tokens from the Rack env.
|
|
17
17
|
#
|
|
18
18
|
# @param env [Hash]
|
|
19
|
+
# @param forwarded_header_name [String] Rack env key for forwarded header
|
|
20
|
+
# @param auth_header_name [String] Rack env key for Authorization header
|
|
19
21
|
# @return [Array(String, String)] [auth_token, forwarded_token]
|
|
20
22
|
def extract(env, forwarded_header_name = FORWARDED_HEADER, auth_header_name = AUTH_HEADER)
|
|
21
23
|
fwd_raw = env[forwarded_header_name]
|
|
@@ -54,6 +56,7 @@ module Verikloak
|
|
|
54
56
|
# - Detects scheme case-insensitively
|
|
55
57
|
# - Inserts a missing space (e.g., 'BearerXYZ' => 'Bearer XYZ')
|
|
56
58
|
# - Collapses multiple spaces/tabs after the scheme to a single space
|
|
59
|
+
#
|
|
57
60
|
# @param token [String]
|
|
58
61
|
# @return [String]
|
|
59
62
|
def ensure_bearer(token)
|
|
@@ -78,6 +81,7 @@ module Verikloak
|
|
|
78
81
|
#
|
|
79
82
|
# @param env [Hash]
|
|
80
83
|
# @param token [String]
|
|
84
|
+
# @return [void]
|
|
81
85
|
def set_authorization!(env, token)
|
|
82
86
|
existing = env[AUTH_HEADER].to_s
|
|
83
87
|
# Overwrite only if Authorization is empty or not a valid Bearer value
|
|
@@ -98,6 +102,8 @@ module Verikloak
|
|
|
98
102
|
# downstream when not emitted by a trusted proxy.
|
|
99
103
|
#
|
|
100
104
|
# @param env [Hash]
|
|
105
|
+
# @param headers [Hash{Symbol=>String}, nil] explicit headers to strip
|
|
106
|
+
# @return [void]
|
|
101
107
|
def strip_suspicious!(env, headers = nil)
|
|
102
108
|
if headers.is_a?(Hash)
|
|
103
109
|
headers.each_value { |h| env.delete(h) }
|
|
@@ -15,6 +15,7 @@ require 'rack/utils'
|
|
|
15
15
|
require 'json'
|
|
16
16
|
require 'jwt'
|
|
17
17
|
require 'digest'
|
|
18
|
+
require 'verikloak/header_sources'
|
|
18
19
|
require 'verikloak/bff/configuration'
|
|
19
20
|
require 'verikloak/bff/errors'
|
|
20
21
|
require 'verikloak/bff/proxy_trust'
|
|
@@ -24,6 +25,83 @@ require 'verikloak/bff/constants'
|
|
|
24
25
|
|
|
25
26
|
module Verikloak
|
|
26
27
|
module BFF
|
|
28
|
+
# Internal helpers that sanitize tokens and log payloads for HeaderGuard.
|
|
29
|
+
module HeaderGuardSanitizer
|
|
30
|
+
LOG_CONTROL_CHARS = /[[:cntrl:]]/
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Generate sanitized token metadata suitable for structured logging without
|
|
35
|
+
# verifying the signature.
|
|
36
|
+
#
|
|
37
|
+
# @param token [String, nil]
|
|
38
|
+
# @return [Hash{Symbol=>Object}] sanitized tags keyed by JWT claim/header
|
|
39
|
+
def token_tags(token)
|
|
40
|
+
return {} unless token
|
|
41
|
+
|
|
42
|
+
payload, header = decode_unverified(token)
|
|
43
|
+
aud = payload['aud']
|
|
44
|
+
aud = aud.join(' ') if aud.is_a?(Array)
|
|
45
|
+
{
|
|
46
|
+
sub: sanitize_log_field(payload['sub']&.to_s),
|
|
47
|
+
iss: sanitize_log_field(payload['iss']&.to_s),
|
|
48
|
+
aud: sanitize_log_field(aud&.to_s),
|
|
49
|
+
kid: sanitize_log_field(header['kid']&.to_s)
|
|
50
|
+
}.compact
|
|
51
|
+
rescue StandardError
|
|
52
|
+
{}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Decode a JWT without verifying the signature while guarding against
|
|
56
|
+
# excessively large tokens.
|
|
57
|
+
#
|
|
58
|
+
# @param token [String, nil]
|
|
59
|
+
# @return [Array<Hash>] payload and header hashes
|
|
60
|
+
def decode_unverified(token)
|
|
61
|
+
return [{}, {}] if token.nil? || token.bytesize > Constants::MAX_TOKEN_BYTES
|
|
62
|
+
|
|
63
|
+
JWT.decode(token, nil, false)
|
|
64
|
+
rescue StandardError
|
|
65
|
+
[{}, {}]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Remove unsafe characters from a structured logging payload.
|
|
69
|
+
#
|
|
70
|
+
# @param payload [Hash]
|
|
71
|
+
# @return [Hash] sanitized payload suitable for logging
|
|
72
|
+
def sanitize_payload(payload)
|
|
73
|
+
payload.transform_values { |value| sanitize_log_field(value) }.compact
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Sanitize an individual value destined for logs, pruning empty results.
|
|
77
|
+
#
|
|
78
|
+
# @param value [Object]
|
|
79
|
+
# @return [Object, nil] sanitized value or nil when the result is empty
|
|
80
|
+
def sanitize_log_field(value)
|
|
81
|
+
case value
|
|
82
|
+
when nil
|
|
83
|
+
nil
|
|
84
|
+
when String
|
|
85
|
+
sanitized = sanitize_string(value)
|
|
86
|
+
sanitized.empty? ? nil : sanitized
|
|
87
|
+
when Array
|
|
88
|
+
sanitized = value.map { |item| item.is_a?(String) ? sanitize_string(item) : item }
|
|
89
|
+
sanitized.reject! { |item| item.nil? || (item.is_a?(String) && item.empty?) }
|
|
90
|
+
sanitized.empty? ? nil : sanitized
|
|
91
|
+
else
|
|
92
|
+
value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Remove control characters and invalid UTF-8 from a string.
|
|
97
|
+
#
|
|
98
|
+
# @param value [#to_s]
|
|
99
|
+
# @return [String]
|
|
100
|
+
def sanitize_string(value)
|
|
101
|
+
value.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '').gsub(LOG_CONTROL_CHARS, '')
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
27
105
|
# Rack middleware that enforces BFF boundary and header/claims consistency.
|
|
28
106
|
class HeaderGuard
|
|
29
107
|
# Accept both Rack 2 and Rack 3 builder call styles:
|
|
@@ -73,6 +151,7 @@ module Verikloak
|
|
|
73
151
|
# Apply per-instance configuration overrides.
|
|
74
152
|
#
|
|
75
153
|
# @param opts [Hash]
|
|
154
|
+
# @return [void]
|
|
76
155
|
def apply_overrides!(opts)
|
|
77
156
|
cfg = @config
|
|
78
157
|
opts.each do |k, v|
|
|
@@ -106,37 +185,6 @@ module Verikloak
|
|
|
106
185
|
end
|
|
107
186
|
end
|
|
108
187
|
|
|
109
|
-
# Extract selected JWT tags for audit logging (best-effort, unverified).
|
|
110
|
-
#
|
|
111
|
-
# @param token [String, nil]
|
|
112
|
-
# @return [Hash] subset of {sub, iss, aud, kid}
|
|
113
|
-
def token_tags(token)
|
|
114
|
-
return {} unless token
|
|
115
|
-
|
|
116
|
-
payload, header = decode_unverified(token)
|
|
117
|
-
{
|
|
118
|
-
sub: payload['sub'],
|
|
119
|
-
iss: payload['iss'],
|
|
120
|
-
aud: (payload['aud'].is_a?(Array) ? payload['aud'].join(' ') : payload['aud']).to_s,
|
|
121
|
-
kid: header['kid']
|
|
122
|
-
}.compact
|
|
123
|
-
rescue StandardError
|
|
124
|
-
{}
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Decode JWT header/payload without validation (for logging only).
|
|
128
|
-
#
|
|
129
|
-
# @param token [String]
|
|
130
|
-
# @return [Array(Hash, Hash)] [payload, header]
|
|
131
|
-
|
|
132
|
-
def decode_unverified(token)
|
|
133
|
-
return [{}, {}] if token.nil? || token.bytesize > Constants::MAX_TOKEN_BYTES
|
|
134
|
-
|
|
135
|
-
JWT.decode(token, nil, false)
|
|
136
|
-
rescue StandardError
|
|
137
|
-
[{}, {}]
|
|
138
|
-
end
|
|
139
|
-
|
|
140
188
|
# Extract request id for logging from common headers.
|
|
141
189
|
#
|
|
142
190
|
# @param env [Hash]
|
|
@@ -158,19 +206,21 @@ module Verikloak
|
|
|
158
206
|
# @param env [Hash]
|
|
159
207
|
# @param kind [Symbol] :ok, :mismatch, :claims_mismatch, :error
|
|
160
208
|
# @param attrs [Hash]
|
|
209
|
+
# @return [void]
|
|
161
210
|
def log_event(env, kind, **attrs)
|
|
162
211
|
lg = logger(env)
|
|
163
212
|
payload = { event: 'bff.header_guard', kind: kind, rid: request_id(env) }.merge(attrs).compact
|
|
213
|
+
sanitized = HeaderGuardSanitizer.sanitize_payload(payload)
|
|
164
214
|
if @config.log_with.respond_to?(:call)
|
|
165
215
|
begin
|
|
166
|
-
@config.log_with.call(
|
|
216
|
+
@config.log_with.call(sanitized)
|
|
167
217
|
rescue StandardError
|
|
168
218
|
# ignore log hook failures
|
|
169
219
|
end
|
|
170
220
|
end
|
|
171
221
|
return unless lg
|
|
172
222
|
|
|
173
|
-
msg =
|
|
223
|
+
msg = sanitized.map { |k, v| v.nil? || v.to_s.empty? ? nil : "#{k}=#{v}" }.compact.join(' ')
|
|
174
224
|
level = (kind == :ok ? :info : :warn)
|
|
175
225
|
lg.public_send(level, msg)
|
|
176
226
|
rescue StandardError
|
|
@@ -180,6 +230,7 @@ module Verikloak
|
|
|
180
230
|
# Raise when the request did not come through a trusted proxy.
|
|
181
231
|
#
|
|
182
232
|
# @param env [Hash]
|
|
233
|
+
# @raise [UntrustedProxyError]
|
|
183
234
|
def ensure_trusted_proxy!(env)
|
|
184
235
|
return if ProxyTrust.trusted?(env, @config.trusted_proxies, @config.xff_strategy)
|
|
185
236
|
|
|
@@ -189,6 +240,7 @@ module Verikloak
|
|
|
189
240
|
# Enforce presence of forwarded token when required.
|
|
190
241
|
#
|
|
191
242
|
# @param fwd_token [String, nil]
|
|
243
|
+
# @raise [MissingForwardedTokenError]
|
|
192
244
|
def ensure_forwarded_if_required!(fwd_token)
|
|
193
245
|
return unless @config.require_forwarded_header
|
|
194
246
|
raise MissingForwardedTokenError if fwd_token.nil? || fwd_token.to_s.strip.empty?
|
|
@@ -199,6 +251,7 @@ module Verikloak
|
|
|
199
251
|
# @param env [Hash]
|
|
200
252
|
# @param auth_token [String, nil]
|
|
201
253
|
# @param fwd_token [String, nil]
|
|
254
|
+
# @raise [HeaderMismatchError]
|
|
202
255
|
def enforce_header_consistency!(env, auth_token, fwd_token)
|
|
203
256
|
return unless @config.enforce_header_consistency
|
|
204
257
|
return unless auth_token && fwd_token
|
|
@@ -215,26 +268,41 @@ module Verikloak
|
|
|
215
268
|
#
|
|
216
269
|
# @param env [Hash]
|
|
217
270
|
# @param chosen [String, nil]
|
|
271
|
+
# @return [void]
|
|
272
|
+
# @raise [ClaimsMismatchError]
|
|
218
273
|
def enforce_claims_consistency!(env, chosen)
|
|
219
274
|
res = ConsistencyChecks.enforce!(env, chosen, @config.enforce_claims_consistency, @config.auth_request_headers)
|
|
220
275
|
return unless res.is_a?(Array) && res.first == :error
|
|
221
276
|
|
|
222
277
|
field = res.last
|
|
223
278
|
log_event(env, :claims_mismatch, field: field.to_s)
|
|
279
|
+
return if claims_consistency_log_only?
|
|
280
|
+
|
|
224
281
|
raise ClaimsMismatchError, field
|
|
225
282
|
end
|
|
226
283
|
|
|
284
|
+
# Determine whether claims mismatches should only be logged.
|
|
285
|
+
#
|
|
286
|
+
# @return [Boolean]
|
|
287
|
+
def claims_consistency_log_only?
|
|
288
|
+
mode = @config.claims_consistency_mode || :enforce
|
|
289
|
+
mode = mode.to_sym if mode.is_a?(String)
|
|
290
|
+
mode = :enforce unless %i[enforce log_only].include?(mode)
|
|
291
|
+
mode == :log_only
|
|
292
|
+
end
|
|
293
|
+
|
|
227
294
|
# Set normalized Authorization header and emit success log.
|
|
228
295
|
#
|
|
229
296
|
# @param env [Hash]
|
|
230
297
|
# @param chosen [String, nil]
|
|
231
298
|
# @param auth_token [String, nil]
|
|
232
299
|
# @param fwd_token [String, nil]
|
|
300
|
+
# @return [void]
|
|
233
301
|
def normalize_authorization!(env, chosen, auth_token, fwd_token)
|
|
234
302
|
return unless chosen
|
|
235
303
|
|
|
236
304
|
ForwardedToken.set_authorization!(env, chosen)
|
|
237
|
-
log_event(env, :ok, source: token_source(auth_token, fwd_token), **token_tags(chosen))
|
|
305
|
+
log_event(env, :ok, source: token_source(auth_token, fwd_token), **HeaderGuardSanitizer.token_tags(chosen))
|
|
238
306
|
end
|
|
239
307
|
|
|
240
308
|
# Build a minimal RFC6750-style error response.
|
|
@@ -252,19 +320,21 @@ module Verikloak
|
|
|
252
320
|
|
|
253
321
|
# Resolve the first env header from which to source a bearer token.
|
|
254
322
|
# Forwarded is considered only when the peer is trusted; HTTP_AUTHORIZATION is never a source.
|
|
323
|
+
#
|
|
255
324
|
# @param env [Hash]
|
|
256
325
|
# @return [String, nil]
|
|
257
326
|
def resolve_first_token_header(env)
|
|
258
327
|
candidates = Array(@config.token_header_priority).dup
|
|
259
|
-
candidates -= [
|
|
260
|
-
fwd_key =
|
|
328
|
+
candidates -= [Verikloak::HeaderSources::AUTHORIZATION_HEADER]
|
|
329
|
+
fwd_key = @config.forwarded_header_name || Verikloak::HeaderSources::DEFAULT_FORWARDED_HEADER
|
|
261
330
|
if candidates.include?(fwd_key) && !ProxyTrust.from_trusted_proxy?(env, @config.trusted_proxies)
|
|
262
331
|
candidates -= [fwd_key]
|
|
263
332
|
end
|
|
264
333
|
candidates.find { |k| (v = env[k]) && !v.to_s.empty? }
|
|
265
334
|
end
|
|
266
335
|
|
|
267
|
-
# Seed Authorization from priority headers if nothing chosen and empty Authorization
|
|
336
|
+
# Seed Authorization from priority headers if nothing chosen and empty Authorization.
|
|
337
|
+
#
|
|
268
338
|
# @param env [Hash]
|
|
269
339
|
# @param chosen [String, nil]
|
|
270
340
|
# @return [String, nil] possibly updated chosen token
|
|
@@ -280,9 +350,11 @@ module Verikloak
|
|
|
280
350
|
chosen
|
|
281
351
|
end
|
|
282
352
|
|
|
283
|
-
# Expose hints to downstream
|
|
353
|
+
# Expose hints to downstream middleware or apps.
|
|
354
|
+
#
|
|
284
355
|
# @param env [Hash]
|
|
285
356
|
# @param chosen [String, nil]
|
|
357
|
+
# @return [void]
|
|
286
358
|
def expose_env_hints(env, chosen)
|
|
287
359
|
env['verikloak.bff.token'] = chosen if chosen
|
|
288
360
|
env['verikloak.bff.selected_peer'] =
|
|
@@ -53,6 +53,7 @@ module Verikloak
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
# Return the selected peer IP according to preference and strategy.
|
|
56
|
+
#
|
|
56
57
|
# @param env [Hash]
|
|
57
58
|
# @param preference [Symbol] :remote_then_xff or :xff_only
|
|
58
59
|
# @param strategy [Symbol] :rightmost or :leftmost
|
|
@@ -70,6 +71,7 @@ module Verikloak
|
|
|
70
71
|
end
|
|
71
72
|
|
|
72
73
|
# Parse string to IPAddr or nil on failure.
|
|
74
|
+
#
|
|
73
75
|
# @param str [String]
|
|
74
76
|
# @return [IPAddr, nil]
|
|
75
77
|
def ip_or_nil(str)
|
|
@@ -79,6 +81,7 @@ module Verikloak
|
|
|
79
81
|
end
|
|
80
82
|
|
|
81
83
|
# Check whether a single rule trusts the selected remote.
|
|
84
|
+
#
|
|
82
85
|
# @param rule [String, Regexp, Proc]
|
|
83
86
|
# @param remote [String]
|
|
84
87
|
# @param remote_ip [IPAddr, nil]
|
|
@@ -104,6 +107,7 @@ module Verikloak
|
|
|
104
107
|
|
|
105
108
|
# Determine if the request originates from a trusted proxy subnet.
|
|
106
109
|
# Rails-aligned behavior: prefer REMOTE_ADDR, fallback to nearest (rightmost) X-Forwarded-For.
|
|
110
|
+
#
|
|
107
111
|
# @param env [Hash]
|
|
108
112
|
# @param trusted [Array<String, Regexp, Proc>, nil]
|
|
109
113
|
# @return [Boolean]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Helpers shared across verikloak middleware for normalizing Rack env header
|
|
4
|
+
# names and token source priority lists. Extracted to allow other gems (such as
|
|
5
|
+
# verikloak-rails) to consume the same normalization logic.
|
|
6
|
+
module Verikloak
|
|
7
|
+
# Provides normalization helpers for Rack env header keys and token priority lists.
|
|
8
|
+
module HeaderSources
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
DEFAULT_FORWARDED_HEADER = 'HTTP_X_FORWARDED_ACCESS_TOKEN'
|
|
12
|
+
AUTHORIZATION_HEADER = 'HTTP_AUTHORIZATION'
|
|
13
|
+
|
|
14
|
+
# Normalize a Rack env header key, accepting symbols, mixed case, or dash
|
|
15
|
+
# separated names and returning an upper-case HTTP_* variant.
|
|
16
|
+
#
|
|
17
|
+
# @param header [String, Symbol, nil]
|
|
18
|
+
# @return [String] normalized env key or empty string when blank
|
|
19
|
+
def normalize_env_key(header)
|
|
20
|
+
key = header.to_s.strip
|
|
21
|
+
return '' if key.empty?
|
|
22
|
+
|
|
23
|
+
key = key.tr('-', '_').upcase
|
|
24
|
+
key = "HTTP_#{key}" unless key.start_with?('HTTP_')
|
|
25
|
+
key
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Normalize a token priority list by stripping blanks, rejecting
|
|
29
|
+
# Authorization as a source, and deduplicating entries while preserving
|
|
30
|
+
# order.
|
|
31
|
+
#
|
|
32
|
+
# @param priority [Array<String, Symbol>, String, Symbol, nil]
|
|
33
|
+
# @param forwarded_header [String, Symbol]
|
|
34
|
+
# @param drop_authorization [Boolean]
|
|
35
|
+
# @return [Array(Array<String>, String)] normalized priority and forwarded key
|
|
36
|
+
def normalize_priority(priority, forwarded_header: DEFAULT_FORWARDED_HEADER, drop_authorization: true)
|
|
37
|
+
forwarded_env = normalize_env_key(forwarded_header)
|
|
38
|
+
items = Array(priority).flatten
|
|
39
|
+
|
|
40
|
+
normalized = items.map { |value| normalize_env_key(value) }.reject(&:empty?)
|
|
41
|
+
normalized = [forwarded_env] if normalized.empty?
|
|
42
|
+
normalized = normalized.reject { |key| drop_authorization && key == AUTHORIZATION_HEADER }
|
|
43
|
+
|
|
44
|
+
deduped = []
|
|
45
|
+
normalized.each do |key|
|
|
46
|
+
deduped << key unless deduped.include?(key)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
[deduped.freeze, forwarded_env]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Default priority list using the provided forwarded header name.
|
|
53
|
+
#
|
|
54
|
+
# @param forwarded_header [String, Symbol]
|
|
55
|
+
# @return [Array<String>] normalized default priority
|
|
56
|
+
def default_priority(forwarded_header: DEFAULT_FORWARDED_HEADER)
|
|
57
|
+
normalize_priority([forwarded_header], forwarded_header: forwarded_header).first
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- taiyaky
|
|
@@ -55,20 +55,20 @@ dependencies:
|
|
|
55
55
|
requirements:
|
|
56
56
|
- - ">="
|
|
57
57
|
- !ruby/object:Gem::Version
|
|
58
|
-
version: 0.1.
|
|
58
|
+
version: 0.1.5
|
|
59
59
|
- - "<"
|
|
60
60
|
- !ruby/object:Gem::Version
|
|
61
|
-
version:
|
|
61
|
+
version: 1.0.0
|
|
62
62
|
type: :runtime
|
|
63
63
|
prerelease: false
|
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
65
|
requirements:
|
|
66
66
|
- - ">="
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
|
-
version: 0.1.
|
|
68
|
+
version: 0.1.5
|
|
69
69
|
- - "<"
|
|
70
70
|
- !ruby/object:Gem::Version
|
|
71
|
-
version:
|
|
71
|
+
version: 1.0.0
|
|
72
72
|
description: Framework-agnostic Rack middleware that normalizes forwarded tokens,
|
|
73
73
|
enforces trust boundaries, and checks header/claims consistency before verikloak.
|
|
74
74
|
executables: []
|
|
@@ -88,6 +88,7 @@ files:
|
|
|
88
88
|
- lib/verikloak/bff/header_guard.rb
|
|
89
89
|
- lib/verikloak/bff/proxy_trust.rb
|
|
90
90
|
- lib/verikloak/bff/version.rb
|
|
91
|
+
- lib/verikloak/header_sources.rb
|
|
91
92
|
homepage: https://github.com/taiyaky/verikloak-bff
|
|
92
93
|
licenses:
|
|
93
94
|
- MIT
|
|
@@ -95,7 +96,7 @@ metadata:
|
|
|
95
96
|
source_code_uri: https://github.com/taiyaky/verikloak-bff
|
|
96
97
|
changelog_uri: https://github.com/taiyaky/verikloak-bff/blob/main/CHANGELOG.md
|
|
97
98
|
bug_tracker_uri: https://github.com/taiyaky/verikloak-bff/issues
|
|
98
|
-
documentation_uri: https://rubydoc.info/gems/verikloak-bff/0.
|
|
99
|
+
documentation_uri: https://rubydoc.info/gems/verikloak-bff/0.2.0
|
|
99
100
|
rubygems_mfa_required: 'true'
|
|
100
101
|
rdoc_options: []
|
|
101
102
|
require_paths:
|