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(""));

Reply via email to