This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-codec.git
The following commit(s) were added to refs/heads/master by this push:
new 43a9aa66 Restrict Hex decoding to ASCII hexadecimal characters
43a9aa66 is described below
commit 43a9aa663c1b10b32a7cc6bbc1eccc10bba5f9be
Author: Gary Gregory <[email protected]>
AuthorDate: Fri Sep 18 06:23:45 2026 -0700
Restrict Hex decoding to ASCII hexadecimal characters
Reject non-ASCII Unicode digits and fullwidth letters. Add regression
coverage for character, UTF-8 byte array, and ByteBuffer inputs, clarify
the security implications, and document the compatibility change.
---
src/changes/changes.xml | 1 +
.../java/org/apache/commons/codec/binary/Hex.java | 26 ++++++++++++++++++++--
.../org/apache/commons/codec/binary/HexTest.java | 16 +++++++++++++
3 files changed, 41 insertions(+), 2 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index e59c6bcf..f8775ae7 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -60,6 +60,7 @@ The <action> type attribute can be add,update,fix,remove.
<action type="fix" dev="ggregory" due-to="Gary Gregory">Implement Base58
encoded-length calculation and explicitly reject unsupported line
chunking.</action>
<action type="fix" dev="ggregory" due-to="Gary Gregory">Close the
underlying BaseNCodecOutputStream output even when final conversion or flushing
fails, preserving suppressed close exceptions.</action>
<action type="fix" dev="ggregory" due-to="Gary Gregory">Reject invalid
GitIdentifiers tree entry names and file/directory name conflicts to prevent
ambiguous tree serialization and colliding identifiers.</action>
+ <action type="fix" dev="ggregory" due-to="Gary Gregory">Hex decoding now
accepts only ASCII hexadecimal characters (0-9, A-F, a-f). Previously accepted
non-ASCII Unicode digits and fullwidth letters now cause DecoderException,
including when supplied as UTF-8 bytes or ByteBuffers.</action>
<!-- ADD -->
<action type="add" dev="ggregory" due-to="Gary Gregory">Add and use
PhoneticEngine.Builder and deprecate old constructors.</action>
<action type="add" dev="ggregory" due-to="Gary Gregory">Add
BeiderMorseEncoder.Builder and deprecate old constructor.</action>
diff --git a/src/main/java/org/apache/commons/codec/binary/Hex.java
b/src/main/java/org/apache/commons/codec/binary/Hex.java
index 4ce48516..ac293aa3 100644
--- a/src/main/java/org/apache/commons/codec/binary/Hex.java
+++ b/src/main/java/org/apache/commons/codec/binary/Hex.java
@@ -31,7 +31,14 @@ import org.apache.commons.codec.EncoderException;
* Converts hexadecimal Strings. The Charset used for certain operation can be
set, the default is set in
* {@link #DEFAULT_CHARSET_NAME}
*
+ * <p>
+ * Decoding accepts only the ASCII hexadecimal characters {@code 0-9}, {@code
A-F}, and {@code a-f}. Non-ASCII Unicode digits and fullwidth letters
+ * are rejected.
+ * </p>
+ *
+ * <p>
* This class is thread-safe.
+ * </p>
*
* @since 1.1
*/
@@ -357,15 +364,30 @@ public class Hex implements BinaryEncoder, BinaryDecoder {
/**
* Converts a hexadecimal character to an integer.
*
+ * <p>
+ * Only the ASCII characters {@code '0'} to {@code '9'}, {@code 'A'} to
{@code 'F'} and {@code 'a'} to {@code 'f'} are accepted. Other Unicode digits,
+ * such as fullwidth or Arabic-Indic digits, are rejected even though
{@link Character#digit(char, int)} would accept them. These alternate spellings
+ * can bypass textual blocklists or replay caches that compare hexadecimal
strings without decoding or normalizing them first.
+ * </p>
+ *
* @param ch A character to convert to an integer digit.
* @param index The index of the character in the source.
* @return An integer.
* @throws DecoderException Thrown if ch is an illegal hexadecimal
character.
*/
protected static int toDigit(final char ch, final int index) throws
DecoderException {
- final int digit = Character.digit(ch, 16);
+ final int digit;
+ if (ch >= '0' && ch <= '9') {
+ digit = ch - '0';
+ } else if (ch >= 'A' && ch <= 'F') {
+ digit = ch - 'A' + 10;
+ } else if (ch >= 'a' && ch <= 'f') {
+ digit = ch - 'a' + 10;
+ } else {
+ digit = -1;
+ }
if (digit == -1) {
- throw new DecoderException("Illegal hexadecimal character 0x%02X
at index %,d.", ch & 0xFF, index);
+ throw new DecoderException("Illegal hexadecimal character 0x%02X
at index %,d.", ch & 0xFFFF, index);
}
return digit;
}
diff --git a/src/test/java/org/apache/commons/codec/binary/HexTest.java
b/src/test/java/org/apache/commons/codec/binary/HexTest.java
index 9f4556f5..cdf17e79 100644
--- a/src/test/java/org/apache/commons/codec/binary/HexTest.java
+++ b/src/test/java/org/apache/commons/codec/binary/HexTest.java
@@ -36,6 +36,7 @@ import org.apache.commons.codec.EncoderException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
/**
* Tests {@link Hex}.
@@ -329,6 +330,21 @@ class HexTest {
checkDecodeHexCharArrayOddCharacters("A");
}
+ @ParameterizedTest
+ @ValueSource(strings = { "\uFF14\uFF11", "\u0664\u0661", "\u096A\u0967",
"\uFF21\uFF26", "\uFF41\uFF46", "4\uFF11" })
+ void testDecodeNonAsciiUnicodeDigits(final String input) {
+ // Alternate spellings must not decode to the same bytes as ASCII
hexadecimal strings.
+ assertThrows(DecoderException.class, () -> Hex.decodeHex(input));
+ assertThrows(DecoderException.class, () ->
Hex.decodeHex(input.toCharArray()));
+ assertThrows(DecoderException.class, () ->
Hex.decodeHex(input.toCharArray(), new byte[input.length() / 2], 0));
+ assertThrows(DecoderException.class, () -> new Hex().decode(input));
+ final byte[] utf8 = input.getBytes(StandardCharsets.UTF_8);
+ assertThrows(DecoderException.class, () -> new Hex().decode(utf8));
+ final ByteBuffer buffer = allocate(utf8.length);
+ buffer.put(utf8).flip();
+ assertThrows(DecoderException.class, () -> new Hex().decode(buffer));
+ }
+
@Test
void testDecodeStringEmpty() throws DecoderException {
assertArrayEquals(new byte[0], (byte[]) new Hex().decode(""));