Skip to content

Commit

Permalink
Implement username* validation and decoding in DigestScheme (#511)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
arturobernalg authored Dec 8, 2023
1 parent da6d60f commit d03511c
Show file tree
Hide file tree
Showing 9 changed files with 500 additions and 115 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<MultipartPart> parts;

/**
Expand Down Expand Up @@ -99,8 +94,12 @@ protected void formatMultipartHeader(final MultipartPart part, final OutputStrea
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);
}
Expand All @@ -114,98 +113,4 @@ protected void formatMultipartHeader(final MultipartPart part, final OutputStrea
}
}

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));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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*";

}
Original file line number Diff line number Diff line change
Expand Up @@ -341,17 +341,30 @@ private String createDigestResponse(final HttpRequest request) throws Authentica
}
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 )
Expand Down Expand Up @@ -426,7 +439,17 @@ private String createDigestResponse(final HttpRequest request) throws Authentica
buffer.append(StandardAuthScheme.DIGEST + " ");

final List<BasicNameValuePair> 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));
Expand All @@ -444,10 +467,6 @@ private String createDigestResponse(final HttpRequest request) throws Authentica
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) {
Expand Down Expand Up @@ -529,4 +548,44 @@ private void readObject(final ObjectInputStream in) throws IOException, ClassNot
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.
* <p>
* The method checks for:
* - Control characters (ASCII 0x00 to 0x1F and 0x7F).
* - Characters outside the printable ASCII range (above 0x7E).
* - Double quotes (&quot;) and backslashes (\), which are not allowed.
* </p>
*
* @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;
}
}
Loading

0 comments on commit d03511c

Please sign in to comment.