verikloak 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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +16 -3
- data/lib/verikloak/discovery.rb +111 -32
- data/lib/verikloak/error_response.rb +52 -0
- data/lib/verikloak/errors.rb +8 -2
- data/lib/verikloak/jwks_cache.rb +17 -3
- data/lib/verikloak/middleware.rb +18 -88
- data/lib/verikloak/skip_path_matcher.rb +82 -0
- data/lib/verikloak/version.rb +1 -1
- metadata +10 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a8fea8bb10499fcd42f05ecc1eadda3008c5215b1907807351c9b4a8a4a420a4
|
|
4
|
+
data.tar.gz: '05476942149fcdf5f5cbe9605524c8a8ea79359f1b9533b623d08e7b130a0290'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3133b44cd8ada0217916d82b5b725ed5bd50c445c727969478b6af67cb712de8247d7a124585f5ca2ee763ec69171f3c65b70be60fdb91c16f6d5467b2853b9a
|
|
7
|
+
data.tar.gz: c790ce4825f32e2d17187b2de7e3564e765274517b4ca59f0fbd8b61a7fbe22edac4e9f72cee91a92ec7e9b2bc6529da74e4d9b3d22abf3d068a3bff2f78a4df
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ 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
|
+
- **CVE-2026-25765**: Bump `faraday` runtime dependency to `>= 2.14.1`
|
|
14
|
+
- **Header injection**: Sanitize CR/LF characters in `WWW-Authenticate` header values via `Verikloak::ErrorResponse`
|
|
15
|
+
- **JWT size limit**: Reject tokens exceeding 8 KB (`MAX_TOKEN_BYTES = 8192`) to mitigate denial-of-service
|
|
16
|
+
- **HTTPS enforcement**: `Discovery` and `JwksCache` now reject `http://` URLs unless `allow_http: true` is explicitly set
|
|
17
|
+
- **HTTPS redirect enforcement**: Redirect targets during OIDC discovery are now scheme-checked — plain HTTP redirects are blocked unless `allow_http: true`, and non-HTTP(S) schemes (e.g. `ftp://`) are always rejected
|
|
18
|
+
- **SSRF protection**: Redirect targets in OIDC discovery are validated against private IP ranges (RFC 1918, loopback, link-local)
|
|
19
|
+
- **IPv4-mapped IPv6 SSRF hardening**: IPv4-mapped IPv6 addresses (e.g. `::ffff:127.0.0.1`) are normalised to native IPv4 before private-range checks, preventing bypass via mapped addresses
|
|
20
|
+
- **URL normalisation**: `Discovery` and `JwksCache` now strip leading/trailing whitespace from URLs during initialisation, ensuring the validated URL matches what is used for HTTP requests
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- `Verikloak::ErrorResponse` — shared RFC 6750-compliant JSON error response builder
|
|
24
|
+
- `Verikloak::SkipPathMatcher` — extracted reusable skip-path matching module
|
|
25
|
+
- `Verikloak::Error#http_status` attribute for structured error hierarchy
|
|
26
|
+
- `allow_http:` option on `Middleware`, `Discovery`, and `JwksCache`
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **BREAKING**: Minimum `faraday` version raised to `2.14.1`
|
|
30
|
+
- **BREAKING**: Minimum `faraday-retry` version raised to `2.4.0`
|
|
31
|
+
- Runtime dependency `json` added (`~> 2.18`)
|
|
32
|
+
- Dev dependency `rspec` pinned to `~> 3.13`, `rubocop-rspec` pinned to `~> 3.9`, `webmock` pinned to `~> 3.26`
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
10
36
|
## [0.3.0] - 2025-12-31
|
|
11
37
|
|
|
12
38
|
### Added
|
data/README.md
CHANGED
|
@@ -17,6 +17,12 @@ Verikloak is a plug-and-play solution for Ruby (especially Rails API) apps that
|
|
|
17
17
|
- `aud`, `iss`, `exp`, `nbf` claim validation
|
|
18
18
|
- Rails/Rack middleware support
|
|
19
19
|
- Faraday-based customizable HTTP layer
|
|
20
|
+
- HTTPS enforcement for Discovery and JWKs endpoints (with `allow_http:` escape hatch for development)
|
|
21
|
+
- SSRF protection — redirect targets validated against private IP ranges (including IPv4-mapped IPv6 normalisation)
|
|
22
|
+
- HTTPS redirect enforcement — redirect targets are scheme-checked to prevent HTTPS→HTTP downgrade
|
|
23
|
+
- JWT size limit (8 KB) to mitigate denial-of-service via oversized tokens
|
|
24
|
+
- Header injection prevention in `WWW-Authenticate` responses
|
|
25
|
+
- URL normalisation — leading/trailing whitespace stripped from discovery and JWKs URLs
|
|
20
26
|
|
|
21
27
|
## Installation
|
|
22
28
|
|
|
@@ -67,7 +73,8 @@ config.middleware.use Verikloak::Middleware,
|
|
|
67
73
|
token_env_key: "rack.session.token", # Custom token storage key
|
|
68
74
|
user_env_key: "rack.session.claims", # Custom user claims storage key
|
|
69
75
|
realm: "my-api", # Custom realm for WWW-Authenticate header
|
|
70
|
-
logger: Rails.logger
|
|
76
|
+
logger: Rails.logger, # Logger for internal errors
|
|
77
|
+
allow_http: false # Set true only for local development (disables HTTPS enforcement)
|
|
71
78
|
```
|
|
72
79
|
|
|
73
80
|
**Additional middleware options:**
|
|
@@ -215,11 +222,14 @@ For a full list of error cases and detailed explanations, please see the [ERRORS
|
|
|
215
222
|
| `jwks_cache_miss` | 503 Service Unavailable | JWKs cache is empty (e.g., 304 Not Modified without prior cache) |
|
|
216
223
|
| `discovery_metadata_fetch_failed` | 503 Service Unavailable | Failed to fetch OIDC discovery document |
|
|
217
224
|
| `discovery_metadata_invalid` | 503 Service Unavailable | Failed to parse OIDC discovery document |
|
|
218
|
-
| `discovery_redirect_error` | 503 Service Unavailable | Discovery
|
|
225
|
+
| `discovery_redirect_error` | 503 Service Unavailable | Discovery redirect error: missing/invalid Location header, redirect target resolves to a private IP (SSRF protection), redirect uses non-HTTPS scheme, or unsupported scheme |
|
|
226
|
+
| `insecure_discovery_url` | 503 Service Unavailable | Discovery URL uses `http://` and `allow_http: true` is not set |
|
|
227
|
+
| `insecure_jwks_uri` | 503 Service Unavailable | JWKs URI uses `http://` and `allow_http: true` is not set |
|
|
219
228
|
| `internal_server_error` | 500 Internal Server Error | Unexpected internal error (catch-all) |
|
|
220
229
|
|
|
221
230
|
> **Note:** The `decode_with_public_key` method ensures consistent error codes for all JWT verification failures.
|
|
222
|
-
> It may raise `invalid_signature`, `unsupported_algorithm`, `expired_token`, `invalid_issuer`, `invalid_audience`, or `not_yet_valid` depending on the verification outcome.
|
|
231
|
+
> It may raise `invalid_signature`, `unsupported_algorithm`, `expired_token`, `invalid_issuer`, `invalid_audience`, or `not_yet_valid` depending on the verification outcome.
|
|
232
|
+
> Additionally, tokens exceeding 8 KB are rejected with `invalid_token` before decoding to mitigate denial-of-service.
|
|
223
233
|
|
|
224
234
|
## Configuration Options
|
|
225
235
|
|
|
@@ -239,6 +249,7 @@ For a full list of error cases and detailed explanations, please see the [ERRORS
|
|
|
239
249
|
| `user_env_key` | No | Rack env key for decoded claims. Defaults to `verikloak.user`. |
|
|
240
250
|
| `realm` | No | Value used in the `WWW-Authenticate` header. Defaults to `verikloak`. |
|
|
241
251
|
| `logger` | No | Logger for unexpected internal failures (responds to `error`, optionally `debug`). |
|
|
252
|
+
| `allow_http` | No | When `false` (default), `Discovery` and `JwksCache` reject plain `http://` URLs. Set `true` **only** for local development against a non-TLS Keycloak instance. |
|
|
242
253
|
|
|
243
254
|
#### Option: `skip_paths`
|
|
244
255
|
|
|
@@ -373,6 +384,8 @@ Verikloak consists of modular components, each with a focused responsibility:
|
|
|
373
384
|
| `HTTP` | Provides shared Faraday connection with retries/timeouts | Network layer|
|
|
374
385
|
| `JwksCache` | Fetches & caches JWKs public keys (with ETag) | Cache layer |
|
|
375
386
|
| `TokenDecoder` | Decodes and verifies JWTs (signature, exp, nbf, iss, aud) | Crypto layer |
|
|
387
|
+
| `ErrorResponse` | RFC 6750 compliant JSON error response builder | Core layer |
|
|
388
|
+
| `SkipPathMatcher`| Reusable skip-path normalization and matching mixin | Core layer |
|
|
376
389
|
| `Errors` | Centralized error hierarchy | Core layer |
|
|
377
390
|
|
|
378
391
|
This separation enables better testing, modular reuse, and flexibility.
|
data/lib/verikloak/discovery.rb
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'faraday'
|
|
4
|
+
require 'ipaddr'
|
|
4
5
|
require 'json'
|
|
6
|
+
require 'resolv'
|
|
5
7
|
require 'uri'
|
|
6
8
|
|
|
7
9
|
require 'verikloak/http'
|
|
8
10
|
|
|
9
11
|
module Verikloak
|
|
12
|
+
# Private IP ranges that must not be targets of redirects (SSRF protection).
|
|
13
|
+
# Includes RFC 1918, loopback, link-local, and IPv6 equivalents.
|
|
14
|
+
# @api private
|
|
15
|
+
PRIVATE_IP_RANGES = [
|
|
16
|
+
IPAddr.new('10.0.0.0/8'),
|
|
17
|
+
IPAddr.new('172.16.0.0/12'),
|
|
18
|
+
IPAddr.new('192.168.0.0/16'),
|
|
19
|
+
IPAddr.new('127.0.0.0/8'),
|
|
20
|
+
IPAddr.new('169.254.0.0/16'),
|
|
21
|
+
IPAddr.new('0.0.0.0/8'),
|
|
22
|
+
IPAddr.new('::1/128'),
|
|
23
|
+
IPAddr.new('fc00::/7'),
|
|
24
|
+
IPAddr.new('fe80::/10')
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
10
27
|
# Fetches and caches the OpenID Connect Discovery document.
|
|
11
28
|
#
|
|
12
29
|
# This class retrieves the discovery metadata from an OpenID Connect provider
|
|
@@ -43,16 +60,27 @@ module Verikloak
|
|
|
43
60
|
# @param discovery_url [String] The full URL to the `.well-known/openid-configuration`.
|
|
44
61
|
# @param connection [Faraday::Connection] Optional Faraday client (for DI/tests).
|
|
45
62
|
# @param cache_ttl [Integer] Cache TTL in seconds (default: 3600).
|
|
63
|
+
# @param allow_http [Boolean] When false (default), raises on plain HTTP URLs. Set true for local development only.
|
|
46
64
|
# @raise [DiscoveryError] when `discovery_url` is not a valid HTTP(S) URL
|
|
47
|
-
def initialize(discovery_url:, connection: Verikloak::HTTP.default_connection, cache_ttl: 3600)
|
|
48
|
-
|
|
65
|
+
def initialize(discovery_url:, connection: Verikloak::HTTP.default_connection, cache_ttl: 3600, allow_http: false)
|
|
66
|
+
normalized_url = discovery_url.is_a?(String) ? discovery_url.strip : discovery_url
|
|
67
|
+
|
|
68
|
+
unless normalized_url.is_a?(String) && normalized_url.match?(%r{^https?://})
|
|
49
69
|
raise DiscoveryError.new('Invalid discovery URL: must be a non-empty HTTP(S) URL',
|
|
50
70
|
code: 'invalid_discovery_url')
|
|
51
71
|
end
|
|
52
72
|
|
|
53
|
-
|
|
73
|
+
unless allow_http || normalized_url.start_with?('https://')
|
|
74
|
+
raise DiscoveryError.new(
|
|
75
|
+
'Discovery URL must use HTTPS. Set allow_http: true to permit plain HTTP (development only).',
|
|
76
|
+
code: 'insecure_discovery_url'
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@discovery_url = normalized_url
|
|
54
81
|
@conn = connection
|
|
55
82
|
@cache_ttl = cache_ttl
|
|
83
|
+
@allow_http = allow_http
|
|
56
84
|
@cached_json = nil
|
|
57
85
|
@fetched_at = nil
|
|
58
86
|
@mutex = Mutex.new
|
|
@@ -98,30 +126,28 @@ module Verikloak
|
|
|
98
126
|
# @return [Hash]
|
|
99
127
|
# @raise [DiscoveryError]
|
|
100
128
|
def handle_final_response(response)
|
|
129
|
+
return parse_json(response.body) if response.status == 200
|
|
130
|
+
|
|
131
|
+
raise DiscoveryError.new(failure_message(response), code: 'discovery_metadata_fetch_failed')
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Builds the error message for non-200 final responses.
|
|
135
|
+
# @api private
|
|
136
|
+
def failure_message(response)
|
|
101
137
|
status = response.status
|
|
102
|
-
return
|
|
103
|
-
|
|
104
|
-
if status == 404
|
|
105
|
-
# If the 404 occurred after a redirect (final URL differs from the original discovery URL),
|
|
106
|
-
# keep the generic message to align with redirect tests; otherwise use a specific "not found" message.
|
|
107
|
-
final_url = response.respond_to?(:env) && response.env&.url ? response.env.url.to_s : nil
|
|
108
|
-
message = if final_url && final_url != @discovery_url
|
|
109
|
-
'Failed to fetch discovery document: status 404'
|
|
110
|
-
else
|
|
111
|
-
'Discovery document not found (404)'
|
|
112
|
-
end
|
|
113
|
-
raise DiscoveryError.new(message, code: 'discovery_metadata_fetch_failed')
|
|
114
|
-
end
|
|
115
|
-
if (500..599).cover?(status)
|
|
116
|
-
raise DiscoveryError.new("Discovery endpoint server error: status #{status}",
|
|
117
|
-
code: 'discovery_metadata_fetch_failed')
|
|
118
|
-
end
|
|
138
|
+
return "Discovery endpoint server error: status #{status}" if (500..599).cover?(status)
|
|
139
|
+
return "Failed to fetch discovery document: status #{status}" unless status == 404
|
|
119
140
|
|
|
120
|
-
|
|
121
|
-
|
|
141
|
+
final_url = response.respond_to?(:env) && response.env&.url&.to_s
|
|
142
|
+
if final_url && final_url != @discovery_url
|
|
143
|
+
'Failed to fetch discovery document: status 404'
|
|
144
|
+
else
|
|
145
|
+
'Discovery document not found (404)'
|
|
146
|
+
end
|
|
122
147
|
end
|
|
123
148
|
|
|
124
149
|
# Follows HTTP redirects up to `max_hops`, resolving relative `Location` values.
|
|
150
|
+
# Validates that redirect targets do not point to private/internal networks (SSRF protection).
|
|
125
151
|
# @api private
|
|
126
152
|
# @param response [Faraday::Response]
|
|
127
153
|
# @param max_hops [Integer]
|
|
@@ -141,6 +167,7 @@ module Verikloak
|
|
|
141
167
|
|
|
142
168
|
location = location_from(current)
|
|
143
169
|
url = absolutize_location(location, base)
|
|
170
|
+
validate_redirect_target!(url)
|
|
144
171
|
current = @conn.get(url)
|
|
145
172
|
base = url
|
|
146
173
|
hops += 1
|
|
@@ -163,9 +190,7 @@ module Verikloak
|
|
|
163
190
|
# @return [String] absolute or relative URL string
|
|
164
191
|
# @raise [DiscoveryError]
|
|
165
192
|
def location_from(response)
|
|
166
|
-
|
|
167
|
-
headers = {}
|
|
168
|
-
raw.each { |k, v| headers[k.to_s.downcase] = v }
|
|
193
|
+
headers = (response.headers || {}).transform_keys { |k| k.to_s.downcase }
|
|
169
194
|
location = headers['location'].to_s.strip
|
|
170
195
|
raise DiscoveryError.new('Redirect without Location header', code: 'discovery_redirect_error') if location.empty?
|
|
171
196
|
|
|
@@ -199,16 +224,70 @@ module Verikloak
|
|
|
199
224
|
raise DiscoveryError.new('Discovery response is not valid JSON', code: 'discovery_metadata_invalid')
|
|
200
225
|
end
|
|
201
226
|
|
|
202
|
-
# Validates
|
|
227
|
+
# Validates that a redirect target URL uses a permitted scheme and does not resolve
|
|
228
|
+
# to a private/internal IP address.
|
|
229
|
+
#
|
|
230
|
+
# Scheme check: rejects non-HTTP(S) schemes unconditionally, and rejects plain HTTP
|
|
231
|
+
# when `@allow_http` is false — preventing HTTPS→HTTP downgrade attacks.
|
|
232
|
+
#
|
|
233
|
+
# SSRF check: resolves the target hostname and compares each address against
|
|
234
|
+
# {PRIVATE_IP_RANGES}. IPv4-mapped IPv6 addresses (e.g. `::ffff:127.0.0.1`) are
|
|
235
|
+
# normalised to their native IPv4 form before comparison.
|
|
236
|
+
#
|
|
203
237
|
# @api private
|
|
204
|
-
# @param
|
|
205
|
-
# @
|
|
238
|
+
# @param url [String] The redirect target URL
|
|
239
|
+
# @raise [DiscoveryError] when the target uses a disallowed scheme or resolves to a private IP
|
|
240
|
+
def validate_redirect_target!(url)
|
|
241
|
+
uri = URI.parse(url)
|
|
242
|
+
validate_redirect_scheme!(uri)
|
|
243
|
+
validate_redirect_not_private!(uri)
|
|
244
|
+
rescue URI::InvalidURIError => e
|
|
245
|
+
raise DiscoveryError.new("Invalid redirect URL: #{e.message}", code: 'discovery_redirect_error')
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Validates that the redirect URI uses an allowed HTTP(S) scheme.
|
|
249
|
+
# @api private
|
|
250
|
+
# @param uri [URI] Parsed redirect target
|
|
251
|
+
# @raise [DiscoveryError]
|
|
252
|
+
def validate_redirect_scheme!(uri)
|
|
253
|
+
unless %w[http https].include?(uri.scheme)
|
|
254
|
+
raise DiscoveryError.new(
|
|
255
|
+
"Redirect target uses unsupported scheme: #{uri.scheme}",
|
|
256
|
+
code: 'discovery_redirect_error'
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
return if @allow_http || uri.scheme == 'https'
|
|
261
|
+
|
|
262
|
+
raise DiscoveryError.new(
|
|
263
|
+
'Redirect target must use HTTPS (set allow_http: true for development)',
|
|
264
|
+
code: 'discovery_redirect_error'
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Validates that the redirect target does not resolve to a private/internal IP.
|
|
269
|
+
# IPv4-mapped IPv6 addresses are normalised before comparison.
|
|
270
|
+
# @api private
|
|
271
|
+
# @param uri [URI] Parsed redirect target
|
|
206
272
|
# @raise [DiscoveryError]
|
|
207
|
-
def
|
|
208
|
-
|
|
273
|
+
def validate_redirect_not_private!(uri)
|
|
274
|
+
host = uri.host
|
|
275
|
+
return unless host
|
|
276
|
+
|
|
277
|
+
Resolv.getaddresses(host).each do |addr|
|
|
278
|
+
ip = IPAddr.new(addr)
|
|
279
|
+
ip = ip.native if ip.ipv4_mapped?
|
|
280
|
+
next unless PRIVATE_IP_RANGES.any? { |range| range.include?(ip) }
|
|
209
281
|
|
|
210
|
-
|
|
211
|
-
|
|
282
|
+
raise DiscoveryError.new(
|
|
283
|
+
"Redirect target resolves to a private/internal address (#{host})",
|
|
284
|
+
code: 'discovery_redirect_error'
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
rescue IPAddr::InvalidAddressError
|
|
288
|
+
# If the address cannot be parsed, allow the request to proceed
|
|
289
|
+
# (Faraday will handle the actual connection error)
|
|
290
|
+
nil
|
|
212
291
|
end
|
|
213
292
|
|
|
214
293
|
# Wraps a block with network and parsing error handling and re-raising as {DiscoveryError}.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Verikloak
|
|
6
|
+
# Shared helpers for building RFC 6750 compliant JSON error responses.
|
|
7
|
+
#
|
|
8
|
+
# Provides a consistent format for error responses across all Verikloak middleware:
|
|
9
|
+
# - JSON body with `{ error:, message: }` structure
|
|
10
|
+
# - `WWW-Authenticate` header with Bearer scheme for 401 responses
|
|
11
|
+
# - Header value sanitization to prevent injection attacks
|
|
12
|
+
module ErrorResponse
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Build a JSON error response with optional RFC 6750 `WWW-Authenticate` header.
|
|
16
|
+
#
|
|
17
|
+
# @param code [String] The error code (e.g. "unauthorized", "insufficient_audience")
|
|
18
|
+
# @param message [String] Human-readable error message
|
|
19
|
+
# @param status [Integer] HTTP status code
|
|
20
|
+
# @param realm [String] The realm value for WWW-Authenticate (default: "verikloak")
|
|
21
|
+
# @return [Array(Integer, Hash, Array<String>)] Rack response triple
|
|
22
|
+
def build(code:, message:, status:, realm: 'verikloak')
|
|
23
|
+
body = { error: code, message: message }.to_json
|
|
24
|
+
headers = { 'Content-Type' => 'application/json' }
|
|
25
|
+
|
|
26
|
+
if status == 401
|
|
27
|
+
realm_val = sanitize_header_value(realm)
|
|
28
|
+
code_val = sanitize_header_value(code)
|
|
29
|
+
msg_val = sanitize_header_value(message)
|
|
30
|
+
headers['WWW-Authenticate'] =
|
|
31
|
+
%(Bearer realm="#{realm_val}", error="#{code_val}", error_description="#{msg_val}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
[status, headers, [body]]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Sanitizes a value for safe inclusion in HTTP header quoted-string fields.
|
|
38
|
+
# Escapes backslashes and double-quotes, and strips CR/LF and other control characters.
|
|
39
|
+
#
|
|
40
|
+
# @param val [String, nil]
|
|
41
|
+
# @return [String]
|
|
42
|
+
def sanitize_header_value(val)
|
|
43
|
+
s = val.to_s
|
|
44
|
+
# Truncate at first CRLF sequence to prevent header injection
|
|
45
|
+
s = s.split("\r\n", 2).first.to_s
|
|
46
|
+
# Replace remaining lone CR or LF with spaces
|
|
47
|
+
s = s.gsub(/[\r\n]/, ' ')
|
|
48
|
+
s.gsub(/(["\\])/) { |m| "\\#{m}" }
|
|
49
|
+
.gsub(/[[:cntrl:]]/, '')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/verikloak/errors.rb
CHANGED
|
@@ -10,17 +10,23 @@ module Verikloak
|
|
|
10
10
|
#
|
|
11
11
|
# @attr_reader [String, Symbol, nil] code
|
|
12
12
|
# A short error code identifier suitable for programmatic handling.
|
|
13
|
+
# @attr_reader [Integer, nil] http_status
|
|
14
|
+
# HTTP status code associated with the error (e.g. 401, 403, 500, 503).
|
|
13
15
|
#
|
|
14
16
|
# @example Raising with a code
|
|
15
17
|
# raise Verikloak::Error.new("Something went wrong", code: "internal_error")
|
|
18
|
+
# @example Raising with a code and HTTP status
|
|
19
|
+
# raise Verikloak::Error.new("Forbidden", code: "forbidden", http_status: 403)
|
|
16
20
|
class Error < StandardError
|
|
17
|
-
attr_reader :code
|
|
21
|
+
attr_reader :code, :http_status
|
|
18
22
|
|
|
19
23
|
# @param message [String, nil] Human-readable error message.
|
|
20
24
|
# @param code [String, Symbol, nil] Optional short error code for programmatic handling.
|
|
21
|
-
|
|
25
|
+
# @param http_status [Integer, nil] Optional HTTP status code.
|
|
26
|
+
def initialize(message = nil, code: nil, http_status: nil)
|
|
22
27
|
super(message)
|
|
23
28
|
@code = code
|
|
29
|
+
@http_status = http_status
|
|
24
30
|
end
|
|
25
31
|
end
|
|
26
32
|
|
data/lib/verikloak/jwks_cache.rb
CHANGED
|
@@ -38,13 +38,27 @@ module Verikloak
|
|
|
38
38
|
class JwksCache
|
|
39
39
|
# @param jwks_uri [String] HTTPS URL of the JWKs endpoint
|
|
40
40
|
# @param connection [Faraday::Connection, nil] Optional Faraday connection for HTTP requests
|
|
41
|
+
# @param allow_http [Boolean] When false (default), raises on plain HTTP URIs. Set true for local development only.
|
|
41
42
|
# @raise [JwksCacheError] if the URI is not an HTTP(S) URL
|
|
42
|
-
def initialize(jwks_uri:, connection: nil)
|
|
43
|
-
unless jwks_uri.is_a?(String)
|
|
43
|
+
def initialize(jwks_uri:, connection: nil, allow_http: false)
|
|
44
|
+
unless jwks_uri.is_a?(String)
|
|
44
45
|
raise JwksCacheError.new('Invalid JWKs URI: must be a non-empty HTTP(S) URL', code: 'jwks_fetch_failed')
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
clean_jwks_uri = jwks_uri.strip
|
|
49
|
+
|
|
50
|
+
unless clean_jwks_uri.match?(%r{^https?://})
|
|
51
|
+
raise JwksCacheError.new('Invalid JWKs URI: must be a non-empty HTTP(S) URL', code: 'jwks_fetch_failed')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
unless allow_http || clean_jwks_uri.start_with?('https://')
|
|
55
|
+
raise JwksCacheError.new(
|
|
56
|
+
'JWKs URI must use HTTPS. Set allow_http: true to permit plain HTTP (development only).',
|
|
57
|
+
code: 'insecure_jwks_uri'
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@jwks_uri = clean_jwks_uri
|
|
48
62
|
@connection = connection || Verikloak::HTTP.default_connection
|
|
49
63
|
@cached_keys = nil
|
|
50
64
|
@etag = nil
|
data/lib/verikloak/middleware.rb
CHANGED
|
@@ -6,86 +6,10 @@ require 'set'
|
|
|
6
6
|
require 'faraday'
|
|
7
7
|
|
|
8
8
|
require 'verikloak/http'
|
|
9
|
+
require 'verikloak/skip_path_matcher'
|
|
10
|
+
require 'verikloak/error_response'
|
|
9
11
|
|
|
10
12
|
module Verikloak
|
|
11
|
-
# @api private
|
|
12
|
-
#
|
|
13
|
-
# Internal mixin for skip-path normalization and matching.
|
|
14
|
-
# Extracted from Middleware to reduce class length and improve testability.
|
|
15
|
-
module SkipPathMatcher
|
|
16
|
-
private
|
|
17
|
-
|
|
18
|
-
# Checks whether the request path matches any compiled skip pattern.
|
|
19
|
-
#
|
|
20
|
-
# Supported patterns:
|
|
21
|
-
# * `'/'` — matches only the root path
|
|
22
|
-
# * `'/foo'` — exact-match only (matches `/foo` but **not** `/foo/...`)
|
|
23
|
-
# * `'/foo/*'` — prefix match (matches `/foo` and any nested path under it)
|
|
24
|
-
#
|
|
25
|
-
# @param path [String]
|
|
26
|
-
# @return [Boolean]
|
|
27
|
-
def skip?(path)
|
|
28
|
-
np = normalize_path(path)
|
|
29
|
-
return true if @skip_root && np == '/'
|
|
30
|
-
return true if @skip_exacts.include?(np)
|
|
31
|
-
|
|
32
|
-
@skip_prefixes.any? { |prefix| np == prefix || np.start_with?("#{prefix}/") }
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Normalizes paths for stable comparisons:
|
|
36
|
-
# - ensures leading slash
|
|
37
|
-
# - collapses multiple slashes (e.g. //foo///bar -> /foo/bar)
|
|
38
|
-
# - removes trailing slash except for root
|
|
39
|
-
#
|
|
40
|
-
# @param path [String, nil]
|
|
41
|
-
# @return [String]
|
|
42
|
-
def normalize_path(path)
|
|
43
|
-
s = (path || '').to_s
|
|
44
|
-
s = "/#{s}" unless s.start_with?('/')
|
|
45
|
-
s = s.gsub(%r{/+}, '/')
|
|
46
|
-
s.length > 1 ? s.chomp('/') : s
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Pre-compiles {skip_paths} into fast lookup structures.
|
|
50
|
-
#
|
|
51
|
-
# * `@skip_root` — whether `'/'` is present
|
|
52
|
-
# * `@skip_exacts` — exact-match set (e.g. `'/health'`)
|
|
53
|
-
# * `@skip_prefixes` — wildcard prefixes for `'/*'` (e.g. `'/public'`)
|
|
54
|
-
#
|
|
55
|
-
# @param paths [Array<String>]
|
|
56
|
-
# @return [void]
|
|
57
|
-
def compile_skip_paths(paths)
|
|
58
|
-
@skip_root = false
|
|
59
|
-
@skip_exacts = Set.new
|
|
60
|
-
@skip_prefixes = []
|
|
61
|
-
|
|
62
|
-
Array(paths).each do |raw|
|
|
63
|
-
next if raw.nil?
|
|
64
|
-
|
|
65
|
-
s = raw.to_s.strip
|
|
66
|
-
next if s.empty?
|
|
67
|
-
|
|
68
|
-
if s == '/'
|
|
69
|
-
@skip_root = true
|
|
70
|
-
next
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
if s.end_with?('/*')
|
|
74
|
-
prefix = normalize_path(s.chomp('/*'))
|
|
75
|
-
next if prefix == '/' # root is handled by @skip_root
|
|
76
|
-
|
|
77
|
-
@skip_prefixes << prefix
|
|
78
|
-
else
|
|
79
|
-
exact = normalize_path(s)
|
|
80
|
-
@skip_exacts << exact
|
|
81
|
-
# Do NOT add to @skip_prefixes here; plain '/foo' is exact-match only.
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
@skip_prefixes.uniq!
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
|
|
89
13
|
# @api private
|
|
90
14
|
#
|
|
91
15
|
# Internal mixin for audience resolution with dynamic callable support.
|
|
@@ -492,7 +416,7 @@ module Verikloak
|
|
|
492
416
|
# Use configured issuer if provided, otherwise use discovered issuer
|
|
493
417
|
@issuer = @configured_issuer || config['issuer']
|
|
494
418
|
jwks_uri = config['jwks_uri']
|
|
495
|
-
@jwks_cache = JwksCache.new(jwks_uri: jwks_uri, connection: @connection)
|
|
419
|
+
@jwks_cache = JwksCache.new(jwks_uri: jwks_uri, connection: @connection, allow_http: @allow_http)
|
|
496
420
|
elsif @configured_issuer.nil? && @issuer.nil?
|
|
497
421
|
# If jwks_cache was injected but no issuer configured and not yet discovered, fetch discovery to set issuer
|
|
498
422
|
config = @discovery.fetch!
|
|
@@ -665,12 +589,15 @@ module Verikloak
|
|
|
665
589
|
token_env_key: DEFAULT_TOKEN_ENV_KEY,
|
|
666
590
|
user_env_key: DEFAULT_USER_ENV_KEY,
|
|
667
591
|
realm: DEFAULT_REALM,
|
|
668
|
-
logger: nil
|
|
592
|
+
logger: nil,
|
|
593
|
+
allow_http: false)
|
|
669
594
|
@app = app
|
|
670
595
|
@connection = connection || Verikloak::HTTP.default_connection
|
|
671
596
|
@audience_source = audience
|
|
672
|
-
@discovery = discovery || Discovery.new(discovery_url: discovery_url, connection: @connection
|
|
597
|
+
@discovery = discovery || Discovery.new(discovery_url: discovery_url, connection: @connection,
|
|
598
|
+
allow_http: allow_http)
|
|
673
599
|
@jwks_cache = jwks_cache
|
|
600
|
+
@allow_http = allow_http
|
|
674
601
|
@leeway = leeway
|
|
675
602
|
@token_verify_options = token_verify_options || {}
|
|
676
603
|
@decoder_cache_limit = normalize_decoder_cache_limit(decoder_cache_limit)
|
|
@@ -709,6 +636,10 @@ module Verikloak
|
|
|
709
636
|
error_response('internal_server_error', 'An unexpected error occurred', 500)
|
|
710
637
|
end
|
|
711
638
|
|
|
639
|
+
# Maximum token size in bytes to prevent DoS via oversized JWTs.
|
|
640
|
+
# Aligned with BFF's {Verikloak::BFF::Constants::MAX_TOKEN_BYTES}.
|
|
641
|
+
MAX_TOKEN_BYTES = 8192
|
|
642
|
+
|
|
712
643
|
private
|
|
713
644
|
|
|
714
645
|
# Returns the Faraday connection used for HTTP operations (Discovery/JWKs).
|
|
@@ -748,6 +679,10 @@ module Verikloak
|
|
|
748
679
|
raise MiddlewareError.new('Invalid Authorization header format', code: 'invalid_authorization_header')
|
|
749
680
|
end
|
|
750
681
|
|
|
682
|
+
if token.bytesize > MAX_TOKEN_BYTES
|
|
683
|
+
raise MiddlewareError.new('Token exceeds maximum allowed size', code: 'invalid_token')
|
|
684
|
+
end
|
|
685
|
+
|
|
751
686
|
token
|
|
752
687
|
end
|
|
753
688
|
|
|
@@ -773,19 +708,14 @@ module Verikloak
|
|
|
773
708
|
end
|
|
774
709
|
|
|
775
710
|
# Builds a JSON error response with RFC 6750 `WWW-Authenticate` header for 401.
|
|
711
|
+
# Delegates to {Verikloak::ErrorResponse} for consistent formatting across gems.
|
|
776
712
|
#
|
|
777
713
|
# @param code [String] The error code to include in the response
|
|
778
714
|
# @param message [String] The error message to include in the response
|
|
779
715
|
# @param status [Integer] The HTTP status code for the response
|
|
780
716
|
# @return [Array(Integer, Hash, Array<String>)] Rack response triple
|
|
781
717
|
def error_response(code = 'unauthorized', message = 'Unauthorized', status = 401)
|
|
782
|
-
|
|
783
|
-
headers = { 'Content-Type' => 'application/json' }
|
|
784
|
-
if status == 401
|
|
785
|
-
headers['WWW-Authenticate'] =
|
|
786
|
-
%(Bearer realm="#{@realm}", error="#{code}", error_description="#{message.gsub('"', '\\"')}")
|
|
787
|
-
end
|
|
788
|
-
[status, headers, [body]]
|
|
718
|
+
Verikloak::ErrorResponse.build(code: code, message: message, status: status, realm: @realm)
|
|
789
719
|
end
|
|
790
720
|
|
|
791
721
|
# Logs unexpected internal errors to STDERR (non-PII). Used for diagnostics only.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Verikloak
|
|
6
|
+
# Reusable mixin for skip-path normalization and matching.
|
|
7
|
+
#
|
|
8
|
+
# Include this module and call {#compile_skip_paths} during initialization,
|
|
9
|
+
# then call {#skip?} per-request to check whether the path should bypass processing.
|
|
10
|
+
#
|
|
11
|
+
# Supported patterns:
|
|
12
|
+
# * `'/'` — matches only the root path
|
|
13
|
+
# * `'/foo'` — exact-match only (matches `/foo` but **not** `/foo/...`)
|
|
14
|
+
# * `'/foo/*'` — prefix match (matches `/foo` and any nested path under it)
|
|
15
|
+
module SkipPathMatcher
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# Checks whether the request path matches any compiled skip pattern.
|
|
19
|
+
#
|
|
20
|
+
# @param path [String]
|
|
21
|
+
# @return [Boolean]
|
|
22
|
+
def skip?(path)
|
|
23
|
+
np = normalize_path(path)
|
|
24
|
+
return true if @skip_root && np == '/'
|
|
25
|
+
return true if @skip_exacts.include?(np)
|
|
26
|
+
|
|
27
|
+
@skip_prefixes.any? { |prefix| np == prefix || np.start_with?("#{prefix}/") }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Normalizes paths for stable comparisons:
|
|
31
|
+
# - ensures leading slash
|
|
32
|
+
# - collapses multiple slashes (e.g. //foo///bar -> /foo/bar)
|
|
33
|
+
# - removes trailing slash except for root
|
|
34
|
+
#
|
|
35
|
+
# @param path [String, nil]
|
|
36
|
+
# @return [String]
|
|
37
|
+
def normalize_path(path)
|
|
38
|
+
s = (path || '').to_s
|
|
39
|
+
s = "/#{s}" unless s.start_with?('/')
|
|
40
|
+
s = s.gsub(%r{/+}, '/')
|
|
41
|
+
s.length > 1 ? s.chomp('/') : s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Pre-compiles {skip_paths} into fast lookup structures.
|
|
45
|
+
#
|
|
46
|
+
# * `@skip_root` — whether `'/'` is present
|
|
47
|
+
# * `@skip_exacts` — exact-match set (e.g. `'/health'`)
|
|
48
|
+
# * `@skip_prefixes` — wildcard prefixes for `'/*'` (e.g. `'/public'`)
|
|
49
|
+
#
|
|
50
|
+
# @param paths [Array<String>]
|
|
51
|
+
# @return [void]
|
|
52
|
+
def compile_skip_paths(paths)
|
|
53
|
+
@skip_root = false
|
|
54
|
+
@skip_exacts = Set.new
|
|
55
|
+
@skip_prefixes = []
|
|
56
|
+
|
|
57
|
+
Array(paths).each do |raw|
|
|
58
|
+
next if raw.nil?
|
|
59
|
+
|
|
60
|
+
s = raw.to_s.strip
|
|
61
|
+
next if s.empty?
|
|
62
|
+
|
|
63
|
+
if s == '/'
|
|
64
|
+
@skip_root = true
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if s.end_with?('/*')
|
|
69
|
+
prefix = normalize_path(s.chomp('/*'))
|
|
70
|
+
next if prefix == '/' # root is handled by @skip_root
|
|
71
|
+
|
|
72
|
+
@skip_prefixes << prefix
|
|
73
|
+
else
|
|
74
|
+
exact = normalize_path(s)
|
|
75
|
+
@skip_exacts << exact
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@skip_prefixes.uniq!
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/verikloak/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: verikloak
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- taiyaky
|
|
@@ -15,7 +15,7 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
18
|
+
version: 2.14.1
|
|
19
19
|
- - "<"
|
|
20
20
|
- !ruby/object:Gem::Version
|
|
21
21
|
version: '3.0'
|
|
@@ -25,7 +25,7 @@ dependencies:
|
|
|
25
25
|
requirements:
|
|
26
26
|
- - ">="
|
|
27
27
|
- !ruby/object:Gem::Version
|
|
28
|
-
version:
|
|
28
|
+
version: 2.14.1
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '3.0'
|
|
@@ -35,7 +35,7 @@ dependencies:
|
|
|
35
35
|
requirements:
|
|
36
36
|
- - ">="
|
|
37
37
|
- !ruby/object:Gem::Version
|
|
38
|
-
version:
|
|
38
|
+
version: 2.4.0
|
|
39
39
|
- - "<"
|
|
40
40
|
- !ruby/object:Gem::Version
|
|
41
41
|
version: '3.0'
|
|
@@ -45,7 +45,7 @@ dependencies:
|
|
|
45
45
|
requirements:
|
|
46
46
|
- - ">="
|
|
47
47
|
- !ruby/object:Gem::Version
|
|
48
|
-
version:
|
|
48
|
+
version: 2.4.0
|
|
49
49
|
- - "<"
|
|
50
50
|
- !ruby/object:Gem::Version
|
|
51
51
|
version: '3.0'
|
|
@@ -55,14 +55,14 @@ dependencies:
|
|
|
55
55
|
requirements:
|
|
56
56
|
- - "~>"
|
|
57
57
|
- !ruby/object:Gem::Version
|
|
58
|
-
version: '2.
|
|
58
|
+
version: '2.18'
|
|
59
59
|
type: :runtime
|
|
60
60
|
prerelease: false
|
|
61
61
|
version_requirements: !ruby/object:Gem::Requirement
|
|
62
62
|
requirements:
|
|
63
63
|
- - "~>"
|
|
64
64
|
- !ruby/object:Gem::Version
|
|
65
|
-
version: '2.
|
|
65
|
+
version: '2.18'
|
|
66
66
|
- !ruby/object:Gem::Dependency
|
|
67
67
|
name: jwt
|
|
68
68
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -96,10 +96,12 @@ files:
|
|
|
96
96
|
- README.md
|
|
97
97
|
- lib/verikloak.rb
|
|
98
98
|
- lib/verikloak/discovery.rb
|
|
99
|
+
- lib/verikloak/error_response.rb
|
|
99
100
|
- lib/verikloak/errors.rb
|
|
100
101
|
- lib/verikloak/http.rb
|
|
101
102
|
- lib/verikloak/jwks_cache.rb
|
|
102
103
|
- lib/verikloak/middleware.rb
|
|
104
|
+
- lib/verikloak/skip_path_matcher.rb
|
|
103
105
|
- lib/verikloak/token_decoder.rb
|
|
104
106
|
- lib/verikloak/version.rb
|
|
105
107
|
homepage: https://github.com/taiyaky/verikloak
|
|
@@ -109,7 +111,7 @@ metadata:
|
|
|
109
111
|
source_code_uri: https://github.com/taiyaky/verikloak
|
|
110
112
|
changelog_uri: https://github.com/taiyaky/verikloak/blob/main/CHANGELOG.md
|
|
111
113
|
bug_tracker_uri: https://github.com/taiyaky/verikloak/issues
|
|
112
|
-
documentation_uri: https://rubydoc.info/gems/verikloak/0.
|
|
114
|
+
documentation_uri: https://rubydoc.info/gems/verikloak/0.4.0
|
|
113
115
|
rubygems_mfa_required: 'true'
|
|
114
116
|
rdoc_options: []
|
|
115
117
|
require_paths:
|