This is an automated email from the ASF dual-hosted git repository.

FrankChen021 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 8803f6cbb68 fix: validate V1 header count and value offsets in 
GenericIndexed (#20447)
8803f6cbb68 is described below

commit 8803f6cbb684890621aad152ee6cf4c5b110aa46
Author: dfengliu <[email protected]>
AuthorDate: Tue Oct 20 20:18:56 2026 +0800

    fix: validate V1 header count and value offsets in GenericIndexed (#20447)
    
    * fix: validate V1 header count and value offsets in GenericIndexed
    
    A crafted V1 header let a negative or overflowing element count through,
    and a malformed offset table moved reads out of the value region, surfacing
    as raw JVM exceptions (BufferUnderflowException, NegativeArraySizeException,
    IndexOutOfBoundsException) or silent misreads of header bytes. Reject
    non-negative counts and out-of-range offsets with descriptive errors.
    
    * fix: validate GenericIndexed offsets against a stable bound
    
    The singleThreaded value buffer is reused across calls and its limit is
    narrowed to the previous value's end. Bounds-checking against the mutable
    limit made rightward binary-search probes in the UTF-8 dictionary lookup
    path fail for valid sorted dictionaries. Validate against the original
    value-region limit captured when the view is created, and add regression
    tests for rightward probes and successive lookups.
    
    * fix: validate relative header offsets in GenericIndexed
    
    Validate each element's offsets against the header table before adding the
    V1 base offset. Tables whose previous end offset falls below the four-byte
    value-size marker (e.g. [0, 10]) previously let get() decode bytes outside
    the declared element; they are now rejected with a descriptive error on all
    four read paths (V1/V2, cached and single-threaded).
    
    * fix: read the null/empty marker through a stable view in GenericIndexed
    
    The singleThreaded value buffer is reused and its limit is narrowed by
    earlier lookups, so the absolute null/empty marker read could throw
    IndexOutOfBoundsException for a later in-range zero-length or null value.
    Read the marker through a dedicated view captured at the stable original
    limit, and add a successive-lookup regression test.
---
 .../apache/druid/segment/data/GenericIndexed.java  |  55 +++++-
 .../data/GenericIndexedMalformedHeaderTest.java    | 193 +++++++++++++++++++++
 2 files changed, 243 insertions(+), 5 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/segment/data/GenericIndexed.java 
b/processing/src/main/java/org/apache/druid/segment/data/GenericIndexed.java
index 1225800c4b8..e888b18e3a7 100644
--- a/processing/src/main/java/org/apache/druid/segment/data/GenericIndexed.java
+++ b/processing/src/main/java/org/apache/druid/segment/data/GenericIndexed.java
@@ -19,6 +19,7 @@
 
 package org.apache.druid.segment.data;
 
+import com.google.common.base.Preconditions;
 import com.google.common.primitives.Ints;
 import org.apache.druid.collections.ResourceHolder;
 import org.apache.druid.common.utils.SerializerUtils;
@@ -282,9 +283,16 @@ public abstract class GenericIndexed<T> implements 
CloseableIndexed<T>, Serializ
     )
     {
       super(strategy, allowReverseLookup, buffer.getInt());
+      Preconditions.checkArgument(size >= 0, "size[%s] must be non-negative", 
size);
       this.theBuffer = buffer;
       this.headerOffset = theBuffer.position();
-      this.valuesOffset = theBuffer.position() + size * Integer.BYTES;
+      final long valuesOffsetLong = (long) headerOffset + (long) size * 
Integer.BYTES;
+      Preconditions.checkArgument(
+          valuesOffsetLong <= theBuffer.limit(),
+          "size[%s] exceeds the available buffer",
+          size
+      );
+      this.valuesOffset = (int) valuesOffsetLong;
     }
 
     @Nullable
@@ -304,6 +312,7 @@ public abstract class GenericIndexed<T> implements 
CloseableIndexed<T>, Serializ
         startOffset = theBuffer.getInt(headerOffset + headerPosition) + 
Integer.BYTES;
         endOffset = theBuffer.getInt(headerOffset + headerPosition + 
Integer.BYTES);
       }
+      validateRelativeOffsets(index != 0, startOffset, endOffset);
       return copyBufferAndGet(theBuffer, valuesOffset + startOffset, 
valuesOffset + endOffset);
     }
 
@@ -311,6 +320,8 @@ public abstract class GenericIndexed<T> implements 
CloseableIndexed<T>, Serializ
     public BufferIndexed singleThreaded()
     {
       final ByteBuffer copyBuffer = theBuffer.asReadOnlyBuffer();
+      final int bufferLimit = copyBuffer.limit();
+      final ByteBuffer markerView = theBuffer.asReadOnlyBuffer();
       return new BufferIndexed()
       {
         @Nullable
@@ -330,7 +341,8 @@ public abstract class GenericIndexed<T> implements 
CloseableIndexed<T>, Serializ
             startOffset = theBuffer.getInt(headerOffset + headerPosition) + 
Integer.BYTES;
             endOffset = theBuffer.getInt(headerOffset + headerPosition + 
Integer.BYTES);
           }
-          return bufferedIndexedGetByteBuffer(copyBuffer, valuesOffset + 
startOffset, valuesOffset + endOffset);
+          validateRelativeOffsets(index != 0, startOffset, endOffset);
+          return bufferedIndexedGetByteBuffer(copyBuffer, markerView, 
valuesOffset + startOffset, valuesOffset + endOffset, bufferLimit);
         }
 
         @Override
@@ -407,6 +419,7 @@ public abstract class GenericIndexed<T> implements 
CloseableIndexed<T>, Serializ
         startOffset = headerBuffer.getInt(headerPosition) + Integer.BYTES;
         endOffset = headerBuffer.getInt(headerPosition + Integer.BYTES);
       }
+      validateRelativeOffsets(relativePositionOfIndex != 0, startOffset, 
endOffset);
       int fileNum = index >> logBaseTwoOfElementsPerValueFile;
       return copyBufferAndGet(valueBuffers[fileNum], startOffset, endOffset);
     }
@@ -415,8 +428,12 @@ public abstract class GenericIndexed<T> implements 
CloseableIndexed<T>, Serializ
     public BufferIndexed singleThreaded()
     {
       final ByteBuffer[] copyValueBuffers = new 
ByteBuffer[valueBuffers.length];
+      final ByteBuffer[] markerViews = new ByteBuffer[valueBuffers.length];
+      final int[] valueBufferLimits = new int[valueBuffers.length];
       for (int i = 0; i < valueBuffers.length; i++) {
         copyValueBuffers[i] = valueBuffers[i].asReadOnlyBuffer();
+        markerViews[i] = valueBuffers[i].asReadOnlyBuffer();
+        valueBufferLimits[i] = copyValueBuffers[i].limit();
       }
 
       return new BufferIndexed()
@@ -440,8 +457,9 @@ public abstract class GenericIndexed<T> implements 
CloseableIndexed<T>, Serializ
             startOffset = headerBuffer.getInt(headerPosition) + Integer.BYTES;
             endOffset = headerBuffer.getInt(headerPosition + Integer.BYTES);
           }
+          validateRelativeOffsets(relativePositionOfIndex != 0, startOffset, 
endOffset);
           int fileNum = index >> logBaseTwoOfElementsPerValueFile;
-          return bufferedIndexedGetByteBuffer(copyValueBuffers[fileNum], 
startOffset, endOffset);
+          return bufferedIndexedGetByteBuffer(copyValueBuffers[fileNum], 
markerViews[fileNum], startOffset, endOffset, valueBufferLimits[fileNum]);
         }
 
         @Override
@@ -559,11 +577,30 @@ public abstract class GenericIndexed<T> implements 
CloseableIndexed<T>, Serializ
     return IndexedIterable.create(this).iterator();
   }
 
+  private static void validateRelativeOffsets(boolean hasPrevious, int 
startOffset, int endOffset)
+  {
+    if (endOffset < startOffset || (hasPrevious && startOffset - Integer.BYTES 
< Integer.BYTES)) {
+      throw new IAE(
+          "value offsets out of bounds: startOffset[%s], endOffset[%s]",
+          startOffset,
+          endOffset
+      );
+    }
+  }
+
   @Nullable
   protected T copyBufferAndGet(ByteBuffer valueBuffer, int startOffset, int 
endOffset)
   {
     ByteBuffer copyValueBuffer = valueBuffer.asReadOnlyBuffer();
     int size = endOffset - startOffset;
+    if (startOffset < Integer.BYTES || endOffset < startOffset || endOffset > 
copyValueBuffer.limit()) {
+      throw new IAE(
+          "value offsets out of bounds: startOffset[%s], endOffset[%s], 
limit[%s]",
+          startOffset,
+          endOffset,
+          copyValueBuffer.limit()
+      );
+    }
     if (size == 0 && (copyValueBuffer.get(startOffset - Integer.BYTES) == 
NULL_VALUE_SIZE_MARKER)) {
       return null;
     }
@@ -599,10 +636,18 @@ public abstract class GenericIndexed<T> implements 
CloseableIndexed<T>, Serializ
     }
 
     @Nullable
-    ByteBuffer bufferedIndexedGetByteBuffer(ByteBuffer copyValueBuffer, int 
startOffset, int endOffset)
+    ByteBuffer bufferedIndexedGetByteBuffer(ByteBuffer copyValueBuffer, 
ByteBuffer markerView, int startOffset, int endOffset, int bufferLimit)
     {
       int size = endOffset - startOffset;
-      if (size == 0 && (copyValueBuffer.get(startOffset - Integer.BYTES) == 
NULL_VALUE_SIZE_MARKER)) {
+      if (startOffset < Integer.BYTES || endOffset < startOffset || endOffset 
> bufferLimit) {
+        throw new IAE(
+            "value offsets out of bounds: startOffset[%s], endOffset[%s], 
limit[%s]",
+            startOffset,
+            endOffset,
+            bufferLimit
+        );
+      }
+      if (size == 0 && (markerView.get(startOffset - Integer.BYTES) == 
NULL_VALUE_SIZE_MARKER)) {
         return null;
       }
 
diff --git 
a/processing/src/test/java/org/apache/druid/segment/data/GenericIndexedMalformedHeaderTest.java
 
b/processing/src/test/java/org/apache/druid/segment/data/GenericIndexedMalformedHeaderTest.java
new file mode 100644
index 00000000000..90191466092
--- /dev/null
+++ 
b/processing/src/test/java/org/apache/druid/segment/data/GenericIndexedMalformedHeaderTest.java
@@ -0,0 +1,193 @@
+/*
+ * 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.
+ */
+
+package org.apache.druid.segment.data;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+public class GenericIndexedMalformedHeaderTest
+{
+  private static ByteBuffer buildV1(int numElements, int[] offsets, byte[] 
values, int numBytesUsedOverride)
+  {
+    final int valuesBytes = values.length;
+    final int numBytesUsed = numBytesUsedOverride >= 0 ? numBytesUsedOverride 
: 8 + numElements * 4 + valuesBytes;
+    final ByteBuffer buffer = 
ByteBuffer.allocate(64).order(ByteOrder.BIG_ENDIAN);
+    buffer.put((byte) 0x1); // VERSION_ONE
+    buffer.put((byte) 0x0); // allowReverseLookup
+    buffer.putInt(numBytesUsed);
+    buffer.putInt(numElements);
+    for (int offset : offsets) {
+      buffer.putInt(offset);
+    }
+    buffer.put(values);
+    buffer.flip();
+    buffer.limit(buffer.capacity());
+    return buffer;
+  }
+
+  @Test
+  public void testNegativeCountRejected()
+  {
+    final IllegalArgumentException e = Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> GenericIndexed.read(buildV1(-5, new int[]{}, new byte[]{1, 2, 
3}, 8), GenericIndexed.STRING_STRATEGY, null)
+    );
+    Assertions.assertTrue(e.getMessage().contains("must be non-negative"), 
e.getMessage());
+  }
+
+  @Test
+  public void testOverflowCountRejected()
+  {
+    final IllegalArgumentException e = Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> GenericIndexed.read(buildV1(0x40000001, new int[]{}, new 
byte[]{}, 8), GenericIndexed.STRING_STRATEGY, null)
+    );
+    Assertions.assertTrue(e.getMessage().contains("exceeds the available 
buffer"), e.getMessage());
+  }
+
+  @Test
+  public void testHugeOffsetRejected()
+  {
+    GenericIndexed<String> indexed = GenericIndexed.read(
+        buildV1(2, new int[]{6, 1000}, new byte[]{0, 0, 0, 2, 'a', 'b', 0, 0, 
0, 2, 'c', 'd'}, -1),
+        GenericIndexed.STRING_STRATEGY,
+        null
+    );
+    Assertions.assertEquals("ab", indexed.get(0));
+    final IllegalArgumentException e = Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> indexed.get(1)
+    );
+    Assertions.assertTrue(e.getMessage().contains("out of bounds"), 
e.getMessage());
+  }
+
+  @Test
+  public void testNegativeOffsetRejected()
+  {
+    GenericIndexed<String> indexed = GenericIndexed.read(
+        buildV1(2, new int[]{-5, 10}, new byte[]{0, 0, 0, 2, 'x', 'y', 0, 0, 
0, 2, 'p', 'q'}, -1),
+        GenericIndexed.STRING_STRATEGY,
+        null
+    );
+    final IllegalArgumentException e = Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> indexed.get(0)
+    );
+    Assertions.assertTrue(e.getMessage().contains("out of bounds"), 
e.getMessage());
+  }
+
+  @Test
+  public void testValidHeaderReads()
+  {
+    GenericIndexed<String> indexed = GenericIndexed.read(
+        buildV1(1, new int[]{6}, new byte[]{0, 0, 0, 2, 'h', 'i'}, -1),
+        GenericIndexed.STRING_STRATEGY,
+        null
+    );
+    Assertions.assertEquals(1, indexed.size());
+    Assertions.assertEquals("hi", indexed.get(0));
+  }
+
+  @Test
+  public void testSingleThreadedRightwardProbes()
+  {
+    GenericIndexed<String> indexed = GenericIndexed.fromIterable(
+        java.util.Arrays.asList("a", "b", "c"),
+        GenericIndexed.STRING_STRATEGY
+    );
+    GenericIndexed<String>.BufferIndexed bi = indexed.singleThreaded();
+
+    Assertions.assertDoesNotThrow(() -> bi.getByteBuffer(0));
+    // A rightward binary-search probe must not be rejected by the bounds check
+    // after an earlier probe narrowed the reused buffer's limit.
+    Assertions.assertDoesNotThrow(() -> bi.getByteBuffer(2));
+    Assertions.assertDoesNotThrow(() -> bi.getByteBuffer(1));
+  }
+
+  @Test
+  public void testSingleThreadedSuccessiveLookups()
+  {
+    GenericIndexed<String> indexed = GenericIndexed.fromIterable(
+        java.util.Arrays.asList("a", "b", "c"),
+        GenericIndexed.STRING_STRATEGY
+    );
+    GenericIndexed<String>.BufferIndexed bi = indexed.singleThreaded();
+
+    Assertions.assertDoesNotThrow(() -> bi.getByteBuffer(1));
+    final ByteBuffer second = bi.getByteBuffer(2);
+    Assertions.assertEquals(1, second.remaining());
+    Assertions.assertEquals((byte) 'c', second.get());
+    final ByteBuffer first = bi.getByteBuffer(0);
+    Assertions.assertEquals(1, first.remaining());
+    Assertions.assertEquals((byte) 'a', first.get());
+  }
+
+  @Test
+  public void testIntermediateOffsetGapRejected()
+  {
+    GenericIndexed<String> indexed = GenericIndexed.read(
+        buildV1(2, new int[]{0, 10}, new byte[]{0, 0, 0, 2, 'x', 'y', 0, 0, 0, 
2, 'p', 'q'}, -1),
+        GenericIndexed.STRING_STRATEGY,
+        null
+    );
+    // offsets[0] == 0 leaves the first value without its four-byte size 
marker,
+    // so a direct get(1) must reject the header instead of decoding outside 
the element.
+    final IllegalArgumentException e = Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> indexed.get(1)
+    );
+    Assertions.assertTrue(e.getMessage().contains("out of bounds"), 
e.getMessage());
+  }
+
+  @Test
+  public void testSingleThreadedEmptyAndNullValues()
+  {
+    GenericIndexed<String> indexed = GenericIndexed.fromIterable(
+        java.util.Arrays.asList("a", "", null),
+        GenericIndexed.STRING_STRATEGY
+    );
+    GenericIndexed<String>.BufferIndexed bi = indexed.singleThreaded();
+
+    // An earlier direct lookup narrows the reused buffer's limit; later 
empty/null
+    // marker reads must not throw IndexOutOfBoundsException because of it.
+    Assertions.assertDoesNotThrow(() -> bi.getByteBuffer(0));
+    final ByteBuffer empty = bi.getByteBuffer(1);
+    Assertions.assertEquals(0, empty.remaining());
+    Assertions.assertNull(bi.getByteBuffer(2));
+  }
+
+  @Test
+  public void testSingleThreadedIntermediateOffsetGapRejected()
+  {
+    GenericIndexed<String> indexed = GenericIndexed.read(
+        buildV1(2, new int[]{0, 10}, new byte[]{0, 0, 0, 2, 'x', 'y', 0, 0, 0, 
2, 'p', 'q'}, -1),
+        GenericIndexed.STRING_STRATEGY,
+        null
+    );
+    final IllegalArgumentException e = Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> indexed.singleThreaded().getByteBuffer(1)
+    );
+    Assertions.assertTrue(e.getMessage().contains("out of bounds"), 
e.getMessage());
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to