diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ee4eb11..bf8f52f7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,8 +33,6 @@ jobs: os: - ubuntu-latest ruby: - - "2.5" - - "2.6" - "2.7" - "3.0" - "3.1" @@ -46,10 +44,6 @@ jobs: - gemfiles/standalone.gemfile experimental: [false] include: - - os: ubuntu-latest - ruby: "2.5" - gemfile: gemfiles/openssl.gemfile - experimental: false - os: ubuntu-latest ruby: "truffleruby-head" gemfile: "gemfiles/standalone.gemfile" diff --git a/.rubocop.yml b/.rubocop.yml index 8093a01d..a8ec975c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,5 @@ AllCops: - TargetRubyVersion: 2.5 + TargetRubyVersion: 2.7 NewCops: enable SuggestExtensions: false Exclude: diff --git a/CHANGELOG.md b/CHANGELOG.md index 047b5903..9369f4b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,13 @@ [Full Changelog](https://github.com/jwt/ruby-jwt/compare/v3.1.2...v3.1.3) +**Breaking changes:** +- Drop support for Ruby 2.6 and older [#713](https://github.com/jwt/ruby-jwt/pull/713) ([@ydah](https://github.com/ydah)) +- Bump minimum json gem version to 2.6 [#713](https://github.com/jwt/ruby-jwt/pull/713) ([@ydah](https://github.com/ydah)) + **Features:** -- Your contribution here +- Add duplicate claim name detection per RFC 7519 Section 4 [#713](https://github.com/jwt/ruby-jwt/pull/713) ([@ydah](https://github.com/ydah)) **Fixes and enhancements:** diff --git a/README.md b/README.md index 0e8122ed..775665f0 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,28 @@ encoded_token.verify_signature!(algorithm: 'HS256', key: "secret") encoded_token.payload # => {"pay"=>"load"} ``` +## Duplicate Claim Name Detection + +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 using the `EncodedToken` API. + +### Using EncodedToken API + +```ruby +# Enable strict duplicate key detection +token = JWT::EncodedToken.new(jwt_string) +token.raise_on_duplicate_keys! + +begin + token.verify_signature!(algorithm: 'HS256', key: secret) + token.verify_claims! + token.payload +rescue JWT::DuplicateKeyError => e + puts "Duplicate key detected: #{e.message}" +end +``` + +This is recommended for security-sensitive applications to prevent attacks that exploit different systems reading different values from the same JWT. + ## Claims JSON Web Token defines some reserved claim names and defines how they should be diff --git a/lib/jwt/encoded_token.rb b/lib/jwt/encoded_token.rb index cbaec1c8..de50bd6b 100644 --- a/lib/jwt/encoded_token.rb +++ b/lib/jwt/encoded_token.rb @@ -11,7 +11,7 @@ module JWT # encoded_token = JWT::EncodedToken.new(token.jwt) # encoded_token.verify_signature!(algorithm: 'HS256', key: 'secret') # encoded_token.payload # => {'pay' => 'load'} - class EncodedToken + class EncodedToken # rubocop:disable Metrics/ClassLength # @private # Allow access to the unverified payload for claim verification. class ClaimsContext @@ -44,12 +44,29 @@ def initialize(jwt) raise ArgumentError, 'Provided JWT must be a String' unless jwt.is_a?(String) @jwt = jwt + @allow_duplicate_keys = true @signature_verified = false @claims_verified = false @encoded_header, @encoded_payload, @encoded_signature = jwt.split('.') end + # Enables strict duplicate key detection for this token. + # When called, the token will raise JWT::DuplicateKeyError if duplicate keys + # are found in the header or payload during parsing. + # + # @example + # token = JWT::EncodedToken.new(jwt_string) + # token.raise_on_duplicate_keys! + # token.header # May raise JWT::DuplicateKeyError + # + # @return [self] + # @raise [JWT::DuplicateKeyError] if duplicate keys are found during subsequent parsing. + def raise_on_duplicate_keys! + @allow_duplicate_keys = false + self + end + # Returns the decoded signature of the JWT token. # # @return [String] the decoded signature. @@ -224,7 +241,7 @@ def parse_unencoded(segment) end def parse(segment) - JWT::JSON.parse(segment) + JWT::JSON.parse(segment, allow_duplicate_keys: @allow_duplicate_keys) rescue ::JSON::ParserError raise JWT::DecodeError, 'Invalid segment encoding' end diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb index 2a0f8a2c..a6bbf4a0 100644 --- a/lib/jwt/error.rb +++ b/lib/jwt/error.rb @@ -51,4 +51,8 @@ class Base64DecodeError < DecodeError; end # The JWKError class is raised when there is an error with the JSON Web Key (JWK). class JWKError < DecodeError; end + + # The DuplicateKeyError class is raised when a JWT contains duplicate keys in the header or payload. + # @see https://datatracker.ietf.org/doc/html/rfc7519#section-4 RFC 7519 Section 4 + class DuplicateKeyError < DecodeError; end end diff --git a/lib/jwt/json.rb b/lib/jwt/json.rb index 90ae4585..20edb11c 100644 --- a/lib/jwt/json.rb +++ b/lib/jwt/json.rb @@ -3,15 +3,36 @@ require 'json' module JWT + # JSON parsing utilities with duplicate key detection support # @api private class JSON class << self + # Generates a JSON string from the given data + # @param data [Object] the data to serialize + # @return [String] the JSON string def generate(data) ::JSON.generate(data) end - def parse(data) - ::JSON.parse(data) + # Parses a JSON string with optional duplicate key detection + # + # @param data [String] the JSON string to parse + # @param allow_duplicate_keys [Boolean] whether to allow duplicate keys (default: true) + # @return [Hash] the parsed JSON object + # @raise [JWT::DuplicateKeyError] if allow_duplicate_keys is false and duplicate keys are found + # + # @example Default behavior (allows duplicates, uses last value) + # JWT::JSON.parse('{"a":1,"a":2}') # => {"a" => 2} + # + # @example Strict mode (rejects duplicates) + # JWT::JSON.parse('{"a":1,"a":2}', allow_duplicate_keys: false) + # # => raises JWT::DuplicateKeyError + def parse(data, allow_duplicate_keys: true) + ::JSON.parse(data, allow_duplicate_key: allow_duplicate_keys) + rescue ::JSON::ParserError => e + raise JWT::DuplicateKeyError, e.message if e.message.include?('duplicate key') + + raise end end end diff --git a/lib/jwt/jwa/ecdsa.rb b/lib/jwt/jwa/ecdsa.rb index 9840621f..33d6159b 100644 --- a/lib/jwt/jwa/ecdsa.rb +++ b/lib/jwt/jwa/ecdsa.rb @@ -98,7 +98,7 @@ def curve_by_name(name) def raw_to_asn1(signature, private_key) byte_size = (private_key.group.degree + 7) / 8 sig_bytes = signature[0..(byte_size - 1)] - sig_char = signature[byte_size..-1] || '' + sig_char = signature[byte_size..] || '' OpenSSL::ASN1::Sequence.new([sig_bytes, sig_char].map { |int| OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(int, 2)) }).to_der end diff --git a/ruby-jwt.gemspec b/ruby-jwt.gemspec index 1c469c46..f755b3d9 100644 --- a/ruby-jwt.gemspec +++ b/ruby-jwt.gemspec @@ -15,7 +15,7 @@ Gem::Specification.new do |spec| spec.description = 'A pure ruby implementation of the RFC 7519 OAuth JSON Web Token (JWT) standard.' spec.homepage = 'https://github.com/jwt/ruby-jwt' spec.license = 'MIT' - spec.required_ruby_version = '>= 2.5' + spec.required_ruby_version = '>= 2.7' spec.metadata = { 'bug_tracker_uri' => 'https://github.com/jwt/ruby-jwt/issues', 'changelog_uri' => "https://github.com/jwt/ruby-jwt/blob/v#{JWT.gem_version}/CHANGELOG.md", @@ -32,6 +32,7 @@ Gem::Specification.new do |spec| spec.require_paths = %w[lib] spec.add_dependency 'base64' + spec.add_dependency 'json', '>= 2.13.0' spec.add_development_dependency 'appraisal' spec.add_development_dependency 'bundler' diff --git a/spec/jwt/claims/duplicate_key_spec.rb b/spec/jwt/claims/duplicate_key_spec.rb new file mode 100644 index 00000000..03ccbf48 --- /dev/null +++ b/spec/jwt/claims/duplicate_key_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +RSpec.describe 'Duplicate Claim Name Detection' do + let(:secret) { 'test_secret' } + let(:algorithm) { 'HS256' } + + def sign_jwt(signing_input, secret) + signature = OpenSSL::HMAC.digest('SHA256', secret, signing_input) + JWT::Base64.url_encode(signature) + end + + def build_jwt_with_duplicate_payload(duplicate_payload_json) + header = JWT::Base64.url_encode('{"alg":"HS256"}') + payload = JWT::Base64.url_encode(duplicate_payload_json) + signing_input = "#{header}.#{payload}" + signature = sign_jwt(signing_input, secret) + "#{signing_input}.#{signature}" + end + + def build_jwt_with_duplicate_header(duplicate_header_json, payload_json = '{"sub":"user"}') + header = JWT::Base64.url_encode(duplicate_header_json) + payload = JWT::Base64.url_encode(payload_json) + signing_input = "#{header}.#{payload}" + signature = sign_jwt(signing_input, secret) + "#{signing_input}.#{signature}" + end + + describe 'using EncodedToken API' do + describe 'payload with duplicate keys' do + let(:duplicate_payload_jwt) { build_jwt_with_duplicate_payload('{"sub":"user","sub":"admin"}') } + + context 'with default behavior' do + it 'uses the last value (allows duplicates)' do + token = JWT::EncodedToken.new(duplicate_payload_jwt) + expect(token.unverified_payload['sub']).to eq('admin') + end + end + + context 'with raise_on_duplicate_keys!' do + it 'raises DuplicateKeyError' do + token = JWT::EncodedToken.new(duplicate_payload_jwt) + token.raise_on_duplicate_keys! + expect do + token.unverified_payload + end.to raise_error(JWT::DuplicateKeyError, /duplicate key/) + end + end + end + + describe 'header with duplicate keys' do + let(:duplicate_header_jwt) { build_jwt_with_duplicate_header('{"alg":"HS256","alg":"none"}') } + + context 'with default behavior' do + it 'uses the last value (allows duplicates)' do + token = JWT::EncodedToken.new(duplicate_header_jwt) + expect(token.header['alg']).to eq('none') + end + end + + context 'with raise_on_duplicate_keys!' do + it 'raises DuplicateKeyError for header' do + token = JWT::EncodedToken.new(duplicate_header_jwt) + token.raise_on_duplicate_keys! + expect do + token.header + end.to raise_error(JWT::DuplicateKeyError, /duplicate key/) + end + end + end + + describe 'chaining' do + let(:valid_jwt) { build_jwt_with_duplicate_payload('{"sub":"user"}') } + + it 'returns self for method chaining' do + token = JWT::EncodedToken.new(valid_jwt) + expect(token.raise_on_duplicate_keys!).to eq(token) + end + end + + describe 'valid tokens' do + let(:valid_jwt) { build_jwt_with_duplicate_payload('{"sub":"user","name":"John"}') } + + it 'parses valid JSON without duplicates' do + token = JWT::EncodedToken.new(valid_jwt) + token.raise_on_duplicate_keys! + expect(token.unverified_payload).to eq({ 'sub' => 'user', 'name' => 'John' }) + end + end + end + + describe 'multiple duplicate keys' do + let(:multiple_duplicates_jwt) { build_jwt_with_duplicate_payload('{"a":1,"b":2,"a":3,"b":4}') } + + context 'with raise_on_duplicate_keys!' do + it 'raises DuplicateKeyError for the first duplicate found' do + token = JWT::EncodedToken.new(multiple_duplicates_jwt) + token.raise_on_duplicate_keys! + expect do + token.unverified_payload + end.to raise_error(JWT::DuplicateKeyError, /duplicate key/) + end + end + end +end diff --git a/spec/jwt/json_spec.rb b/spec/jwt/json_spec.rb new file mode 100644 index 00000000..a2d81534 --- /dev/null +++ b/spec/jwt/json_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +RSpec.describe JWT::JSON do + describe '.generate' do + it 'generates JSON from a hash' do + expect(described_class.generate({ 'a' => 1 })).to eq('{"a":1}') + end + end + + describe '.parse' do + context 'with allow_duplicate_keys: true (default)' do + it 'uses the last value for duplicate keys' do + result = described_class.parse('{"a":1,"a":2}') + expect(result['a']).to eq(2) + end + + it 'parses valid JSON without duplicates' do + result = described_class.parse('{"a":1,"b":2}') + expect(result).to eq({ 'a' => 1, 'b' => 2 }) + end + end + + context 'with allow_duplicate_keys: false' do + it 'raises DuplicateKeyError for duplicate keys' do + expect do + described_class.parse('{"a":1,"a":2}', allow_duplicate_keys: false) + end.to raise_error(JWT::DuplicateKeyError, /duplicate key/) + end + + it 'parses valid JSON without duplicates' do + result = described_class.parse('{"a":1,"b":2}', allow_duplicate_keys: false) + expect(result).to eq({ 'a' => 1, 'b' => 2 }) + end + + it 'detects duplicates in nested objects' do + json = '{"outer":{"inner":1,"inner":2}}' + expect do + described_class.parse(json, allow_duplicate_keys: false) + end.to raise_error(JWT::DuplicateKeyError, /duplicate key/) + end + + it 'allows same key in different objects' do + json = '{"obj1":{"a":1},"obj2":{"a":2}}' + result = described_class.parse(json, allow_duplicate_keys: false) + expect(result['obj1']['a']).to eq(1) + expect(result['obj2']['a']).to eq(2) + end + end + end +end