-
Notifications
You must be signed in to change notification settings - Fork 3.4k
API: Harden variant binary parsing against malformed input #16568
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6cd1f92
94b3ff7
28b8d7e
63e0283
cd133ee
e465ed9
b99f75d
cea6d79
76c6409
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,29 +36,50 @@ static SerializedArray from(VariantMetadata metadata, byte[] bytes) { | |
| } | ||
|
|
||
| static SerializedArray from(VariantMetadata metadata, ByteBuffer value, int header) { | ||
| return from(metadata, value, header, 0); | ||
| } | ||
|
|
||
| static SerializedArray from(VariantMetadata metadata, ByteBuffer value, int header, int depth) { | ||
| Preconditions.checkArgument( | ||
| value.order() == ByteOrder.LITTLE_ENDIAN, "Unsupported byte order: big endian"); | ||
| BasicType basicType = VariantUtil.basicType(header); | ||
| Preconditions.checkArgument( | ||
| basicType == BasicType.ARRAY, "Invalid array, basic type: " + basicType); | ||
| return new SerializedArray(metadata, value, header); | ||
| return new SerializedArray(metadata, value, header, depth); | ||
| } | ||
|
|
||
| private final VariantMetadata metadata; | ||
| private final ByteBuffer value; | ||
| private final int offsetSize; | ||
| private final int offsetListOffset; | ||
| private final int dataOffset; | ||
| private final int depth; | ||
| private final VariantValue[] array; | ||
|
|
||
| private SerializedArray(VariantMetadata metadata, ByteBuffer value, int header) { | ||
| private SerializedArray(VariantMetadata metadata, ByteBuffer value, int header, int depth) { | ||
| this.metadata = metadata; | ||
| this.value = value; | ||
| this.depth = depth; | ||
| this.offsetSize = 1 + ((header & OFFSET_SIZE_MASK) >> OFFSET_SIZE_SHIFT); | ||
| int numElementsSize = ((header & IS_LARGE) == IS_LARGE) ? 4 : 1; | ||
| Preconditions.checkArgument( | ||
| value.remaining() >= HEADER_SIZE + numElementsSize, | ||
| "Invalid variant array: buffer too small for element count field"); | ||
| int numElements = ByteBuffers.readLittleEndianUnsigned(value, HEADER_SIZE, numElementsSize); | ||
| Preconditions.checkArgument( | ||
| numElements >= 0, "Invalid variant array: negative element count %s", numElements); | ||
| Preconditions.checkArgument( | ||
| numElements <= VariantUtil.MAX_ELEMENTS, | ||
| "Invalid variant array: element count %s exceeds maximum %s", | ||
| numElements, | ||
| VariantUtil.MAX_ELEMENTS); | ||
| this.offsetListOffset = HEADER_SIZE + numElementsSize; | ||
| this.dataOffset = offsetListOffset + ((1 + numElements) * offsetSize); | ||
| long offsetTableEnd = (long) offsetListOffset + ((long) numElements + 1L) * offsetSize; | ||
| Preconditions.checkArgument( | ||
| offsetTableEnd <= value.remaining(), | ||
|
nssalian marked this conversation as resolved.
|
||
| "Invalid variant array: element count %s exceeds buffer", | ||
| numElements); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've just realised that this and the parquet-java hardening don't worry about leftover data. "don't do that" is implicit the policy there, being as it is useless. I wonder what the rust parquet reader does.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will be matching parquet-java's implicit "don't do that" stance. Happy to revisit if the dev list lands on rejecting trailing bytes. |
||
| this.dataOffset = Math.toIntExact(offsetTableEnd); | ||
| this.array = new VariantValue[numElements]; | ||
| } | ||
|
|
||
|
|
@@ -76,8 +97,16 @@ public VariantValue get(int index) { | |
| int next = | ||
| ByteBuffers.readLittleEndianUnsigned( | ||
| value, offsetListOffset + (offsetSize * (1 + index)), offsetSize); | ||
| long dataLen = value.remaining() - (long) dataOffset; | ||
| Preconditions.checkArgument( | ||
| offset >= 0 && next >= offset && next <= dataLen, | ||
| "Invalid variant array: offset range [%s, %s] out of data region [0, %s]", | ||
| offset, | ||
| next, | ||
| dataLen); | ||
| array[index] = | ||
| VariantValue.from(metadata, VariantUtil.slice(value, dataOffset + offset, next - offset)); | ||
| VariantUtil.fromBuffer( | ||
| metadata, VariantUtil.slice(value, dataOffset + offset, next - offset), depth + 1); | ||
| } | ||
| return array[index]; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,12 +42,16 @@ static SerializedObject from(VariantMetadata metadata, byte[] bytes) { | |
| } | ||
|
|
||
| static SerializedObject from(VariantMetadata metadata, ByteBuffer value, int header) { | ||
| return from(metadata, value, header, 0); | ||
| } | ||
|
|
||
| static SerializedObject from(VariantMetadata metadata, ByteBuffer value, int header, int depth) { | ||
| Preconditions.checkArgument( | ||
| value.order() == ByteOrder.LITTLE_ENDIAN, "Unsupported byte order: big endian"); | ||
| BasicType basicType = VariantUtil.basicType(header); | ||
| Preconditions.checkArgument( | ||
| basicType == BasicType.OBJECT, "Invalid object, basic type: " + basicType); | ||
| return new SerializedObject(metadata, value, header); | ||
| return new SerializedObject(metadata, value, header, depth); | ||
| } | ||
|
|
||
| private final VariantMetadata metadata; | ||
|
|
@@ -60,21 +64,42 @@ static SerializedObject from(VariantMetadata metadata, ByteBuffer value, int hea | |
| private final int[] offsets; | ||
| private final int[] lengths; | ||
| private final int dataOffset; | ||
| private final int depth; | ||
| private final VariantValue[] values; | ||
|
|
||
| private SerializedObject(VariantMetadata metadata, ByteBuffer value, int header) { | ||
| private SerializedObject(VariantMetadata metadata, ByteBuffer value, int header, int depth) { | ||
| this.metadata = metadata; | ||
| this.value = value; | ||
| this.depth = depth; | ||
| this.offsetSize = 1 + ((header & OFFSET_SIZE_MASK) >> OFFSET_SIZE_SHIFT); | ||
| this.fieldIdSize = 1 + ((header & FIELD_ID_SIZE_MASK) >> FIELD_ID_SIZE_SHIFT); | ||
| int numElementsSize = ((header & IS_LARGE) == IS_LARGE) ? 4 : 1; | ||
| Preconditions.checkArgument( | ||
| value.remaining() >= HEADER_SIZE + numElementsSize, | ||
| "Invalid variant object: buffer too small for element count field"); | ||
| int numElements = ByteBuffers.readLittleEndianUnsigned(value, HEADER_SIZE, numElementsSize); | ||
| Preconditions.checkArgument( | ||
| numElements >= 0, "Invalid variant object: negative element count %s", numElements); | ||
| Preconditions.checkArgument( | ||
| numElements <= VariantUtil.MAX_ELEMENTS, | ||
| "Invalid variant object: element count %s exceeds maximum %s", | ||
| numElements, | ||
| VariantUtil.MAX_ELEMENTS); | ||
| this.fieldIdListOffset = HEADER_SIZE + numElementsSize; | ||
| long dataStart = | ||
| (long) fieldIdListOffset | ||
| + (long) numElements * fieldIdSize | ||
| + ((long) numElements + 1L) * offsetSize; | ||
| Preconditions.checkArgument( | ||
| dataStart <= value.remaining(), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same int-overflow as the array side: I'd derive both offsets from the
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed - both offsets are now derived from a long dataStart intermediate and Math.toIntExact-ed, so the guard protects the values we use downstream. |
||
| "Invalid variant object: element count %s exceeds buffer", | ||
| numElements); | ||
| this.offsetListOffset = | ||
| Math.toIntExact((long) fieldIdListOffset + (long) numElements * fieldIdSize); | ||
| this.fieldIds = new Integer[numElements]; | ||
| this.offsetListOffset = fieldIdListOffset + (numElements * fieldIdSize); | ||
| this.offsets = new int[numElements]; | ||
| this.lengths = new int[numElements]; | ||
| this.dataOffset = offsetListOffset + ((1 + numElements) * offsetSize); | ||
| this.dataOffset = Math.toIntExact(dataStart); | ||
| this.values = new VariantValue[numElements]; | ||
|
|
||
| if (numElements > 0) { | ||
|
|
@@ -96,14 +121,28 @@ private void initOffsetsAndLengths(int numElements) { | |
| int dataLength = | ||
| ByteBuffers.readLittleEndianUnsigned( | ||
| value, offsetListOffset + (numElements * offsetSize), offsetSize); | ||
| long dataLen = value.remaining() - (long) dataOffset; | ||
| Preconditions.checkArgument( | ||
| dataLength >= 0 && dataLength <= dataLen, | ||
| "Invalid variant object: data length %s out of data region [0, %s]", | ||
| dataLength, | ||
| dataLen); | ||
| for (int index = 0; index < numElements; index += 1) { | ||
| Preconditions.checkArgument( | ||
| offsets[index] >= 0 && offsets[index] <= dataLength, | ||
| "Invalid variant object: offset %s out of declared data length %s", | ||
| offsets[index], | ||
| dataLength); | ||
| } | ||
| offsetToLength.put(dataLength, 0); | ||
|
|
||
| // populate lengths list by sorting offsets | ||
| List<Integer> sortedOffsets = | ||
| offsetToLength.keySet().stream().sorted().collect(Collectors.toList()); | ||
| for (int index = 0; index < numElements; index += 1) { | ||
| int offset = sortedOffsets.get(index); | ||
| int length = sortedOffsets.get(index + 1) - offset; | ||
| // shared offsets are spec-legal (zero-length spans); do not require uniqueness | ||
| for (int i = 0; i < sortedOffsets.size() - 1; i += 1) { | ||
| int offset = sortedOffsets.get(i); | ||
| int length = sortedOffsets.get(i + 1) - offset; | ||
| offsetToLength.put(offset, length); | ||
| } | ||
|
|
||
|
|
@@ -161,11 +200,19 @@ public String next() { | |
| }; | ||
| } | ||
|
|
||
| // Field id range is validated lazily on first access, not at construction. | ||
| private int id(int index) { | ||
| if (null == fieldIds[index]) { | ||
| fieldIds[index] = | ||
| int dictSize = metadata.dictionarySize(); | ||
|
nssalian marked this conversation as resolved.
|
||
| int id = | ||
| ByteBuffers.readLittleEndianUnsigned( | ||
| value, fieldIdListOffset + (index * fieldIdSize), fieldIdSize); | ||
| Preconditions.checkArgument( | ||
| id >= 0 && id < dictSize, | ||
| "Invalid variant object: field id %s out of range [0, %s)", | ||
| id, | ||
| dictSize); | ||
| fieldIds[index] = id; | ||
| } | ||
|
|
||
| return fieldIds[index]; | ||
|
|
@@ -182,8 +229,10 @@ public VariantValue get(String name) { | |
|
|
||
| if (null == values[index]) { | ||
| values[index] = | ||
| VariantValue.from( | ||
| metadata, VariantUtil.slice(value, dataOffset + offsets[index], lengths[index])); | ||
| VariantUtil.fromBuffer( | ||
| metadata, | ||
| VariantUtil.slice(value, dataOffset + offsets[index], lengths[index]), | ||
| depth + 1); | ||
| } | ||
|
|
||
| return values[index]; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,8 @@ | |
| import java.nio.ByteOrder; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.util.function.Function; | ||
| import org.apache.iceberg.relocated.com.google.common.base.Preconditions; | ||
| import org.apache.iceberg.util.ByteBuffers; | ||
|
|
||
| class VariantUtil { | ||
| private static final int BASIC_TYPE_MASK = 0b11; | ||
|
|
@@ -30,8 +32,45 @@ class VariantUtil { | |
| private static final int BASIC_TYPE_OBJECT = 2; | ||
| private static final int BASIC_TYPE_ARRAY = 3; | ||
|
|
||
| /** | ||
| * Maximum nesting depth in a Variant (permitted depths 0..MAX_VARIANT_DEPTH). Safety limit, not a | ||
| * spec bound. Matches parquet-java (apache/parquet-java#3562). | ||
| */ | ||
| static final int MAX_VARIANT_DEPTH = 1000; | ||
|
nssalian marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Maximum element count for Variant containers and metadata dictionaries. Safety limit against | ||
| * buffer-to-heap allocation amplification. | ||
| */ | ||
| static final int MAX_ELEMENTS = 16_777_216; | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a non-spec limit — the Parquet VariantEncoding spec doesn't cap nesting depth and Iceberg defers to it. A valid variant with >500 levels written by Spark or another client would read fine elsewhere but throw here at scan time, which reads as silent data loss rather than malformed-input rejection. What did parquet-java#3562 settle on? If it picked a different value (or no cap), we'd diverge on file interop. I'd at minimum cross-reference that decision in a comment here and confirm 500 is high enough to never reject real data (it's below Jackson's default of 2000).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no real reason, except it was what was in org.apache.parquet.variant.VariantJsonParser how about we discuss on parquet dev list? I don't really care, just that there's some limit and that it's broadly consistent.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've started a discussion on the parquet dev list, please get involved. Looking at jackson, seems like 1000 is their limit right now, so copying that would be consistent. Let's see what the mailing list discussion outcome is though and we can all go with that, and add to the test dataset.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Following the parquet dev list thread Steve started: [DISCUSS] Variant Hardening: how deep is a realistic variant depth? (https://lists.apache.org/thread/q6wbom1q9pndv2nj6wcynxjcjxxkc1hm). I have aligned MAX_VARIANT_DEPTH to 1000 (jackson's 1000 seems the likely outcome).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've set the parquet to 1000 too, so we are all now consistent |
||
| private VariantUtil() {} | ||
|
|
||
| /** Parses a variant value; validates input and enforces {@link #MAX_VARIANT_DEPTH}. */ | ||
| static VariantValue fromBuffer(VariantMetadata metadata, ByteBuffer value, int depth) { | ||
|
nssalian marked this conversation as resolved.
|
||
| Preconditions.checkArgument(depth >= 0, "Invalid variant: negative depth %s", depth); | ||
| Preconditions.checkArgument( | ||
| depth <= MAX_VARIANT_DEPTH, | ||
| "Invalid variant: nesting depth %s exceeds maximum %s", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Javadoc on I'd either flip this to
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed the doc to match: "top-level value is depth 0, so a Variant may contain up to MAX_VARIANT_DEPTH + 1 nested levels." |
||
| depth, | ||
| MAX_VARIANT_DEPTH); | ||
| Preconditions.checkArgument(value.remaining() >= 1, "Invalid variant: empty value buffer"); | ||
| int header = ByteBuffers.readByte(value, 0); | ||
| BasicType basicType = basicType(header); | ||
| switch (basicType) { | ||
| case PRIMITIVE: | ||
| return SerializedPrimitive.from(value, header); | ||
| case SHORT_STRING: | ||
| return SerializedShortString.from(value, header); | ||
| case OBJECT: | ||
| return SerializedObject.from(metadata, value, header, depth); | ||
| case ARRAY: | ||
| return SerializedArray.from(metadata, value, header, depth); | ||
| } | ||
|
|
||
| throw new UnsupportedOperationException("Unsupported basic type: " + basicType); | ||
| } | ||
|
|
||
| static float readFloat(ByteBuffer buffer, int offset) { | ||
| return buffer.getFloat(buffer.position() + offset); | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.