Alex Merced's Data, Dev and AI Blog

Article

CVE-2026-73334 and the Trust Boundary Inside an Encrypted Parquet File

Cross-posted. This article's canonical home is iceberglakehouse.com.

Picture a Spark job that reads encrypted Parquet files from a landing bucket. A partner drops the files there every night. The job decrypts them, cleans the rows, and writes the result into an Apache Iceberg table. The job has a KMS token in its configuration, because that is how it asks the key management service to unwrap the keys that protect each file.

Now picture one of those nightly files arriving with a small change in its footer. Nobody touched the data pages. The only difference is a URL string buried in the key material, pointing at a host the attacker controls. When the job opens the file, the reader hands that URL to the KMS client. If the client does not check it, the client sends the job's KMS token to the attacker.

That is CVE-2026-73334 in one paragraph. It was disclosed on September 8, 2026, against the key management tools in parquet-java, the Java implementation of Apache Parquet. The fix shipped in parquet-java 1.18.1.

This article explains the bug at the level of the actual code path. It covers which settings decide whether you are exposed, and how Apache Iceberg's own encryption design sits relative to the vulnerable path. It also covers what a hardened KMS client looks like and how to audit a lakehouse for the pattern. The short version: Iceberg's native table encryption does not route through the vulnerable code, but plenty of lakehouses have Parquet readers outside Iceberg that do.

What the Vulnerability Is, Precisely#

The affected code lives in the org.apache.parquet.crypto.keytools package of the parquet-hadoop artifact. The advisory lists versions 1.12 through 1.18.0. Version 1.12 is where the key tools arrived, so every release that shipped them carries the bug.

The key tools package sits on top of Parquet Modular Encryption, the part of the Parquet format that encrypts individual columns and the file footer. Modular encryption itself only needs raw keys. The key tools add envelope encryption on top: they generate random data keys, wrap those keys with master keys held in a key management service (KMS), and store the wrapped result in the file. Readers later ask the KMS to unwrap them.

A writer application can optionally set a KMS URL. When it does, the key tools store that URL inside the file, next to the wrapped keys. On the read side, the URL has three possible sources. The reading application can supply its own value. The reader can take the value stored in the file. Or the KMS client can ignore the URL entirely.

The bug is in the second case. In versions before 1.18.1, if the reading application did not set a KMS URL of its own, the reader pulled the URL from the file and passed it to the pluggable KmsClient implementation. If that implementation did not validate the host, it connected to whatever the file said and presented its access token.

The Apache advisory, written by Parquet PMC member Gidon Gershinsky, rates the issue as moderate. Some third-party trackers score it higher, with one listing a CVSS score of 8.1. The difference comes down to preconditions. The attack needs three things at once: a reader that does not pin the KMS URL, a custom KMS client that trusts the URL it receives, and an attacker who can place or modify a Parquet file that the reader will open. Remove any one of those and the attack fails.

That combination is less rare than it sounds. Pipelines that ingest files from partners, vendors, or other teams are common. So are custom KMS clients, because the Parquet project ships only an interface and some sample and test implementations, and every production deployment writes its own adapter for its KMS. Many of those adapters were written to connect to whatever URL they were handed, because that was the job the interface appeared to describe.

How Parquet's Key Tools Use a KMS#

To see why a URL ends up inside a data file, you need a working picture of the key tools. Here is the minimum.

Parquet Modular Encryption encrypts each column chunk with a data encryption key (DEK). It encrypts the footer with its own key as well, unless the file uses plaintext footer mode. Those DEKs are random bytes generated per file. Something has to protect them, and that something is the master key in the KMS.

The key tools support two wrapping modes, controlled by parquet.encryption.double.wrapping, which defaults to true.

In single wrapping, the tools send each DEK to the KMS and ask it to wrap the key with a named master key. The wrapped DEK goes into the file.

In double wrapping, the tools add a middle layer. They generate a key encryption key (KEK), wrap the DEKs locally with the KEK, and send only the KEK to the KMS for wrapping. This cuts KMS round trips sharply, because many DEKs share one KEK. KEKs are cached for the lifetime set by parquet.encryption.cache.lifetime.seconds, which defaults to ten minutes.

Either way, the file ends up carrying a block of key material. It is a small JSON document with a fixed set of fields. The format type is PKMT1. The document records whether it belongs to the footer key, which master key wrapped things (masterKeyID), the wrapped DEK (wrappedDEK), and, in double wrapping mode, the KEK identifier and wrapped KEK. It also records two fields that matter for this CVE: kmsInstanceID and kmsInstanceURL.

By default, the key material lives inside the file footer. The property parquet.encryption.key.material.store.internally controls this and defaults to true. When set to false, the key material goes into small sidecar files in the same folder, which makes key rotation possible without rewriting immutable Parquet files. Either way, the material sits in storage next to the data.

The reader reverses the process. It parses the key material, figures out which KMS to talk to, creates or reuses a KmsClient, and calls unwrapKey with the wrapped key and the master key ID. The KMS returns the plaintext key, and decryption proceeds.

The KmsClient interface is small. It has an initialize method that receives the Hadoop configuration, a KMS instance ID, a KMS instance URL, and an access token. It then has wrapKey and unwrapKey. The URL arrives through initialize. So does the token. Whatever the client does with the URL, it does it while holding the token.

If you want the full model of how Parquet modular encryption and Iceberg table encryption fit together, including DEK and KEK lifecycles, I cover that in depth in File Encryption for the Lakehouse. For this article, the picture above is enough. The key fact is that the KMS URL is written by whoever wrote the file, and read by whoever reads it.

Most data engineers think of a Parquet file as data. The reader treats the pages as data, and the footer as instructions for reading that data. The footer tells the reader where column chunks start, which encodings they use, which compression codec applies, and, for encrypted files, which keys to fetch and where.

That last part is the trouble. A file footer is written by the writer, and the writer is not always you. Anything that can put a file into a location your reader scans can author that footer. The list is longer than most teams expect.

Partner and vendor drops are the obvious case. A bucket that accepts files from outside your organization is an input boundary, the same way a public API is.

Internal producers are less obvious. A team that owns one landing prefix and a separate team that owns the reader are two trust domains, even inside one company. A compromised service account on the producer side becomes a path to tokens on the consumer side.

Shared object storage is the third case. If write access to a prefix is broader than it should be, then anyone holding those credentials can plant a file. Storage permissions drift. Prefixes get reused. Old IAM policies stay attached long after the pipeline they served is gone.

Restores and copies are the fourth. A file restored from a backup, copied from another account, or pulled from an archive carries whatever footer it had when it was written. If that footer was tampered with at any point in its life, the reader sees the tampered version.

The Parquet design already accounts for tampering in one direction. Modular encryption uses authenticated encryption (AES-GCM by default), which means an attacker cannot silently modify encrypted pages or an encrypted footer without the reader detecting it. The key material is a different story. It has to be readable before decryption starts, because it is how the reader finds the keys in the first place. So it is not protected by the file's own encryption.

This is the conceptual core of the CVE. The key material is metadata that tells the reader how to obtain secrets, and it is stored in an untrusted location. Any field in it that affects where the reader sends credentials is a routing instruction from an untrusted party.

The master key ID has the same property, in a weaker form. A tampered masterKeyID makes the reader ask the KMS to unwrap with a different master key. A well-built KMS enforces access control per key, so the request fails unless the reader's identity is allowed to use that key. Nothing leaks. The URL is worse, because the damage happens before the KMS is ever involved. The token leaves the building on the first connection.

A useful rule falls out of this. Treat every value your reader pulls from a file footer as input to validate, not as configuration. Configuration is what you set in your own code or your own deployment. Input is what arrives from storage. The Parquet key tools blurred that line for one field, and the fix restores it.

How the Reader Picks a KMS URL, Before and After 1.18.1#

The decision happens in FileKeyUnwrapper, in a method that builds the KMS client for a given block of key material. Reading the current source makes the logic concrete.

The reader first resolves the KMS instance ID. It reads parquet.encryption.kms.instance.id from the Hadoop configuration. If that is empty, it falls back to the kmsInstanceID stored in the file. If both are missing, it throws.

Then it resolves the URL. It reads parquet.encryption.kms.instance.url from the configuration. If that is set, the reader uses it and never looks at the file's value. This is what the advisory calls application control.

If the configuration value is empty, the behavior splits by version.

Before 1.18.1, the reader took kmsInstanceURL from the file whenever the configuration did not supply one. That value then went into the KmsClient through initialize.

From 1.18.1 on, the reader checks a new boolean property, parquet.encryption.kms.enable.url.read, which defaults to false. Only when it is true does the reader take the URL from the file. Otherwise it passes the placeholder string DEFAULT, which is the same sentinel the interface already used when no URL was configured.

The documentation for the new property states the contract plainly. It says the stored URL is not provided to readers by default because storage is untrusted. It says readers that need the URL should set it themselves. And it says KMS clients must validate the URL and use authentication if anyone turns file reads back on.

Here is the full decision matrix.

Reader sets kms.instance.url kms.enable.url.read parquet-java version URL passed to KmsClient Exposed to CVE path
Yes any any Application value No
No not applicable 1.12 to 1.18.0 Value from file footer Yes, if client trusts it
No false (default) 1.18.1 and later DEFAULT placeholder No
No true 1.18.1 and later Value from file footer Yes, if client trusts it

Two details in that table deserve attention.

The first row is the safest configuration on every version, including old ones. If your readers set parquet.encryption.kms.instance.url explicitly, the file value is never consulted. The advisory calls this the required mitigation for anyone who uses the URL parameter, whatever version they run. If you cannot upgrade a component this week, pinning the URL is the change that closes the path today.

The last row is a trap. Some teams will upgrade to 1.18.1, find that a job broke because it depended on the file URL, and flip enable.url.read to true to get it working again. That restores the exact behavior the CVE describes. The property exists for readers that truly cannot know their KMS endpoint in advance, and it comes with an obligation to validate. It is not a compatibility switch.

One more gap is worth closing. The instance ID still falls back to the file value when the configuration is empty, on every version. An instance ID is a label, not a network address, so it does not carry the same direct risk. But custom clients sometimes use the instance ID to pick an endpoint from a map, or build a URL from it. If your client does that, a file-controlled instance ID becomes a file-controlled URL by another name. Pin parquet.encryption.kms.instance.id as well, and check how your client uses it.

Where Apache Iceberg Sits Relative to the Vulnerable Path#

Iceberg tables store their data in Parquet files, and the Iceberg Java library depends on parquet-java. So the natural question is whether Iceberg tables are exposed. The answer depends on which encryption path you use, and the difference is architectural rather than accidental.

Iceberg has its own table encryption design, formalized in the v3 table spec. It does not use the Parquet key tools to find keys. It keeps key information in Iceberg metadata instead of in the data file footer.

Here is how the pieces fit. The catalog is configured with a KMS client through one of two catalog properties. encryption.kms-type accepts aws, azure, or gcp and maps to the built-in client for that cloud. encryption.kms-impl takes a class name for a custom client. Setting both is an error. The table then names its master key with the table property encryption.key-id.

The client implements Iceberg's KeyManagementClient interface, which is separate from Parquet's KmsClient. It has wrapKey and unwrapKey methods that take a key buffer and a wrapping key ID, optional key generation support, and an initialize method that receives the catalog properties. Note what it does not receive: anything from a data file. The KMS endpoint and credentials come from catalog configuration only.

Per-file keys travel through metadata. Each data file entry in a manifest carries a key_metadata field. The table metadata carries an encryption-keys list, where each entry has a key-id, the encrypted key metadata, and optionally the ID of the key that wrapped it. Snapshots reference the key used for their manifest list. The chain runs from the catalog's master key, through metadata that the catalog and the committing writer control, down to the key for each file.

When Iceberg's Parquet writer and reader handle an encrypted file, they pass the resolved data key and AAD prefix directly to parquet-java as file encryption and decryption properties. They do not ask the Parquet key tools to figure out keys from the footer. The Iceberg Parquet reader goes a step further: it strips parquet.crypto.factory.class from the Hadoop configuration properties it passes through for reads. That is the property that activates the key tools in the first place. An Iceberg read of an Iceberg-encrypted table never reaches FileKeyUnwrapper, so it never consults a footer URL.

This is a real design strength, and it is worth understanding why it holds. Iceberg treats the location of key information as a metadata concern. Metadata commits go through the catalog, which enforces who can write them. A data file footer has no such gate. By keeping key routing out of the footer, Iceberg keeps the attacker who can plant a data file away from the credential path.

That does not make an Iceberg lakehouse immune. It means the exposure lives in the places where Parquet is read outside Iceberg's own read path. In a typical lakehouse, those places are easy to list.

Raw Parquet reads with the key tools enabled. Apache Spark documents Parquet columnar encryption using PropertiesDrivenCryptoFactory, the key tools' entry point. Any Spark, Hive, or plain Java job that reads encrypted Parquet files as files, rather than as an Iceberg table, goes through the vulnerable code if it uses that factory.

Landing and staging zones. The ingestion pattern from the opening of this article is common. Files arrive encrypted, a job decrypts and transforms them, and the output goes into Iceberg. The Iceberg write is safe. The read that feeds it is not, unless it pins the URL.

Importing existing files. Iceberg procedures that register existing Parquet files, such as add_files and table migration, read those files to collect metrics and schema. If the files were encrypted with the key tools and your job configuration enables the crypto factory for that step, the read goes through the key tools. Treat any job that touches pre-existing encrypted files as in scope until you confirm its configuration.

Custom services and tools. Data quality scanners, compaction services outside the table format's own actions, export tools, and small utilities that open Parquet files directly all count. These are often the least audited code in a platform.

Non-Java readers. The CVE is specific to parquet-java. PyArrow and Arrow C++ have their own Parquet encryption and key management implementations, and Rust readers have theirs. They are not covered by this advisory. The underlying design question is the same for all of them, though: does the reader trust a KMS location read from the file? Check each implementation you run against that question, not against this CVE number.

One more boundary matters. Iceberg's design protects the key path, but it relies on the catalog configuration being right. If a custom KeyManagementClient reads endpoint information from table properties, and table properties are writable by broad roles, you have rebuilt a smaller version of the same problem inside the catalog. Keep KMS endpoints in catalog-level configuration that only platform administrators control.

Finding Exposure in Your Platform#

An audit for this CVE has two halves. You need to know where parquet-java versions 1.12 through 1.18.0 run, and you need to know which of those processes read encrypted files through the key tools without pinning the URL. The second half narrows the first dramatically.

Start with inventory. For JVM services built with Maven or Gradle, the dependency tree tells you which parquet-hadoop version resolves.

# Maven: show the resolved parquet-hadoop version for a service
mvn dependency:tree -Dincludes=org.apache.parquet:parquet-hadoop

# Gradle equivalent
./gradlew dependencies --configuration runtimeClasspath | grep parquet-hadoop

# Engine distributions and shaded jars: search the jars on disk
find /opt/spark/jars /opt/hive/lib -name 'parquet-hadoop-*.jar' 2>/dev/null

# Fat jars can hide a shaded copy of the key tools
for j in $(find /opt/apps -name '*.jar'); do
  unzip -l "$j" 2>/dev/null | grep -q 'crypto/keytools/FileKeyUnwrapper' \
    && echo "keytools present in: $j"
done

The last loop matters because shading hides versions. A fat jar that bundles parquet-java under a relocated package still contains FileKeyUnwrapper, even if no dependency report mentions parquet-hadoop. Searching for the class file catches those cases.

Next, find the processes that turn the key tools on. The signal is the crypto factory property. Search job configurations, Spark defaults, Hadoop site files, and code.

# Configuration files and job definitions
grep -rn 'parquet.crypto.factory.class' /etc/spark /etc/hadoop ./jobs ./conf 2>/dev/null

# Application code that sets it programmatically
grep -rn 'PropertiesDrivenCryptoFactory\|parquet.crypto.factory.class' ./src

# Readers that already pin the URL (these are covered)
grep -rn 'parquet.encryption.kms.instance.url' ./jobs ./conf ./src

# Readers that turned file URL reads back on after upgrading
grep -rn 'parquet.encryption.kms.enable.url.read' ./jobs ./conf ./src

Every process that sets the crypto factory and does not set the instance URL is on your list. Every process that sets enable.url.read to true needs a review of its KMS client, whatever version it runs.

Then look at the KMS clients themselves. Find every class that implements org.apache.parquet.crypto.keytools.KmsClient. For each one, read initialize and answer three questions. Does it use the kmsInstanceURL argument at all? If so, does it compare the host against a fixed allow list? Does it require HTTPS with certificate validation? A client that ignores the URL argument and reads its endpoint from its own trusted configuration is safe on every version. A client that connects to the argument as given is the vulnerable half of the pair.

Finally, map inputs. For each process on the list, write down who can write to the locations it reads. If the answer is "only this pipeline's own writer, with credentials nobody else holds," the practical risk is low. If the answer includes partners, other teams, or broad storage roles, fix that process first.

This ordering keeps the audit short. Most lakehouses have hundreds of processes that bundle parquet-java and only a handful that enable the key tools. The handful is where the risk is.

What a Hardened KmsClient Looks Like#

Upgrading parquet-java changes the default. It does not fix a client that trusts its input. If any reader in your platform ever sets enable.url.read to true, or if a future bug routes another untrusted value into the client, the client is the last line of defense. It is worth making that line solid.

A hardened client follows four rules. It gets its endpoint from trusted configuration, not from the arguments it receives. If it accepts a URL argument at all, it validates the host against a fixed allow list. It requires HTTPS with normal certificate validation. And it never sends a token to a host it has not validated.

Here is a sketch in Java against the real KmsClient interface. The HTTP calls target a generic internal KMS gateway, because every organization's KMS API differs. The validation logic is the part to copy.

package com.example.security;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Base64;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.Arrays;
import org.apache.hadoop.conf.Configuration;
import org.apache.parquet.crypto.KeyAccessDeniedException;
import org.apache.parquet.crypto.keytools.KmsClient;

public class PinnedEndpointKmsClient implements KmsClient {

  // Your own properties, set by platform administrators, never by file writers.
  static final String ENDPOINT_PROP = "example.kms.endpoint";
  static final String ALLOWED_HOSTS_PROP = "example.kms.allowed.hosts";

  private URI endpoint;
  private String token;
  private HttpClient http;

  @Override
  public void initialize(Configuration conf, String kmsInstanceID,
                         String kmsInstanceURL, String accessToken)
      throws KeyAccessDeniedException {

    String configured = conf.getTrimmed(ENDPOINT_PROP);
    if (configured == null || configured.isEmpty()) {
      throw new KeyAccessDeniedException("KMS endpoint is not configured");
    }
    URI trusted = URI.create(configured);

    Set<String> allowed = Arrays.stream(
            conf.getTrimmedStrings(ALLOWED_HOSTS_PROP))
        .map(String::toLowerCase)
        .collect(Collectors.toSet());

    requireSafe(trusted, allowed);

    // A URL argument that is not the placeholder must match the trusted
    // endpoint exactly. Anything else is treated as tampering.
    if (kmsInstanceURL != null
        && !KmsClient.KMS_INSTANCE_URL_DEFAULT.equals(kmsInstanceURL)) {
      URI offered = URI.create(kmsInstanceURL);
      requireSafe(offered, allowed);
      if (!offered.getHost().equalsIgnoreCase(trusted.getHost())) {
        throw new KeyAccessDeniedException(
            "KMS URL from key material does not match configured endpoint");
      }
    }

    this.endpoint = trusted;
    this.token = accessToken;
    this.http = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .followRedirects(HttpClient.Redirect.NEVER)
        .build();
  }

  private static void requireSafe(URI uri, Set<String> allowed) {
    if (!"https".equalsIgnoreCase(uri.getScheme())) {
      throw new KeyAccessDeniedException("KMS endpoint must use https");
    }
    String host = uri.getHost();
    if (host == null || !allowed.contains(host.toLowerCase())) {
      throw new KeyAccessDeniedException("KMS host is not on the allow list");
    }
    if (uri.getUserInfo() != null) {
      throw new KeyAccessDeniedException("KMS URL must not carry user info");
    }
  }

  @Override
  public String wrapKey(byte[] keyBytes, String masterKeyIdentifier)
      throws KeyAccessDeniedException {
    String body = Base64.getEncoder().encodeToString(keyBytes);
    return call("wrap", masterKeyIdentifier, body);
  }

  @Override
  public byte[] unwrapKey(String wrappedKey, String masterKeyIdentifier)
      throws KeyAccessDeniedException {
    return Base64.getDecoder().decode(call("unwrap", masterKeyIdentifier, wrappedKey));
  }

  private String call(String op, String keyId, String payload) {
    HttpRequest req = HttpRequest.newBuilder(
            endpoint.resolve("/v1/keys/" + keyId + "/" + op))
        .timeout(Duration.ofSeconds(10))
        .header("Authorization", "Bearer " + token)
        .POST(HttpRequest.BodyPublishers.ofString(payload))
        .build();
    try {
      HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
      if (resp.statusCode() == 401 || resp.statusCode() == 403) {
        throw new KeyAccessDeniedException("KMS denied " + op + " for key " + keyId);
      }
      if (resp.statusCode() != 200) {
        throw new KeyAccessDeniedException("KMS " + op + " failed: " + resp.statusCode());
      }
      return resp.body();
    } catch (java.io.IOException | InterruptedException e) {
      throw new KeyAccessDeniedException("KMS call failed", e);
    }
  }
}

Walk through what each part buys you.

The endpoint comes from example.kms.endpoint, a property you own and set in deployment configuration. The file never supplies it. This alone defeats the CVE, because the token only ever goes to the configured endpoint.

The allow list is a second check on the configured value. It catches mistakes in your own configuration, such as a staging endpoint pushed to production or a typo that resolves to someone else's domain. It is cheap and it turns a whole class of misconfiguration into a loud startup failure.

The URL argument is still examined, but only to reject tampering. If the key tools pass the DEFAULT placeholder, the client ignores it. If they pass a real value, the client requires it to match the trusted host. A mismatch fails the read. That is the right outcome. A file whose key material names an unexpected KMS host is either misconfigured or malicious, and neither should decrypt quietly.

HTTPS is required, and redirects are disabled. The redirect setting matters more than it looks. A client that validates the first host and then follows a 302 to another host has validated nothing. Java's HttpClient does not follow redirects unless told to, but setting Redirect.NEVER explicitly documents the intent and protects against a future refactor.

The URI check rejects user info. A URL like https://kms.example.com@evil.example.net/ is a classic trick where the part before @ looks like the host to a human reader. URI.getHost() returns the real host, and rejecting user info outright removes the ambiguity.

Access failures map to KeyAccessDeniedException. The interface documentation asks for this, and it matters operationally. The key tools and the engines above them treat that exception as an authorization failure rather than a generic crash, which gives you cleaner error reporting when a tampered file hits the check.

Two things are missing on purpose. The sketch does no caching, because the key tools already cache KMS clients and KEKs. And it does not log the token or the wrapped keys. Log the key ID, the operation, and the host. Never log material.

Configuration Before and After#

For most teams, the change that closes this CVE is a handful of properties. Here is what a vulnerable Spark reader configuration looks like, followed by the fixed version.

A typical vulnerable setup looks like this in spark-defaults.conf or equivalent job configuration.

spark.hadoop.parquet.crypto.factory.class=org.apache.parquet.crypto.keytools.PropertiesDrivenCryptoFactory
spark.hadoop.parquet.encryption.kms.client.class=com.example.security.LegacyKmsClient
spark.hadoop.parquet.encryption.key.access.token=${KMS_TOKEN}
# No instance URL set. On parquet-java 1.12 to 1.18.0 the reader uses the file's URL.

The crypto factory turns on the key tools. The client class names the adapter. The token comes from the environment. Nothing tells the reader where the KMS lives, so on an affected version the file decides.

The hardened version pins both routing values, keeps file URL reads off, and points at a client that validates.

spark.hadoop.parquet.crypto.factory.class=org.apache.parquet.crypto.keytools.PropertiesDrivenCryptoFactory
spark.hadoop.parquet.encryption.kms.client.class=com.example.security.PinnedEndpointKmsClient
spark.hadoop.parquet.encryption.key.access.token=${KMS_TOKEN}

# Application control: the reader never consults the file's values.
spark.hadoop.parquet.encryption.kms.instance.url=https://kms.internal.example.com
spark.hadoop.parquet.encryption.kms.instance.id=prod-kms-01

# Explicit, for readers on 1.18.1 and later. False is the default. Keep it that way.
spark.hadoop.parquet.encryption.kms.enable.url.read=false

# Your client's own trusted settings.
spark.hadoop.example.kms.endpoint=https://kms.internal.example.com
spark.hadoop.example.kms.allowed.hosts=kms.internal.example.com

Setting enable.url.read=false explicitly looks redundant on a patched version, and it is. It earns its place anyway. It documents the decision in the one file people read when a job breaks. On an older runtime that does not know the property, it does nothing, and the pinned instance URL is what protects you. That is why the instance URL comes first.

The instance URL and the client's own endpoint hold the same value here. That duplication is deliberate. The Parquet property protects you on every version of parquet-java. The client property protects you if a future reader path passes something unexpected. Each layer works without the other.

Failure Modes and Warning Signs#

Patching and pinning close the main path. A few patterns keep the risk alive afterward, and each one has a signal you can watch for.

The compatibility flip. A job that relied on file URLs breaks after the upgrade, and someone sets enable.url.read=true to fix it. The warning sign is that property appearing in any configuration change. Put it on a review list in your configuration repository. Any pull request that sets it to true needs a named owner for the KMS client and proof that the client validates.

Stale runtimes. Engines bundle their own parquet-java. Upgrading your application's dependency does nothing if the job runs inside a Spark or Hive distribution that ships an older jar first on the classpath. The sign is a dependency report that says 1.18.1 while the cluster's jar directory says otherwise. Verify on the running cluster, not in the build file.

Shaded copies. A connector or a vendor library that shades parquet-java carries its own key tools. The class search from the audit section finds these. The warning sign is any jar that contains FileKeyUnwrapper under a relocated package name.

Clients that build URLs from instance IDs. As covered earlier, the instance ID still falls back to the file value. A client that treats the ID as a lookup key into a map of endpoints is safe if the map is fixed and unknown IDs fail. A client that formats the ID into a hostname template is not. The sign is string concatenation involving kmsInstanceID anywhere in client code.

Tokens with too much reach. Suppose a token does leak. The damage depends on what that token can do. A token scoped to unwrap with one master key, from one network location, for one hour, is a small problem. A long-lived token that can use every key in the KMS is a large one. The warning sign is any KMS token in your platform that does not expire, or that is shared across pipelines with different data.

Silent decryption failures. After you pin URLs and harden clients, a tampered file will fail to decrypt. That is correct. But many pipelines treat read errors as transient and retry, or quarantine the file without alerting anyone. The sign is a growing quarantine prefix, or retry counts climbing on one input source. A decryption failure caused by a key material mismatch is a security event, not a data quality event. Route it to the people who can investigate the producer.

Egress that nobody watches. The attack requires your reader to open a connection to an attacker's host. Many data platforms run compute with open outbound internet access because it is convenient. If your KMS is internal, your readers have no reason to reach arbitrary hosts. The sign is outbound connections from ingestion workers to destinations outside your known list.

Operational Guidance#

Here is the order I recommend for working through this, from fastest risk reduction to longest-lived improvement.

First, pin the URL and instance ID on every reader that enables the key tools. This works on every parquet-java version and needs no code release. It is a configuration change you can make today, and it closes the path the CVE describes.

Second, upgrade parquet-java to 1.18.1 or later everywhere it runs, including engine distributions and shaded dependencies. The new default protects the readers you missed in step one. Expect a slower rollout here, because engine upgrades carry their own testing burden. That is fine, because step one already covered you.

Third, review every custom KmsClient implementation. Apply the four rules from the hardened client section. A client that reads its endpoint from its own trusted configuration and ignores the URL argument is the simplest safe design. If a client has to accept file URLs for a real reason, it needs the allow list, the HTTPS requirement, and the redirect guard.

Fourth, assess whether any token already leaked. Ask your KMS for access logs covering the period your readers ran on affected versions without pinned URLs. You are looking for token use from unexpected sources. Also check network logs on the reader side for outbound connections to unknown hosts on the ports your KMS uses. If you cannot rule out exposure for a token, rotate it. Rotation is cheap compared to the investigation that follows a confirmed misuse.

Fifth, shrink token scope. Short-lived tokens, per-pipeline identities, and per-key permissions limit what any single leak can do. This is standard practice for cloud IAM, and it applies just as much to KMS tokens handed to Parquet readers. It is also the change that pays off against the next bug, not only this one.

Sixth, restrict egress from compute that reads untrusted files. An ingestion worker that can only reach your object store, your catalog, and your KMS cannot send a token anywhere else, even if every other control fails.

Seventh, move encrypted tables toward Iceberg-native encryption where it fits. The Iceberg design keeps key routing in catalog-controlled metadata, which removes the footer from the credential path. Engine support for Iceberg table encryption is still maturing across the ecosystem, so check that your engines read and write encrypted Iceberg tables before planning a migration. Where they do, the architecture is the stronger one.

For monitoring, three signals cover most of the risk. Alert on any configuration change that sets enable.url.read to true. Alert on KeyAccessDeniedException messages that mention a host mismatch, which only a hardened client produces. And alert on outbound connections from ingestion compute to destinations outside the allow list.

Where This Is Heading#

CVE-2026-73334 is a small bug with a large lesson. File formats carry more and more metadata that tells readers how to behave. Some of that metadata points outside the file. The Parquet FILE logical type, which shipped in Parquet Format 2.14.0 in September 2026, lets a column hold references to external objects by URI. Iceberg v4 proposals add relative paths and a matching file type. Catalogs vend credentials, sign requests, and are starting to discuss pre-signed URLs for those external references.

Every one of those features puts a location string in front of a reader that holds credentials. The design question is the same each time. Who wrote this location, and does the reader treat it as configuration or as input? The Parquet fix answers it for KMS URLs by defaulting to distrust. The Iceberg encryption design answers it by keeping key routing in catalog metadata. Both answers point the same way.

Expect more of this. Engines will add allow lists for external references. Catalogs will become the place that decides which locations a reader is allowed to fetch, and with which credentials. Security reviews of data pipelines will start asking about footer fields the way web security reviews ask about request headers. Teams that build that habit now will spend less time on the next advisory.

Conclusion#

CVE-2026-73334 exists because one field in an encrypted Parquet file's key material was treated as configuration when it came from storage. On parquet-java versions 1.12 through 1.18.0, a reader that did not set its own KMS URL passed the file's URL to the KMS client. A client that did not validate that URL sent its access token wherever the file pointed.

The fix is simple to apply. Pin parquet.encryption.kms.instance.url and parquet.encryption.kms.instance.id on every reader that uses the key tools. Upgrade to parquet-java 1.18.1, which disables file-controlled URLs by default behind parquet.encryption.kms.enable.url.read. Harden custom KMS clients so they read endpoints from trusted configuration, validate hosts, require HTTPS, and refuse redirects.

Iceberg's native table encryption stays off the vulnerable path by design, because it keeps key information in catalog-controlled metadata and never asks the Parquet key tools to route keys from a footer. The exposure in an Iceberg lakehouse lives in the Parquet reads around the tables: landing zones, imports, raw-file jobs, and custom tools. Audit those, pin them, and scope your tokens so the next leak is a small one.

Keep Going#

If this piece was useful, I have written a lot more on how Apache Iceberg tables are built and secured. Apache Iceberg: The Definitive Guide walks through the table format's metadata layers, which is the foundation for understanding why Iceberg keeps key routing out of data files. You can find every book I have written, across lakehouse architecture, Apache Iceberg, Apache Polaris, and AI, at books.alexmerced.com.

Newsletter

Get new posts in your inbox

Deep dives on Apache Iceberg, lakehouse architecture and applied AI. No spam, unsubscribe anytime.

Subscribe

Menu

Search

Type at least two characters.