-
Notifications
You must be signed in to change notification settings - Fork 1.6k
GH-3561 Harden variants against malformed metadata. #3562
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: master
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
|
|
@@ -61,6 +61,8 @@ public final class Variant { | |
| * Lazy cache for the parsed array header. | ||
| */ | ||
| private VariantUtil.ArrayInfo cachedArrayInfo; | ||
| /** Nesting depth of this Variant relative to the top-level value (0 = top-level). */ | ||
| private final int depth; | ||
|
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. Minor: depth here counts navigation depth through Variant objects, so it also ticks up for leaf primitives and short strings, not just container nesting. Harmless — a one-line note on the field might just save the next reader a double-take. |
||
|
|
||
| /** | ||
| * The threshold to switch from linear search to binary search when looking up a field by key in | ||
|
|
@@ -69,10 +71,31 @@ public final class Variant { | |
| */ | ||
| static final int BINARY_SEARCH_THRESHOLD = 32; | ||
|
|
||
| /** | ||
| * Create a Variant from a tuple of (value, metadata) byte arrays. | ||
| * Includes validation of the version and the top-level structure. | ||
| * @param value value buffer | ||
| * @param metadata metadata buffer | ||
| * @throws UnsupportedOperationException if there is a version mismatch | ||
| * @throws IllegalArgumentException for any validation failure. | ||
| */ | ||
| public Variant(byte[] value, byte[] metadata) { | ||
| this(value, 0, value.length, metadata, 0, metadata.length); | ||
| } | ||
|
|
||
| /** | ||
| * Create a Variant from a subset of the (value, metadata) buffers | ||
| * supplied. | ||
| * Includes validation of the version and the toplevel structure. | ||
| * @param value value buffer | ||
| * @param valuePos offset where the value data begins | ||
| * @param valueLength length of value data | ||
| * @param metadata metadata buffer | ||
| * @param metadataPos offset where the metadata begins. | ||
| * @param metadataLength length of the metadata. | ||
| * @throws UnsupportedOperationException if there is a version mismatch | ||
| * @throws IllegalArgumentException for any validation failure. | ||
| */ | ||
| public Variant(byte[] value, int valuePos, int valueLength, byte[] metadata, int metadataPos, int metadataLength) { | ||
| this(ByteBuffer.wrap(value, valuePos, valueLength), ByteBuffer.wrap(metadata, metadataPos, metadataLength)); | ||
| } | ||
|
|
@@ -81,10 +104,20 @@ public Variant(byte[] value, int valuePos, int valueLength, byte[] metadata, int | |
| this(value, metadata.getEncodedBuffer()); | ||
| } | ||
|
|
||
| /** | ||
| * Create a Variant from a tuple of (value, metadata) buffers. | ||
| * Includes validation of the version and the toplevel structure. | ||
| * @param value value buffer | ||
| * @param metadata metadata buffer | ||
| * @throws UnsupportedOperationException if there is a version mismatch | ||
| * @throws IllegalArgumentException for any validation failure. | ||
| */ | ||
| public Variant(ByteBuffer value, ByteBuffer metadata) { | ||
| this.value = value.asReadOnlyBuffer().order(ByteOrder.LITTLE_ENDIAN); | ||
| this.metadata = metadata.asReadOnlyBuffer().order(ByteOrder.LITTLE_ENDIAN); | ||
|
|
||
| this.depth = 0; | ||
| // A metadata buffer must contain at least the version byte | ||
| Preconditions.checkArgument(this.metadata.remaining() >= 1, "variant metadata is empty"); | ||
| // There is currently only one allowed version. | ||
| if ((metadata.get(metadata.position()) & VariantUtil.VERSION_MASK) != VariantUtil.VERSION) { | ||
| throw new UnsupportedOperationException(String.format( | ||
|
|
@@ -95,29 +128,39 @@ public Variant(ByteBuffer value, ByteBuffer metadata) { | |
| // Pre-compute dictionary size for lazy metadata cache allocation. | ||
| int pos = this.metadata.position(); | ||
| int metaOffsetSize = ((this.metadata.get(pos) >> 6) & 0x3) + 1; | ||
| if (this.metadata.remaining() > 1) { | ||
| Preconditions.checkArgument( | ||
| this.metadata.remaining() >= 1 + metaOffsetSize, | ||
| "variant metadata truncated: offsetSize=" + metaOffsetSize); | ||
| this.dictSize = VariantUtil.readUnsignedLittleEndian(this.metadata, pos + 1, metaOffsetSize); | ||
| long dictTableEnd = 1L + metaOffsetSize + ((long) this.dictSize + 1) * metaOffsetSize; | ||
| Preconditions.checkArgument( | ||
| dictTableEnd <= this.metadata.remaining(), | ||
| "variant metadata dictionary extends past buffer: dictSize=" + this.dictSize); | ||
| } else { | ||
| this.dictSize = 0; | ||
| } | ||
| Preconditions.checkArgument( | ||
| this.metadata.remaining() >= 1 + metaOffsetSize, | ||
|
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 looks like a genuine fix, but it's also a public-API behavior change that might be worth a release note. A 1-byte metadata buffer (just the version byte) used to construct successfully with dictSize=0 via the old |
||
| "variant metadata truncated: offsetSize=" + metaOffsetSize); | ||
| this.dictSize = VariantUtil.readUnsignedLittleEndian(this.metadata, pos + 1, metaOffsetSize); | ||
| long dictTableEnd = 1L + metaOffsetSize + ((long) this.dictSize + 1) * metaOffsetSize; | ||
| Preconditions.checkArgument( | ||
| dictTableEnd <= this.metadata.remaining(), | ||
| "variant metadata dictionary extends past buffer: dictSize=" + this.dictSize); | ||
| this.metadataCache = null; | ||
| VariantUtil.validateValueShallow(this.value, dictSize); | ||
|
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. Since we now validate in the constructor, should we add some Javadoc to this public function? Including the
Contributor
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. done |
||
| } | ||
|
|
||
| /** | ||
| * Package-private constructor that shares pre-parsed metadata state from a parent Variant. | ||
| * Package-private constructor that shares pre-parsed metadata state from a parent Variant, performing shallow validation of the structure. | ||
| * @param value value buffer | ||
| * @param metadata metadata buffer | ||
| * @param metadataCache shared metadata cache. | ||
| * @param dictSize shared dictionary size. | ||
| * @param depth depth of this variant in a recursive structure. | ||
| * @throws IllegalArgumentException if the depth of variants is too high or the structure | ||
| * invalid in some other form. | ||
| */ | ||
| Variant(ByteBuffer value, ByteBuffer metadata, String[] metadataCache, int dictSize) { | ||
| Variant(ByteBuffer value, ByteBuffer metadata, String[] metadataCache, int dictSize, int depth) { | ||
| Preconditions.checkArgument( | ||
| depth <= VariantUtil.MAX_VARIANT_DEPTH, | ||
| "variant nesting depth exceeds maximum %s", | ||
| VariantUtil.MAX_VARIANT_DEPTH); | ||
| this.value = value.asReadOnlyBuffer().order(ByteOrder.LITTLE_ENDIAN); | ||
| this.metadata = metadata.asReadOnlyBuffer().order(ByteOrder.LITTLE_ENDIAN); | ||
| this.metadataCache = metadataCache; | ||
| this.dictSize = dictSize; | ||
| this.depth = depth; | ||
| VariantUtil.validateValueShallow(this.value, dictSize); | ||
| } | ||
|
|
||
| public ByteBuffer getValueBuffer() { | ||
|
|
@@ -359,9 +402,10 @@ public Variant getElementAtIndex(int index) { | |
|
|
||
| /** | ||
| * Creates a child Variant that shares this instance's metadata cache. | ||
| * @throws IllegalArgumentException validation error during construction. | ||
| */ | ||
| private Variant childVariant(ByteBuffer childValue) { | ||
| return new Variant(childValue, metadata, metadataCache, dictSize); | ||
| return new Variant(childValue, metadata, metadataCache, dictSize, depth + 1); | ||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,8 @@ | |
| */ | ||
| package org.apache.parquet.variant; | ||
|
|
||
| import static org.apache.parquet.variant.VariantUtil.MAX_VARIANT_DEPTH; | ||
|
|
||
| import com.fasterxml.jackson.core.JsonFactory; | ||
| import com.fasterxml.jackson.core.JsonParseException; | ||
| import com.fasterxml.jackson.core.JsonParser; | ||
|
|
@@ -37,7 +39,7 @@ public final class VariantJsonParser { | |
|
|
||
| private static final JsonFactory JSON_FACTORY = JsonFactory.builder() | ||
| .streamReadConstraints(StreamReadConstraints.builder() | ||
| .maxNestingDepth(500) | ||
| .maxNestingDepth(MAX_VARIANT_DEPTH) | ||
|
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 bumps the JSON parse-depth boundary from 500 to 1000 as a side effect of sharing the constant, so documents that used to overflow at 501 now parse. Since buildJson/buildJsonArray/buildJsonObject recurse on the real JVM stack, I wasn't sure whether 1000 had been validated as safe on this path specifically (as opposed to the descent path). Might be worth a release note, and maybe a test that parses ~1000-level JSON on a realistic stack. wdyt?
Contributor
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. That original 500 was completely chosen without any data as a general resilience number, the goal being "a malicious file can damage the stack of the thread processing it, but not the rest of the process". I went with the same number for the variant for consistency, and after a discussion in the mailing list went to 1k. we haven't shipped the json parser yet, so no need for a release note. I will look at #3415 notes to see if we should update it though |
||
| .maxStringLength(10_000_000) | ||
| .maxDocumentLength(50_000_000L) | ||
| .build()) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -190,6 +190,12 @@ class VariantUtil { | |
| // The size (in bytes) of a UUID. | ||
| static final int UUID_SIZE = 16; | ||
|
|
||
| /** | ||
| * Maximum permitted nesting depth of a Variant value. | ||
| * same limit as in VariantJsonParser. | ||
| */ | ||
| static final int MAX_VARIANT_DEPTH = 1000; | ||
|
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 checked and the spec doesn't define a max nesting depth — the Iceberg side treats this as a safety limit too, so I think it's a reasonable cap. The one thing I'd flag is interop: a spec-valid variant nested deeper than 1000, written by Spark/PyIceberg/Arrow, would be rejected here. Might be worth a Javadoc note that it's an implementation limit rather than a spec rule, so that expectation is clear.
Contributor
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 add to the javadocs, with a link to the discussion https://lists.apache.org/thread/q6wbom1q9pndv2nj6wcynxjcjxxkc1hm |
||
|
|
||
| // header bytes | ||
| static final byte HEADER_NULL = primitiveHeader(NULL); | ||
| static final byte HEADER_LONG_STRING = primitiveHeader(LONG_STR); | ||
|
|
@@ -880,6 +886,158 @@ static HashMap<String, Integer> getMetadataMap(ByteBuffer metadata) { | |
| return result; | ||
| } | ||
|
|
||
| /** | ||
| * Bounds-checks a single Variant value node against its buffer slot. Performs no recursion | ||
| * into nested children: child nodes are checked on demand when callers descend into them. | ||
| * | ||
| * <p>Cost: O(1) for primitives and short strings, O(numElements) for objects and arrays. | ||
| * Validation of nested structures is deferred so that opening a large well-formed Variant | ||
| * is not penalized by sub-trees the caller never inspects. | ||
| * | ||
| * @param valueBuffer the variant value buffer (position/limit define the extent of this node's slot) | ||
| * @param dictSize the metadata dictionary size, used to bound object field ids | ||
| * @throws IllegalArgumentException if the value header or container table does not fit within | ||
| * the buffer slot, or if any object field id is out of range | ||
| */ | ||
| static void validateValueShallow(final ByteBuffer valueBuffer, final int dictSize) { | ||
| int pos = valueBuffer.position(); | ||
| Preconditions.checkArgument(pos >= 0 && pos < valueBuffer.limit(), "variant value is empty"); | ||
|
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. Small thing: pos is
Contributor
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. cut it; all that matters is limit > pos, and as there's a limit - pos calculation on the line below, do a check on that value to make clear what is really being checked is "is there data" |
||
| long slot = (long) valueBuffer.limit() - pos; | ||
|
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. One subtlety worth a doc note (not a change, I think): |
||
| int header = valueBuffer.get(pos) & 0xFF; | ||
| int basicType = header & BASIC_TYPE_MASK; | ||
| int typeInfo = (header >> BASIC_TYPE_BITS) & PRIMITIVE_TYPE_MASK; | ||
| switch (basicType) { | ||
| case SHORT_STR: | ||
| Preconditions.checkArgument(1L + typeInfo <= slot, "variant short string extends past buffer"); | ||
| return; | ||
| case OBJECT: | ||
| validateContainerShallow(valueBuffer, typeInfo, pos, slot, dictSize, true); | ||
| return; | ||
| case ARRAY: | ||
| validateContainerShallow(valueBuffer, typeInfo, pos, slot, dictSize, false); | ||
| return; | ||
| default: | ||
| validatePrimitiveShallow(valueBuffer, typeInfo, pos, slot); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Shallow validation of a container. | ||
| * | ||
| * @param valueBuffer buffer with the variant data | ||
| * @param typeInfo type information from the metadata | ||
| * @param pos buffer read position | ||
| * @param length length of slot for this primitive. | ||
|
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. Looks like a copy-paste from validatePrimitiveShallow: the @param says "length of slot for this primitive" but this is a container. |
||
| * @param dictSize dictionary size | ||
| * @param isObject is this an object? | ||
| */ | ||
| private static void validateContainerShallow( | ||
| final ByteBuffer valueBuffer, | ||
| final int typeInfo, | ||
| final int pos, | ||
| final long length, | ||
| final int dictSize, | ||
| final boolean isObject) { | ||
| boolean largeSize; | ||
| int idSize; | ||
| if (isObject) { | ||
| largeSize = ((typeInfo >> 4) & 0x1) != 0; | ||
| idSize = ((typeInfo >> 2) & 0x3) + 1; | ||
| } else { | ||
| largeSize = ((typeInfo >> 2) & 0x1) != 0; | ||
| idSize = 0; | ||
| } | ||
| int offsetSize = (typeInfo & 0x3) + 1; | ||
| int sizeBytes = largeSize ? U32_SIZE : 1; | ||
| Preconditions.checkArgument(1L + sizeBytes <= length, "variant container header truncated"); | ||
| int numElements = readUnsignedLittleEndian(valueBuffer, pos + 1, sizeBytes); | ||
| long idStart = 1L + sizeBytes; | ||
| long idBytes = isObject ? (long) numElements * idSize : 0L; | ||
| long offsetStart = idStart + idBytes; | ||
| long offsetBytes = (long) (numElements + 1) * offsetSize; | ||
|
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 the one I'd most want a second look at, since it's where the port seems to have drifted from the Iceberg original.
The line two above already widens the right way ( long offsetBytes = ((long) numElements + 1) * offsetSize;The Iceberg original also capped numElements with a MAX_ELEMENTS bound to make this class unreachable, which doesn't seem to have come across in the port. I could be missing a parquet-java-side reason it can't be hit — a regression test at |
||
| long dataStart = offsetStart + offsetBytes; | ||
| Preconditions.checkArgument( | ||
| dataStart <= length, "variant container offset table extends past buffer: numElements=%s", numElements); | ||
| long dataLen = length - dataStart; | ||
| if (isObject) { | ||
| for (int i = 0; i < numElements; i++) { | ||
| int id = readUnsignedLittleEndian(valueBuffer, pos + (int) idStart + i * idSize, idSize); | ||
| Preconditions.checkArgument( | ||
| id < dictSize, "variant object key id %s out of range (dictSize=%s)", id, dictSize); | ||
| } | ||
| } | ||
| // Each child offset must lie within the data region. Children may overlap or leave gaps; | ||
| // the trailing terminator offset is range-checked for the same reason. | ||
| for (int i = 0; i <= numElements; i++) { | ||
| // O(elements) | ||
| int off = readUnsignedLittleEndian(valueBuffer, pos + (int) offsetStart + i * offsetSize, offsetSize); | ||
| Preconditions.checkArgument( | ||
| off <= dataLen, "variant child offset out of range: %s (data length %s)", off, dataLen); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Validate the primitive type. The buffer must have enough capacity | ||
| * for the given type (and of course, the type must be recognized). | ||
| * | ||
| * @param valueBuffer buffer with the variant data | ||
| * @param typeInfo type information from the metadata | ||
| * @param pos buffer read position | ||
| * @param length length of slot for this primitive. | ||
| */ | ||
| private static void validatePrimitiveShallow( | ||
| final ByteBuffer valueBuffer, final int typeInfo, final int pos, final long length) { | ||
| long size; | ||
| switch (typeInfo) { | ||
| case NULL: | ||
| case TRUE: | ||
| case FALSE: | ||
| size = 1; | ||
| break; | ||
| case INT8: | ||
| size = 2; | ||
| break; | ||
| case INT16: | ||
| size = 3; | ||
| break; | ||
| case INT32: | ||
| case DATE: | ||
| case FLOAT: | ||
| size = 5; | ||
| break; | ||
| case INT64: | ||
| case DOUBLE: | ||
| case TIMESTAMP_TZ: | ||
| case TIMESTAMP_NTZ: | ||
| case TIME: | ||
| case TIMESTAMP_NANOS_TZ: | ||
| case TIMESTAMP_NANOS_NTZ: | ||
| size = 9; | ||
| break; | ||
| case DECIMAL4: | ||
| size = 6; | ||
| break; | ||
| case DECIMAL8: | ||
| size = 10; | ||
| break; | ||
| case DECIMAL16: | ||
| size = 18; | ||
| break; | ||
| case BINARY: | ||
| case LONG_STR: { | ||
| Preconditions.checkArgument(1L + U32_SIZE <= length, "variant string/binary length field truncated"); | ||
| size = 1L + U32_SIZE + readUnsigned(valueBuffer, pos + 1, U32_SIZE); | ||
| break; | ||
| } | ||
| case UUID: | ||
| size = 1L + UUID_SIZE; | ||
| break; | ||
| default: | ||
| throw new IllegalArgumentException(String.format("Unknown primitive type in variant: %d", typeInfo)); | ||
| } | ||
| Preconditions.checkArgument(size <= length, "variant value extends past buffer"); | ||
| } | ||
|
|
||
| /** | ||
| * Computes the actual size (in bytes) of the Variant value. | ||
| * @param value The Variant value binary | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Iceberg tests for this stayed at direct ByteBuffer construction, so I was a little surprised to see the round-trip harness pull parquet-hadoop and hadoop-client into parquet-variant's test scope. Since Parquet's binary column encoding is just a length-prefixed byte copy, I don't think most of these cases exercise a different construction path than wrapping the bytes directly. Might be worth keeping the round-trip for the one or two cases where encode/decode genuinely matters — but you'll know the parquet-java test conventions better than I do.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wanted to create the test files for apache/parquet-testing#113 ; so while I agree it's overkill, it's a test only dependency and an easy way to generate test data for interop testing.