From 94e43b2bb47b0de9f1d1ce60a109ed428dbd7ccb Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Fri, 8 Dec 2023 13:04:31 +0100 Subject: [PATCH] Implement username* validation and decoding in DigestScheme (#511) Introduces validation and decoding logic for the 'username*' field in the DigestScheme class. The changes ensure compliance with RFC 7616 and RFC 5987 by handling cases where the 'username' contains characters not allowed in an ABNF quoted-string. --- .../entity/mime/HttpRFC7578Multipart.java | 109 +----------- .../client5/http/entity/mime/MimeConsts.java | 1 + .../client5/http/impl/auth/DigestScheme.java | 75 +++++++- .../client5/http/impl/auth/RFC5987Codec.java | 161 ++++++++++++++++++ .../client5/http/utils/CodingException.java | 77 +++++++++ .../entity/mime/HttpRFC7578MultipartTest.java | 10 +- .../mime/TestMultipartEntityBuilder.java | 28 +++ .../http/impl/auth/RFC5987CodecTest.java | 67 ++++++++ .../http/impl/auth/TestDigestScheme.java | 87 ++++++++++ 9 files changed, 500 insertions(+), 115 deletions(-) create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/RFC5987Codec.java create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/utils/CodingException.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/RFC5987CodecTest.java diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/HttpRFC7578Multipart.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/HttpRFC7578Multipart.java index 8df79a124..85d946893 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/HttpRFC7578Multipart.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/HttpRFC7578Multipart.java @@ -27,22 +27,17 @@ package org.apache.hc.client5.http.entity.mime; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.charset.Charset; -import java.nio.charset.CharsetEncoder; import java.nio.charset.StandardCharsets; -import java.util.BitSet; import java.util.List; +import org.apache.hc.client5.http.impl.auth.RFC5987Codec; import org.apache.hc.core5.http.NameValuePair; -import org.apache.hc.core5.util.ByteArrayBuffer; class HttpRFC7578Multipart extends AbstractMultipartFormat { - private static final PercentCodec PERCENT_CODEC = new PercentCodec(); - private final List parts; /** @@ -99,8 +94,12 @@ class HttpRFC7578Multipart extends AbstractMultipartFormat { writeBytes(name, out); writeBytes("=\"", out); if (value != null) { - if (name.equalsIgnoreCase(MimeConsts.FIELD_PARAM_FILENAME)) { - out.write(PERCENT_CODEC.encode(value.getBytes(charset))); + if (name.equalsIgnoreCase(MimeConsts.FIELD_PARAM_FILENAME) || + name.equalsIgnoreCase(MimeConsts.FIELD_PARAM_FILENAME_START)) { + final String encodedValue = name.equalsIgnoreCase(MimeConsts.FIELD_PARAM_FILENAME_START) ? + "UTF-8''" + RFC5987Codec.encode(value) : RFC5987Codec.encode(value); + final byte[] encodedBytes = encodedValue.getBytes(StandardCharsets.US_ASCII); + out.write(encodedBytes); } else { writeBytes(value, out); } @@ -114,98 +113,4 @@ class HttpRFC7578Multipart extends AbstractMultipartFormat { } } - static class PercentCodec { - - private static final byte ESCAPE_CHAR = '%'; - - private static final BitSet ALWAYSENCODECHARS = new BitSet(); - - static { - ALWAYSENCODECHARS.set(' '); - ALWAYSENCODECHARS.set('%'); - } - - /** - * Percent-Encoding implementation based on RFC 3986 - */ - public byte[] encode(final byte[] bytes) { - if (bytes == null) { - return null; - } - - final CharsetEncoder characterSetEncoder = StandardCharsets.US_ASCII.newEncoder(); - final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - for (final byte c : bytes) { - int b = c; - if (b < 0) { - b = 256 + b; - } - if (characterSetEncoder.canEncode((char) b) && !ALWAYSENCODECHARS.get(c)) { - buffer.write(b); - } else { - buffer.write(ESCAPE_CHAR); - final char hex1 = hexDigit(b >> 4); - final char hex2 = hexDigit(b); - buffer.write(hex1); - buffer.write(hex2); - } - } - return buffer.toByteArray(); - } - - public byte[] decode(final byte[] bytes) { - if (bytes == null) { - return null; - } - final ByteArrayBuffer buffer = new ByteArrayBuffer(bytes.length); - for (int i = 0; i < bytes.length; i++) { - final int b = bytes[i]; - if (b == ESCAPE_CHAR) { - if (i >= bytes.length - 2) { - throw new IllegalArgumentException("Invalid encoding: too short"); - } - final int u = digit16(bytes[++i]); - final int l = digit16(bytes[++i]); - buffer.append((char) ((u << 4) + l)); - } else { - buffer.append(b); - } - } - return buffer.toByteArray(); - } - } - - /** - * Radix used in encoding and decoding. - */ - private static final int RADIX = 16; - - /** - * Returns the numeric value of the character {@code b} in radix 16. - * - * @param b - * The byte to be converted. - * @return The numeric value represented by the character in radix 16. - * - * @throws IllegalArgumentException - * Thrown when the byte is not valid per {@link Character#digit(char,int)} - */ - static int digit16(final byte b) { - final int i = Character.digit((char) b, RADIX); - if (i == -1) { - throw new IllegalArgumentException("Invalid encoding: not a valid digit (radix " + RADIX + "): " + b); - } - return i; - } - - /** - * Returns the upper case hex digit of the lower 4 bits of the int. - * - * @param b the input int - * @return the upper case hex digit of the lower 4 bits of the int. - */ - static char hexDigit(final int b) { - return Character.toUpperCase(Character.forDigit(b & 0xF, RADIX)); - } - } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/MimeConsts.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/MimeConsts.java index a6c018a6f..d2b00965f 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/MimeConsts.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/MimeConsts.java @@ -34,5 +34,6 @@ final class MimeConsts { public static final String FIELD_PARAM_NAME = "name"; public static final String FIELD_PARAM_FILENAME = "filename"; + public static final String FIELD_PARAM_FILENAME_START = "filename*"; } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java index ad4502afc..144ace870 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java @@ -341,17 +341,30 @@ public class DigestScheme implements AuthScheme, Serializable { } buffer.charset(charset); + a1 = null; + a2 = null; + + // Extract username and username* String username = credentials.getUserName(); + String encodedUsername = null; + // Check if 'username' has invalid characters and use 'username*' + if (username != null && containsInvalidABNFChars(username)) { + encodedUsername = "UTF-8''" + RFC5987Codec.encode(username, StandardCharsets.UTF_8); + } + final String usernameForDigest; if (this.userhashSupported) { final String usernameRealm = username + ":" + realm; final byte[] hashedBytes = digester.digest(usernameRealm.getBytes(StandardCharsets.UTF_8)); - username = formatHex(hashedBytes); // Convert to hex string + usernameForDigest = formatHex(hashedBytes); // Use hashed username for digest + username = usernameForDigest; + } else if (encodedUsername != null) { + usernameForDigest = encodedUsername; // Use encoded username for digest + } else { + usernameForDigest = username; // Use regular username for digest } - a1 = null; - a2 = null; // 3.2.2.2: Calculating digest if ("MD5-sess".equalsIgnoreCase(algorithm)) { // H( unq(username-value) ":" unq(realm-value) ":" passwd ) @@ -426,7 +439,17 @@ public class DigestScheme implements AuthScheme, Serializable { buffer.append(StandardAuthScheme.DIGEST + " "); final List params = new ArrayList<>(20); - params.add(new BasicNameValuePair("username", username)); + if (this.userhashSupported) { + // Use hashed username for the 'username' parameter + params.add(new BasicNameValuePair("username", usernameForDigest)); + params.add(new BasicNameValuePair("userhash", "true")); + } else if (encodedUsername != null) { + // Use encoded 'username*' parameter + params.add(new BasicNameValuePair("username*", encodedUsername)); + } else { + // Use regular 'username' parameter + params.add(new BasicNameValuePair("username", username)); + } params.add(new BasicNameValuePair("realm", realm)); params.add(new BasicNameValuePair("nonce", nonce)); params.add(new BasicNameValuePair("uri", uri)); @@ -444,10 +467,6 @@ public class DigestScheme implements AuthScheme, Serializable { params.add(new BasicNameValuePair("opaque", opaque)); } - if (this.userhashSupported) { - params.add(new BasicNameValuePair("userhash", "true")); - } - for (int i = 0; i < params.size(); i++) { final BasicNameValuePair param = params.get(i); if (i > 0) { @@ -529,4 +548,44 @@ public class DigestScheme implements AuthScheme, Serializable { public String toString() { return getName() + this.paramMap; } + + /** + * Checks if a given string contains characters that are not allowed + * in an ABNF quoted-string as per standard specifications. + *

+ * The method checks for: + * - Control characters (ASCII 0x00 to 0x1F and 0x7F). + * - Characters outside the printable ASCII range (above 0x7E). + * - Double quotes (") and backslashes (\), which are not allowed. + *

+ * + * @param value The string to be checked for invalid ABNF characters. + * @return {@code true} if invalid characters are found, {@code false} otherwise. + * @throws IllegalArgumentException if the input string is null. + */ + private boolean containsInvalidABNFChars(final String value) { + if (value == null) { + throw new IllegalArgumentException("Input string should not be null."); + } + + for (int i = 0; i < value.length(); i++) { + final char c = value.charAt(i); + + // Check for control characters and DEL + if (c <= 0x1F || c == 0x7F) { + return true; + } + + // Check for characters outside the range 0x20 to 0x7E + if (c > 0x7E) { + return true; + } + + // Exclude double quotes and backslash + if (c == '"' || c == '\\') { + return true; + } + } + return false; + } } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/RFC5987Codec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/RFC5987Codec.java new file mode 100644 index 000000000..f373482c7 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/RFC5987Codec.java @@ -0,0 +1,161 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package org.apache.hc.client5.http.impl.auth; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.BitSet; + +import org.apache.hc.client5.http.utils.CodingException; +import org.conscrypt.Internal; + +/** + * Utility class for encoding and decoding strings according to RFC 5987. + * This class provides methods to percent-encode and decode strings, particularly + * useful for handling HTTP header parameters that include non-ASCII characters. + * + * @Internal This class is intended for internal use within the library and + * should not be used as part of the public API. + */ +@Internal +public class RFC5987Codec { + + private static final BitSet UNRESERVED = new BitSet(256); + private static final int RADIX = 16; + + static { + // Alphanumeric characters + for (int i = 'a'; i <= 'z'; i++) { + UNRESERVED.set(i); + } + for (int i = 'A'; i <= 'Z'; i++) { + UNRESERVED.set(i); + } + for (int i = '0'; i <= '9'; i++) { + UNRESERVED.set(i); + } + + // Additional characters as per RFC 5987 attr-char + UNRESERVED.set('!'); + UNRESERVED.set('#'); + UNRESERVED.set('$'); + UNRESERVED.set('&'); + UNRESERVED.set('+'); + UNRESERVED.set('-'); + UNRESERVED.set('.'); + UNRESERVED.set('^'); + UNRESERVED.set('_'); + UNRESERVED.set('`'); + UNRESERVED.set('|'); + UNRESERVED.set('~'); + } + + + + /** + * Encodes a string using the default UTF-8 charset. + * + * @param s The string to encode. + * @return The percent-encoded string. + */ + public static String encode(final String s) { + return encode(s, StandardCharsets.UTF_8); + } + + /** + * Encodes a string using the specified charset. + * + * @param s The string to encode. + * @param charset The charset to use for encoding. + * @return The percent-encoded string. + */ + public static String encode(final String s, final Charset charset) { + final ByteBuffer bb = charset.encode(CharBuffer.wrap(s)); + final StringBuilder sb = new StringBuilder(); + + while (bb.hasRemaining()) { + final int b = bb.get() & 0xff; + if (UNRESERVED.get(b)) { + sb.append((char) b); + } else { + sb.append('%'); + sb.append(Character.toUpperCase(Character.forDigit((b >> 4) & 0xF, RADIX))); + sb.append(Character.toUpperCase(Character.forDigit(b & 0xF, RADIX))); + } + } + + return sb.toString(); + } + + /** + * Decodes a percent-encoded string using the default UTF-8 charset. + * + * @param s The percent-encoded string to decode. + * @return The decoded string. + * @throws IllegalArgumentException If the percent-encoded string is invalid. + */ + public static String decode(final String s) throws CodingException { + return decode(s, StandardCharsets.UTF_8); + } + + /** + * Decodes a percent-encoded string using the specified charset. + * + * @param s The percent-encoded string to decode. + * @param charset The charset to use for decoding. + * @return The decoded CodingException. + * @throws IllegalArgumentException If the percent-encoded string is invalid. + */ + public static String decode(final String s, final Charset charset) throws CodingException { + final ByteBuffer bb = ByteBuffer.allocate(s.length()); + final CharBuffer cb = CharBuffer.wrap(s); + + while (cb.hasRemaining()) { + final char c = cb.get(); + if (c == '%') { + if (cb.remaining() < 2) { + throw new CodingException("Incomplete percent encoding in " + s); + } + final int u = Character.digit(cb.get(), RADIX); + final int l = Character.digit(cb.get(), RADIX); + if (u != -1 && l != -1) { + bb.put((byte) ((u << 4) + l)); + } else { + throw new CodingException("Invalid percent encoding in " + s); + } + } else { + bb.put((byte) c); + } + } + bb.flip(); + return charset.decode(bb).toString(); + } + +} \ No newline at end of file diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/utils/CodingException.java b/httpclient5/src/main/java/org/apache/hc/client5/http/utils/CodingException.java new file mode 100644 index 000000000..f22f3f409 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/utils/CodingException.java @@ -0,0 +1,77 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package org.apache.hc.client5.http.utils; + +import java.io.IOException; + +/** + * Signals that an error has occurred during encoding/decoding process. + *

+ * This exception is thrown to indicate that a problem occurred while + * encoding (such as URL encoding) or decoding data. It is a specific + * type of {@link IOException} that is used within the Apache HttpComponents + * to handle errors related to data transformation processes. + *

+ * + * @since 5.4 + */ +public class CodingException extends IOException { + + + private static final long serialVersionUID = 1668301205622354315L; + + /** + * Constructs a new CodingException with the specified detail message. + * + * @param message the detail message (which is saved for later retrieval + * by the {@link #getMessage()} method). + */ + public CodingException(final String message) { + super(message); + } + + /** + * Constructs a new CodingException with the specified detail message and cause. + *

+ * Note that the detail message associated with {@code cause} is not automatically + * incorporated into this exception's detail message. + *

+ * + * @param message the detail message (which is saved for later retrieval + * by the {@link #getMessage()} method). + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A {@code null} value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + */ + public CodingException(final String message, final Throwable cause) { + super(message, cause); + initCause(cause); + } + +} diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/entity/mime/HttpRFC7578MultipartTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/entity/mime/HttpRFC7578MultipartTest.java index 870482a1f..c1ff07eff 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/entity/mime/HttpRFC7578MultipartTest.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/entity/mime/HttpRFC7578MultipartTest.java @@ -29,20 +29,20 @@ package org.apache.hc.client5.http.entity.mime; import static org.junit.jupiter.api.Assertions.assertEquals; +import org.apache.hc.client5.http.impl.auth.RFC5987Codec; +import org.apache.hc.client5.http.utils.CodingException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; public class HttpRFC7578MultipartTest { @Test - public void testPercentDecodingWithTooShortMessage() throws Exception { - Assertions.assertThrows(java.lang.IllegalArgumentException.class, () -> - new HttpRFC7578Multipart.PercentCodec().decode("%".getBytes())); + public void testPercentDecodingWithTooShortMessage() { + Assertions.assertThrows(CodingException.class, () -> RFC5987Codec.decode("%")); } @Test public void testPercentDecodingWithValidMessages() throws Exception { - final HttpRFC7578Multipart.PercentCodec codec = new HttpRFC7578Multipart.PercentCodec(); final String[][] tests = new String[][] { {"test", "test"}, {"%20", " "}, @@ -54,7 +54,7 @@ public class HttpRFC7578MultipartTest { }; for (final String[] test : tests) { - assertEquals(test[1], new String(codec.decode(test[0].getBytes()))); + assertEquals(test[1], RFC5987Codec.decode(test[0])); } } diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/entity/mime/TestMultipartEntityBuilder.java b/httpclient5/src/test/java/org/apache/hc/client5/http/entity/mime/TestMultipartEntityBuilder.java index babdf1420..963327193 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/entity/mime/TestMultipartEntityBuilder.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/entity/mime/TestMultipartEntityBuilder.java @@ -254,4 +254,32 @@ public class TestMultipartEntityBuilder { "This is the epilogue.\r\n", out.toString(StandardCharsets.ISO_8859_1.name())); } + @Test + public void testMultipartWriteToRFC7578ModeWithFilenameStar() throws Exception { + final String helloWorld = "hello \u03BA\u03CC\u03C3\u03BC\u03B5!%"; + final List parameters = new ArrayList<>(); + parameters.add(new BasicNameValuePair(MimeConsts.FIELD_PARAM_NAME, "test")); + parameters.add(new BasicNameValuePair(MimeConsts.FIELD_PARAM_FILENAME_START, helloWorld)); + + final MultipartFormEntity entity = MultipartEntityBuilder.create() + .setMode(HttpMultipartMode.EXTENDED) + .setBoundary("xxxxxxxxxxxxxxxxxxxxxxxx") + .addPart(new FormBodyPartBuilder() + .setName("test") + .setBody(new StringBody(helloWorld, ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8))) + .addField("Content-Disposition", "multipart/form-data", parameters) + .build()) + .buildEntity(); + + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + entity.writeTo(out); + out.close(); + Assertions.assertEquals("--xxxxxxxxxxxxxxxxxxxxxxxx\r\n" + + "Content-Disposition: multipart/form-data; name=\"test\"; filename*=\"UTF-8''hello%20%CE%BA%CF%8C%CF%83%CE%BC%CE%B5!%25\"\r\n" + + "Content-Type: text/plain; charset=UTF-8\r\n" + + "\r\n" + + "hello \u00ce\u00ba\u00cf\u008c\u00cf\u0083\u00ce\u00bc\u00ce\u00b5!%\r\n" + + "--xxxxxxxxxxxxxxxxxxxxxxxx--\r\n", out.toString(StandardCharsets.ISO_8859_1.name())); + } + } diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/RFC5987CodecTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/RFC5987CodecTest.java new file mode 100644 index 000000000..45e3d59a3 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/RFC5987CodecTest.java @@ -0,0 +1,67 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.auth; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.stream.Stream; + +import org.apache.hc.client5.http.utils.CodingException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class RFC5987CodecTest { + + @ParameterizedTest + @MethodSource("params") + public void testRfc5987EncodingDecoding(final String input, final String expected) throws CodingException { + assertEquals(expected, RFC5987Codec.encode(input)); + assertEquals(input, RFC5987Codec.decode(expected)); + } + + static Stream params() { + return Stream.of( + new Object[]{"foo-ä-€.html", "foo-%C3%A4-%E2%82%AC.html"}, + new Object[]{"世界ーファイル 2.jpg", "%E4%B8%96%E7%95%8C%E3%83%BC%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%202.jpg"}, + new Object[]{"foo.jpg", "foo.jpg"}, + new Object[]{"simple", "simple"}, // Unreserved characters + new Object[]{"reserved/chars?", "reserved%2Fchars%3F"}, // Reserved characters + new Object[]{"", ""}, // Empty string + new Object[]{"space test", "space%20test"}, // String with space + new Object[]{"ümlaut", "%C3%BCmlaut"} // Non-ASCII characters + ); + } + + @Test + public void verifyRfc5987EncodingandDecoding() throws CodingException { + final String s = "!\"$£%^&*()_-+={[}]:@~;'#,./<>?\\|✓éèæðŃœ"; + assertThat(RFC5987Codec.decode(RFC5987Codec.encode(s)), equalTo(s)); + } +} diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java index 382212c83..560d70589 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java @@ -819,4 +819,91 @@ public class TestDigestScheme { final String response = table.get("response"); Assertions.assertNotNull(response); } + @Test + public void testDigestAuthenticationWithInvalidUsernameAndValidUsernameStar() throws Exception { + final ClassicHttpRequest request = new BasicClassicHttpRequest("POST", "/"); + final HttpHost host = new HttpHost("somehost", 80); + final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope(host, "realm1", null), "invalid\"username", "password".toCharArray()) + .build(); + + final String encodedUsername = "UTF-8''J%C3%A4s%C3%B8n%20Doe"; + final String challenge = StandardAuthScheme.DIGEST + " realm=\"realm1\", nonce=\"f2a3f18799759d4f1a1c068b92b573cb\", " + + "qop=\"auth-int\", username*=\"" + encodedUsername + "\""; + final AuthChallenge authChallenge = parse(challenge); + final DigestScheme authscheme = new DigestScheme(); + authscheme.processChallenge(authChallenge, null); + + Assertions.assertTrue(authscheme.isResponseReady(host, credentialsProvider, null)); + + final String authResponse = authscheme.generateAuthResponse(host, request, null); + Assertions.assertNotNull(authResponse); + } + + @Test + public void testDigestAuthenticationWithHighAsciiCharInUsername() throws Exception { + final ClassicHttpRequest request = new BasicClassicHttpRequest("POST", "/"); + final HttpHost host = new HttpHost("somehost", 80); + // Using a username with a high ASCII character + final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope(host, "realm1", null), "high\u007Fchar", "password".toCharArray()) + .build(); + + final String challenge = StandardAuthScheme.DIGEST + " realm=\"realm1\", nonce=\"f2a3f18799759d4f1a1c068b92b573cb\", qop=\"auth-int\""; + final AuthChallenge authChallenge = parse(challenge); + final DigestScheme authscheme = new DigestScheme(); + authscheme.processChallenge(authChallenge, null); + + Assertions.assertTrue(authscheme.isResponseReady(host, credentialsProvider, null)); + final String authResponse = authscheme.generateAuthResponse(host, request, null); + + // Optionally, verify that 'username*' is used in the response + Assertions.assertTrue(authResponse.contains("username*")); + } + + + @Test + public void testDigestAuthenticationWithExtendedAsciiCharInUsername() throws Exception { + final ClassicHttpRequest request = new BasicClassicHttpRequest("POST", "/"); + final HttpHost host = new HttpHost("somehost", 80); + // Using an extended ASCII character (e.g., 0x80) in the username + final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope(host, "realm1", null), "username\u0080", "password".toCharArray()) + .build(); + + final String challenge = StandardAuthScheme.DIGEST + " realm=\"realm1\", nonce=\"f2a3f18799759d4f1a1c068b92b573cb\", qop=\"auth-int\""; + final AuthChallenge authChallenge = parse(challenge); + final DigestScheme authscheme = new DigestScheme(); + authscheme.processChallenge(authChallenge, null); + + Assertions.assertTrue(authscheme.isResponseReady(host, credentialsProvider, null)); + final String authResponse = authscheme.generateAuthResponse(host, request, null); + + + Assertions.assertTrue(authResponse.contains("username*")); + } + + + @Test + public void testDigestAuthenticationWithNonAsciiUsername() throws Exception { + final ClassicHttpRequest request = new BasicClassicHttpRequest("POST", "/"); + final HttpHost host = new HttpHost("somehost", 80); + // Using a username with non-ASCII characters + final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope(host, "realm1", null), "Jäsøn Doe", "password".toCharArray()) + .build(); + + final String challenge = StandardAuthScheme.DIGEST + " realm=\"realm1\", nonce=\"f2a3f18799759d4f1a1c068b92b573cb\", qop=\"auth-int\""; + final AuthChallenge authChallenge = parse(challenge); + final DigestScheme authscheme = new DigestScheme(); + authscheme.processChallenge(authChallenge, null); + + Assertions.assertTrue(authscheme.isResponseReady(host, credentialsProvider, null)); + final String authResponse = authscheme.generateAuthResponse(host, request, null); + + // Optionally, verify that 'username*' is used in the response + Assertions.assertTrue(authResponse.contains("username*")); + } + + }