From 0ac5ecc7b40cd0a0cc0d3972ab4893678b3a44a9 Mon Sep 17 00:00:00 2001 From: Les Hazlewood <121180+lhazlewood@users.noreply.github.com> Date: Tue, 8 Aug 2023 19:17:36 -0700 Subject: [PATCH] - Ensured header 'cty' raw value reflects a compact form but getContentType() return value reflects a normalized value per per https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10 --- .../io/jsonwebtoken/impl/DefaultHeader.java | 5 ++++- .../jsonwebtoken/impl/DefaultJwtBuilder.java | 3 +-- .../lang/CompactMediaTypeIdConverter.java | 20 ++++++++++++++++--- .../groovy/io/jsonwebtoken/JwtsTest.groovy | 5 ++++- .../impl/DefaultHeaderTest.groovy | 7 +++++-- .../impl/DefaultJwtHeaderBuilderTest.groovy | 5 ++++- .../CompactMediaTypeIdConverterTest.groovy | 4 +++- .../impl/security/RFC7520Section5Test.groovy | 5 ++++- 8 files changed, 42 insertions(+), 12 deletions(-) diff --git a/impl/src/main/java/io/jsonwebtoken/impl/DefaultHeader.java b/impl/src/main/java/io/jsonwebtoken/impl/DefaultHeader.java index 86cc39225..5ce3521bc 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/DefaultHeader.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/DefaultHeader.java @@ -16,6 +16,7 @@ package io.jsonwebtoken.impl; import io.jsonwebtoken.Header; +import io.jsonwebtoken.impl.lang.CompactMediaTypeIdConverter; import io.jsonwebtoken.impl.lang.Field; import io.jsonwebtoken.impl.lang.Fields; import io.jsonwebtoken.lang.Registry; @@ -26,7 +27,9 @@ public class DefaultHeader extends FieldMap implements Header { static final Field TYPE = Fields.string(Header.TYPE, "Type"); - static final Field CONTENT_TYPE = Fields.string(Header.CONTENT_TYPE, "Content Type"); + static final Field CONTENT_TYPE = Fields.builder(String.class) + .setId(Header.CONTENT_TYPE).setName("Content Type") + .setConverter(CompactMediaTypeIdConverter.INSTANCE).build(); static final Field ALGORITHM = Fields.string(Header.ALGORITHM, "Algorithm"); static final Field COMPRESSION_ALGORITHM = Fields.string(Header.COMPRESSION_ALGORITHM, "Compression Algorithm"); @SuppressWarnings("DeprecatedIsStillUsed") diff --git a/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java b/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java index b9f6061cc..73f57416d 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java @@ -20,7 +20,6 @@ import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.impl.lang.Bytes; -import io.jsonwebtoken.impl.lang.CompactMediaTypeIdConverter; import io.jsonwebtoken.impl.lang.Function; import io.jsonwebtoken.impl.lang.Functions; import io.jsonwebtoken.impl.lang.Services; @@ -325,7 +324,7 @@ public JwtBuilder content(byte[] content) { public JwtBuilder content(byte[] content, String cty) { Assert.notEmpty(content, "content byte array cannot be null or empty."); Assert.hasText(cty, "Content Type String cannot be null or empty."); - cty = CompactMediaTypeIdConverter.INSTANCE.applyFrom(cty); + //cty = (String)CompactMediaTypeIdConverter.INSTANCE.applyTo(cty); this.headerBuilder.contentType(cty); return content(content); } diff --git a/impl/src/main/java/io/jsonwebtoken/impl/lang/CompactMediaTypeIdConverter.java b/impl/src/main/java/io/jsonwebtoken/impl/lang/CompactMediaTypeIdConverter.java index 8b129af3f..a2574dafe 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/lang/CompactMediaTypeIdConverter.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/lang/CompactMediaTypeIdConverter.java @@ -22,7 +22,9 @@ public final class CompactMediaTypeIdConverter implements Converter INSTANCE = new CompactMediaTypeIdConverter(); - private static final String APP_MEDIA_TYPE_PREFIX = "application/"; + private static final char FORWARD_SLASH = '/'; + + private static final String APP_MEDIA_TYPE_PREFIX = "application" + FORWARD_SLASH; static String compactIfPossible(String cty) { Assert.hasText(cty, "Value cannot be null or empty."); @@ -31,7 +33,7 @@ static String compactIfPossible(String cty) { // we can only use the compact form if no other '/' exists in the string for (int i = cty.length() - 1; i >= APP_MEDIA_TYPE_PREFIX.length(); i--) { char c = cty.charAt(i); - if (c == '/') { + if (c == FORWARD_SLASH) { return cty; // found another '/', can't compact, so just return unmodified } } @@ -51,6 +53,18 @@ public String applyFrom(Object o) { Assert.notNull(o, "Value cannot be null."); Assert.isInstanceOf(String.class, o, "Value must be a string."); String s = (String) o; - return compactIfPossible(s); + //s = compactIfPossible(s); + + // https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10: + // + // A recipient using the media type value MUST treat it as if + // "application/" were prepended to any "cty" value not containing a + // '/'. + // + if (s.indexOf(FORWARD_SLASH) < 0) { + s = APP_MEDIA_TYPE_PREFIX + s; + } + + return s; } } diff --git a/impl/src/test/groovy/io/jsonwebtoken/JwtsTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/JwtsTest.groovy index cc9b74e0d..61a4c8791 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/JwtsTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/JwtsTest.groovy @@ -178,7 +178,10 @@ class JwtsTest { String cty = "application/$subtype" String compact = Jwts.builder().content(s.getBytes(StandardCharsets.UTF_8), cty).compact() def jwt = Jwts.parser().enableUnsecured().build().parseContentJwt(compact) - assertEquals subtype, jwt.header.getContentType() // assert that the compact form was used + // assert raw value is compact form: + assertEquals subtype, jwt.header.get('cty') + // assert getter reflects normalized form per https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10: + assertEquals cty, jwt.header.getContentType() assertEquals s, new String(jwt.payload, StandardCharsets.UTF_8) } diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultHeaderTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultHeaderTest.groovy index 93c5f8d37..97a2f4039 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultHeaderTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultHeaderTest.groovy @@ -39,8 +39,11 @@ class DefaultHeaderTest { @Test void testContentType() { header = h([cty: 'bar']) - assertEquals 'bar', header.getContentType() - assertEquals 'bar', header.get('cty') + // Per per https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10, the raw header should have a + // compact form, but application developers shouldn't have to check for that all the time, so our getter has + // the normalized form: + assertEquals 'bar', header.get('cty') // raw compact form + assertEquals 'application/bar', header.getContentType() // getter normalized form } @Test diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJwtHeaderBuilderTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJwtHeaderBuilderTest.groovy index 430ef5b1f..98c593271 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJwtHeaderBuilderTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJwtHeaderBuilderTest.groovy @@ -266,7 +266,10 @@ class DefaultJwtHeaderBuilderTest { @Test void testDeprecatedSetters() { // TODO: remove before 1.0 assertEquals 'foo', builder.setType('foo').build().getType() - assertEquals 'foo', builder.setContentType('foo').build().getContentType() + + assertEquals 'foo', builder.setContentType('foo').build().get('cty') // compact form + assertEquals 'application/foo', builder.build().getContentType() // normalized form + assertEquals 'foo', builder.setCompressionAlgorithm('foo').build().getCompressionAlgorithm() assertEquals 'foo', builder.setKeyId('foo').build().getKeyId() assertEquals 'foo', builder.setAlgorithm('foo').build().getAlgorithm() diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/lang/CompactMediaTypeIdConverterTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/lang/CompactMediaTypeIdConverterTest.groovy index 971b601b6..2862df714 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/lang/CompactMediaTypeIdConverterTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/lang/CompactMediaTypeIdConverterTest.groovy @@ -52,7 +52,9 @@ class CompactMediaTypeIdConverterTest { void testNonApplicationMediaType() { String cty = 'foo' assertEquals cty, converter.applyTo(cty) - assertEquals cty, converter.applyFrom(cty) + // must auto-prepend 'application/' if no slash in cty value + // per https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10: + assertEquals "application/$cty" as String, converter.applyFrom(cty) } @Test diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/security/RFC7520Section5Test.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/security/RFC7520Section5Test.groovy index 98aa58110..b68216af5 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/security/RFC7520Section5Test.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/RFC7520Section5Test.groovy @@ -610,7 +610,10 @@ class RFC7520Section5Test { assertEquals alg.getId(), parsed.header.getAlgorithm() assertEquals FIGURE_99, b64Url(parsed.header.getPbes2Salt()) assertEquals p2c, parsed.header.getPbes2Count() - assertEquals cty, parsed.header.getContentType() + + assertEquals cty, parsed.header.get('cty') // compact form + assertEquals "application/$cty" as String, parsed.header.getContentType() // normalized form + assertEquals enc.getId(), parsed.header.getEncryptionAlgorithm() assertEquals FIGURE_95, utf8(parsed.payload) }