Skip to content

Commit bf095f9

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 bf095f9

9 files changed

Lines changed: 335 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: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,129 @@
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+
# rubocop:disable Style/RedundantRegexpArgument
40+
class DuplicateKeyChecker
41+
def self.check!(json_str)
42+
new(json_str).check!
43+
end
44+
45+
def initialize(json_str)
46+
@scanner = StringScanner.new(json_str)
47+
@seen_keys_stack = [[]]
48+
@depth = 0
49+
@in_array_stack = [false]
50+
end
51+
52+
def check!
53+
scan_tokens until @scanner.eos?
54+
end
55+
56+
private
57+
58+
def scan_tokens # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
59+
skip_whitespace
60+
return if @scanner.eos?
61+
62+
if @scanner.scan(/\{/)
63+
handle_object_start
64+
elsif @scanner.scan(/\}/)
65+
handle_container_end
66+
elsif @scanner.scan(/\[/)
67+
handle_array_start
68+
elsif @scanner.scan(/\]/)
69+
@depth -= 1
70+
elsif @scanner.scan(/,/) || @scanner.scan(/:/)
71+
# skip comma and colon
72+
elsif @scanner.scan(/"/)
73+
handle_string
74+
elsif @scanner.scan(/-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/)
75+
# skip number
76+
elsif @scanner.scan(/true|false|null/)
77+
# skip literal
78+
else
79+
@scanner.getch
80+
end
81+
end
82+
83+
def skip_whitespace
84+
@scanner.scan(/\s+/)
85+
end
86+
87+
def handle_object_start
88+
@depth += 1
89+
@seen_keys_stack[@depth] = []
90+
@in_array_stack[@depth] = false
91+
end
92+
93+
def handle_array_start
94+
@depth += 1
95+
@seen_keys_stack[@depth] = []
96+
@in_array_stack[@depth] = true
97+
end
98+
99+
def handle_container_end
100+
@depth -= 1
101+
end
102+
103+
def handle_string
104+
str = scan_string_content
105+
check_if_key(str)
106+
end
107+
108+
def scan_string_content
109+
str = +''
110+
str << (@scanner.getch || '') until @scanner.scan(/"/)
111+
str
112+
end
113+
114+
def check_if_key(str)
115+
return if @in_array_stack[@depth]
116+
117+
pos = @scanner.pos
118+
skip_whitespace
119+
if @scanner.peek(1) == ':'
120+
raise JWT::DuplicateKeyError, "Duplicate key detected: #{str}" if @seen_keys_stack[@depth].include?(str)
121+
122+
@seen_keys_stack[@depth] << str
123+
end
124+
@scanner.pos = pos
125+
end
126+
end
127+
# rubocop:enable Style/RedundantRegexpArgument
17128
end
18129
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)