Skip to content

Commit 0838041

Browse files
committed
Add duplicate claim name detection per RFC 7519 Section 4
Implements duplicate claim name detection as specified in RFC 7519 Section 4, which states: > The Claim Names within a JWT Claims Set MUST be unique; JWT parsers MUST either reject JWTs with duplicate Claim Names or use a JSON parser that returns only the lexically last duplicate member name. This feature allows users to reject JWTs that contain duplicate keys in the header or payload, which is recommended for security-sensitive applications to prevent claim confusion attacks.
1 parent 7af2ac0 commit 0838041

9 files changed

Lines changed: 333 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
**Features:**
88

9-
- Your contribution here
9+
- Add duplicate claim name detection per RFC 7519 Section 4 [#713](https://github.com/jwt/ruby-jwt/pull/713) ([@ydah](https://github.com/ydah))
1010

1111
**Fixes and enhancements:**
1212

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,35 @@ encoded_token.verify_signature!(algorithm: 'HS256', key: "secret")
325325
encoded_token.payload # => {"pay"=>"load"}
326326
```
327327

328+
## Duplicate Claim Name Detection
329+
330+
RFC 7519 Section 4 specifies that claim names within a JWT Claims Set MUST be unique. By default, ruby-jwt follows ECMAScript 5.1 behavior and uses the last value for duplicate keys. You can enable strict duplicate key detection to reject JWTs with duplicate claim names.
331+
332+
### Rejecting Duplicate Keys
333+
334+
```ruby
335+
# Reject JWTs with duplicate keys in header or payload
336+
begin
337+
JWT.decode(token, secret, true, algorithm: 'HS256', allow_duplicate_keys: false)
338+
rescue JWT::DuplicateKeyError => e
339+
puts "Duplicate key detected: #{e.message}"
340+
end
341+
```
342+
343+
### Global Configuration
344+
345+
```ruby
346+
# Globally reject duplicate keys
347+
JWT.configure do |config|
348+
config.decode.allow_duplicate_keys = false
349+
end
350+
351+
# Per-decode override
352+
JWT.decode(token, secret, true, algorithm: 'HS256', allow_duplicate_keys: true)
353+
```
354+
355+
This is recommended for security-sensitive applications to prevent attacks that exploit different systems reading different values from the same JWT.
356+
328357
## Claims
329358

330359
JSON Web Token defines some reserved claim names and defines how they should be

lib/jwt/configuration/decode_configuration.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class DecodeConfiguration
2424
# @return [Array<String>] the list of acceptable algorithms.
2525
# @!attribute [rw] required_claims
2626
# @return [Array<String>] the list of required claims.
27+
# @!attribute [rw] allow_duplicate_keys
28+
# @return [Boolean] whether to allow duplicate keys in JWT header and payload.
2729

2830
attr_accessor :verify_expiration,
2931
:verify_not_before,
@@ -34,7 +36,8 @@ class DecodeConfiguration
3436
:verify_sub,
3537
:leeway,
3638
:algorithms,
37-
:required_claims
39+
:required_claims,
40+
:allow_duplicate_keys
3841

3942
# Initializes a new DecodeConfiguration instance with default settings.
4043
def initialize
@@ -48,6 +51,7 @@ def initialize
4851
@leeway = 0
4952
@algorithms = ['HS256']
5053
@required_claims = []
54+
@allow_duplicate_keys = true
5155
end
5256

5357
# @api private
@@ -62,7 +66,8 @@ def to_h
6266
verify_sub: verify_sub,
6367
leeway: leeway,
6468
algorithms: algorithms,
65-
required_claims: required_claims
69+
required_claims: required_claims,
70+
allow_duplicate_keys: allow_duplicate_keys
6671
}
6772
end
6873
end

lib/jwt/decode.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class Decode
2222
def initialize(jwt, key, verify, options, &keyfinder)
2323
raise JWT::DecodeError, 'Nil JSON web token' unless jwt
2424

25-
@token = EncodedToken.new(jwt)
25+
@token = EncodedToken.new(jwt, allow_duplicate_keys: allow_duplicate_keys?(options))
2626
@key = key
2727
@options = options
2828
@verify = verify
@@ -119,5 +119,11 @@ def none_algorithm?
119119
def alg_in_header
120120
token.header['alg']
121121
end
122+
123+
def allow_duplicate_keys?(options)
124+
return options[:allow_duplicate_keys] if options.key?(:allow_duplicate_keys)
125+
126+
JWT.configuration.decode.allow_duplicate_keys
127+
end
122128
end
123129
end

lib/jwt/encoded_token.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ module JWT
1111
# encoded_token = JWT::EncodedToken.new(token.jwt)
1212
# encoded_token.verify_signature!(algorithm: 'HS256', key: 'secret')
1313
# encoded_token.payload # => {'pay' => 'load'}
14-
class EncodedToken
14+
class EncodedToken # rubocop:disable Metrics/ClassLength
1515
# @private
1616
# Allow access to the unverified payload for claim verification.
1717
class ClaimsContext
@@ -39,11 +39,14 @@ def payload
3939
# Initializes a new EncodedToken instance.
4040
#
4141
# @param jwt [String] the encoded JWT token.
42+
# @param allow_duplicate_keys [Boolean] whether to allow duplicate keys in header/payload (default: true).
4243
# @raise [ArgumentError] if the provided JWT is not a String.
43-
def initialize(jwt)
44+
# @raise [JWT::DuplicateKeyError] if allow_duplicate_keys is false and duplicate keys are found.
45+
def initialize(jwt, allow_duplicate_keys: true)
4446
raise ArgumentError, 'Provided JWT must be a String' unless jwt.is_a?(String)
4547

4648
@jwt = jwt
49+
@allow_duplicate_keys = allow_duplicate_keys
4750
@signature_verified = false
4851
@claims_verified = false
4952

@@ -224,7 +227,7 @@ def parse_unencoded(segment)
224227
end
225228

226229
def parse(segment)
227-
JWT::JSON.parse(segment)
230+
JWT::JSON.parse(segment, allow_duplicate_keys: @allow_duplicate_keys)
228231
rescue ::JSON::ParserError
229232
raise JWT::DecodeError, 'Invalid segment encoding'
230233
end

lib/jwt/error.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,8 @@ class Base64DecodeError < DecodeError; end
5151

5252
# The JWKError class is raised when there is an error with the JSON Web Key (JWK).
5353
class JWKError < DecodeError; end
54+
55+
# The DuplicateKeyError class is raised when a JWT contains duplicate keys in the header or payload.
56+
# @see https://datatracker.ietf.org/doc/html/rfc7519#section-4 RFC 7519 Section 4
57+
class DuplicateKeyError < DecodeError; end
5458
end

lib/jwt/json.rb

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,127 @@
11
# frozen_string_literal: true
22

33
require 'json'
4+
require 'strscan'
45

56
module JWT
7+
# JSON parsing utilities with duplicate key detection support
68
# @api private
79
class JSON
810
class << self
11+
# Generates a JSON string from the given data
12+
# @param data [Object] the data to serialize
13+
# @return [String] the JSON string
914
def generate(data)
1015
::JSON.generate(data)
1116
end
1217

13-
def parse(data)
18+
# Parses a JSON string with optional duplicate key detection
19+
#
20+
# @param data [String] the JSON string to parse
21+
# @param allow_duplicate_keys [Boolean] whether to allow duplicate keys (default: true)
22+
# @return [Hash] the parsed JSON object
23+
# @raise [JWT::DuplicateKeyError] if allow_duplicate_keys is false and duplicate keys are found
24+
#
25+
# @example Default behavior (allows duplicates, uses last value)
26+
# JWT::JSON.parse('{"a":1,"a":2}') # => {"a" => 2}
27+
#
28+
# @example Strict mode (rejects duplicates)
29+
# JWT::JSON.parse('{"a":1,"a":2}', allow_duplicate_keys: false)
30+
# # => raises JWT::DuplicateKeyError
31+
def parse(data, allow_duplicate_keys: true)
32+
DuplicateKeyChecker.check!(data) unless allow_duplicate_keys
1433
::JSON.parse(data)
1534
end
1635
end
36+
37+
# @api private
38+
# Checks for duplicate keys in a JSON string using a StringScanner-based tokenizer
39+
class DuplicateKeyChecker
40+
def self.check!(json_str)
41+
new(json_str).check!
42+
end
43+
44+
def initialize(json_str)
45+
@scanner = StringScanner.new(json_str)
46+
@seen_keys_stack = [[]]
47+
@depth = 0
48+
@in_array_stack = [false]
49+
end
50+
51+
def check!
52+
scan_tokens until @scanner.eos?
53+
end
54+
55+
private
56+
57+
def scan_tokens # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
58+
skip_whitespace
59+
return if @scanner.eos?
60+
61+
if @scanner.scan('{')
62+
handle_object_start
63+
elsif @scanner.scan('}')
64+
handle_container_end
65+
elsif @scanner.scan('[')
66+
handle_array_start
67+
elsif @scanner.scan(']')
68+
@depth -= 1
69+
elsif @scanner.scan(',') || @scanner.scan(':')
70+
# skip comma and colon
71+
elsif @scanner.scan('"')
72+
handle_string
73+
elsif @scanner.scan(/-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/)
74+
# skip number
75+
elsif @scanner.scan(/true|false|null/)
76+
# skip literal
77+
else
78+
@scanner.getch
79+
end
80+
end
81+
82+
def skip_whitespace
83+
@scanner.scan(/\s+/)
84+
end
85+
86+
def handle_object_start
87+
@depth += 1
88+
@seen_keys_stack[@depth] = []
89+
@in_array_stack[@depth] = false
90+
end
91+
92+
def handle_array_start
93+
@depth += 1
94+
@seen_keys_stack[@depth] = []
95+
@in_array_stack[@depth] = true
96+
end
97+
98+
def handle_container_end
99+
@depth -= 1
100+
end
101+
102+
def handle_string
103+
str = scan_string_content
104+
check_if_key(str)
105+
end
106+
107+
def scan_string_content
108+
str = +''
109+
str << (@scanner.getch || '') until @scanner.scan('"')
110+
str
111+
end
112+
113+
def check_if_key(str)
114+
return if @in_array_stack[@depth]
115+
116+
pos = @scanner.pos
117+
skip_whitespace
118+
if @scanner.peek(1) == ':'
119+
raise JWT::DuplicateKeyError, "Duplicate key detected: #{str}" if @seen_keys_stack[@depth].include?(str)
120+
121+
@seen_keys_stack[@depth] << str
122+
end
123+
@scanner.pos = pos
124+
end
125+
end
17126
end
18127
end
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe 'Duplicate Claim Name Detection' do
4+
let(:secret) { 'test_secret' }
5+
let(:algorithm) { 'HS256' }
6+
7+
def sign_jwt(signing_input, secret)
8+
signature = OpenSSL::HMAC.digest('SHA256', secret, signing_input)
9+
JWT::Base64.url_encode(signature)
10+
end
11+
12+
def build_jwt_with_duplicate_payload(duplicate_payload_json)
13+
header = JWT::Base64.url_encode('{"alg":"HS256"}')
14+
payload = JWT::Base64.url_encode(duplicate_payload_json)
15+
signing_input = "#{header}.#{payload}"
16+
signature = sign_jwt(signing_input, secret)
17+
"#{signing_input}.#{signature}"
18+
end
19+
20+
def build_jwt_with_duplicate_header(duplicate_header_json, payload_json = '{"sub":"user"}')
21+
header = JWT::Base64.url_encode(duplicate_header_json)
22+
payload = JWT::Base64.url_encode(payload_json)
23+
signing_input = "#{header}.#{payload}"
24+
signature = sign_jwt(signing_input, secret)
25+
"#{signing_input}.#{signature}"
26+
end
27+
28+
describe 'payload with duplicate keys' do
29+
let(:duplicate_payload_jwt) { build_jwt_with_duplicate_payload('{"sub":"user","sub":"admin"}') }
30+
31+
context 'with default configuration' do
32+
it 'uses the last value (backward compatible)' do
33+
payload, = JWT.decode(duplicate_payload_jwt, secret, true, algorithm: algorithm)
34+
expect(payload['sub']).to eq('admin')
35+
end
36+
end
37+
38+
context 'with allow_duplicate_keys: true' do
39+
it 'uses the last value' do
40+
payload, = JWT.decode(duplicate_payload_jwt, secret, true, algorithm: algorithm, allow_duplicate_keys: true)
41+
expect(payload['sub']).to eq('admin')
42+
end
43+
end
44+
45+
context 'with allow_duplicate_keys: false' do
46+
it 'raises DuplicateKeyError' do
47+
expect do
48+
JWT.decode(duplicate_payload_jwt, secret, true, algorithm: algorithm, allow_duplicate_keys: false)
49+
end.to raise_error(JWT::DuplicateKeyError, /Duplicate key detected: sub/)
50+
end
51+
end
52+
end
53+
54+
describe 'header with duplicate keys' do
55+
let(:duplicate_header_jwt) { build_jwt_with_duplicate_header('{"alg":"HS256","alg":"none"}') }
56+
57+
context 'with default configuration' do
58+
it 'uses the last value (backward compatible)' do
59+
_, header = JWT.decode(duplicate_header_jwt, nil, false)
60+
expect(header['alg']).to eq('none')
61+
end
62+
end
63+
64+
context 'with allow_duplicate_keys: false' do
65+
it 'raises DuplicateKeyError for header' do
66+
expect do
67+
JWT.decode(duplicate_header_jwt, nil, false, allow_duplicate_keys: false)
68+
end.to raise_error(JWT::DuplicateKeyError, /Duplicate key detected: alg/)
69+
end
70+
end
71+
end
72+
73+
describe 'global configuration' do
74+
around do |example|
75+
original = JWT.configuration.decode.allow_duplicate_keys
76+
example.run
77+
JWT.configuration.decode.allow_duplicate_keys = original
78+
end
79+
80+
let(:duplicate_payload_jwt) { build_jwt_with_duplicate_payload('{"sub":"user","sub":"admin"}') }
81+
82+
it 'respects global configuration when set to false' do
83+
JWT.configuration.decode.allow_duplicate_keys = false
84+
85+
expect do
86+
JWT.decode(duplicate_payload_jwt, secret, true, algorithm: algorithm)
87+
end.to raise_error(JWT::DuplicateKeyError)
88+
end
89+
90+
it 'allows per-decode override of global configuration' do
91+
JWT.configuration.decode.allow_duplicate_keys = false
92+
93+
payload, = JWT.decode(
94+
duplicate_payload_jwt,
95+
secret,
96+
true,
97+
algorithm: algorithm,
98+
allow_duplicate_keys: true
99+
)
100+
expect(payload['sub']).to eq('admin')
101+
end
102+
103+
it 'defaults to allowing duplicate keys' do
104+
expect(JWT.configuration.decode.allow_duplicate_keys).to be(true)
105+
end
106+
end
107+
108+
describe 'multiple duplicate keys' do
109+
let(:multiple_duplicates_jwt) { build_jwt_with_duplicate_payload('{"a":1,"b":2,"a":3,"b":4}') }
110+
111+
context 'with allow_duplicate_keys: false' do
112+
it 'raises DuplicateKeyError for the first duplicate found' do
113+
expect do
114+
JWT.decode(multiple_duplicates_jwt, secret, true, algorithm: algorithm, allow_duplicate_keys: false)
115+
end.to raise_error(JWT::DuplicateKeyError, /Duplicate key detected: a/)
116+
end
117+
end
118+
end
119+
end

0 commit comments

Comments
 (0)