VYPR
Critical severity9.9NVD Advisory· Published May 4, 2026· Updated May 12, 2026

CVE-2026-42809

CVE-2026-42809

Description

Apache Polaris can issue broad temporary ("vended") storage credentials during staged table creation before the effective table location has been validated or durably reserved. Those temporary credentials are meant to limit the scope of accessible table data and metadata, but this scope limitation becomes attacker- directed because the attacker can choose a reachable target location.

In the confirmed variant, if the caller supplies a custom location during stage create and requests credential vending, Apache Polaris uses that location to construct delegated storage credentials immediately. The stage-create path itself neither runs the normal location validation nor the overlap checks before those credentials are issued.

Closely related to that, the staged-create flow also accepts write.data.path / write.metadata.path in the request properties and feeds those location overrides into the same effective table location set used for credential vending. Those fields are secondary to the main custom-location exploit, but they are still attacker-influenced location inputs that should be validated before any credentials are issued.

Affected products

2
  • Apache/Polarisinferred2 versions
    (expand)+ 1 more
    • (no CPE)
    • cpe:2.3:a:apache:polaris:*:*:*:*:*:*:*:*range: <1.4.1

Patches

1
c98d610bc8f7

Improve staged table handling (#4328)

https://github.com/apache/polarisRobert StuppMay 1, 2026via ghsa
4 files changed · +365 0
  • runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java+4 0 modified
    @@ -587,6 +587,10 @@ public LoadTableResponse createTableStaged(
         TableIdentifier ident = TableIdentifier.of(namespace, request.name());
         TableMetadata metadata = stageTableCreateHelper(namespace, request);
     
    +    if (baseCatalog instanceof IcebergCatalog polarisCatalog) {
    +      polarisCatalog.validateStagedTableCreate(ident, metadata);
    +    }
    +
         return buildLoadTableResponseWithDelegationCredentials(
                 ident,
                 metadata,
    
  • runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalog.java+18 0 modified
    @@ -1021,6 +1021,24 @@ public String transformTableLikeLocation(TableIdentifier tableIdentifier, String
             tableIdentifier, applyReplaceNewLocationWithCatalogDefault(location));
       }
     
    +  void validateStagedTableCreate(TableIdentifier tableIdentifier, TableMetadata tableMetadata) {
    +    PolarisResolvedPathWrapper resolvedStorageEntity =
    +        CatalogUtils.findResolvedStorageEntity(resolvedEntityView, tableIdentifier);
    +    if (resolvedStorageEntity == null) {
    +      throw noSuchNamespaceException(tableIdentifier.namespace());
    +    }
    +    Set<String> dataLocations =
    +        StorageUtil.getLocationsUsedByTable(tableMetadata.location(), tableMetadata.properties());
    +    CatalogUtils.validateLocationsForTableLike(
    +        realmConfig, tableIdentifier, dataLocations, resolvedStorageEntity);
    +    List<PolarisEntity> resolvedNamespace = resolvedStorageEntity.getRawFullPath();
    +    PolarisEntity storageLeafEntity = resolvedStorageEntity.getRawLeafEntity();
    +    dataLocations.forEach(
    +        location ->
    +            validateNoLocationOverlap(
    +                catalogEntity, tableIdentifier, resolvedNamespace, location, storageLeafEntity));
    +  }
    +
       /**
        * Validates that the specified {@code location} is valid for whatever storage config is found for
        * this TableLike's parent hierarchy.
    
  • runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergAllowedLocationTest.java+132 0 modified
    @@ -137,6 +137,138 @@ void testCreateTableInsideOfCatalogAllowedLocations(@TempDir Path tmpDir) {
         assertEquals(response.getStatus(), Response.Status.OK.getStatusCode());
       }
     
    +  @Test
    +  void testCreateTableStagedOutsideOfCatalogAllowedLocations(@TempDir Path tmpDir) {
    +    var services = getTestServices();
    +
    +    var catalogLocation = tmpDir.resolve(catalog).toAbsolutePath().toUri().toString();
    +    var namespaceLocation = tmpDir.resolve(namespace).toAbsolutePath().toUri().toString();
    +    assertNotEquals(catalogLocation, namespaceLocation);
    +
    +    createCatalog(services, Map.of(), catalogLocation, null);
    +
    +    // create a namespace outside of catalog allowed locations
    +    createNamespace(services, namespaceLocation);
    +
    +    var createTableRequest =
    +        CreateTableRequest.builder()
    +            .withName(getTableName())
    +            .withSchema(SCHEMA)
    +            .stageCreate()
    +            .build();
    +
    +    assertThrows(
    +        ForbiddenException.class,
    +        () ->
    +            services
    +                .restApi()
    +                .createTable(
    +                    catalog,
    +                    namespace,
    +                    createTableRequest,
    +                    "vended-credentials",
    +                    services.realmContext(),
    +                    services.securityContext()));
    +  }
    +
    +  @Test
    +  void testCreateTableStagedInsideCatalogAllowedLocations(@TempDir Path tmpDir) {
    +    var services = getTestServices();
    +
    +    var catalogLocation = tmpDir.resolve(catalog).toAbsolutePath().toUri().toString();
    +    var namespaceLocation = tmpDir.resolve(namespace).toAbsolutePath().toUri().toString();
    +    assertNotEquals(catalogLocation, namespaceLocation);
    +
    +    createCatalog(services, Map.of(), catalogLocation, List.of(namespaceLocation));
    +    createNamespace(services, namespaceLocation);
    +
    +    var createTableRequest =
    +        CreateTableRequest.builder()
    +            .withName(getTableName())
    +            .withSchema(SCHEMA)
    +            .stageCreate()
    +            .build();
    +
    +    try (Response response =
    +        services
    +            .restApi()
    +            .createTable(
    +                catalog,
    +                namespace,
    +                createTableRequest,
    +                "vended-credentials",
    +                services.realmContext(),
    +                services.securityContext())) {
    +      assertEquals(response.getStatus(), Response.Status.OK.getStatusCode());
    +    }
    +  }
    +
    +  @Test
    +  void testCreateTableStagedWithExplicitLocationOutsideAllowedLocations(@TempDir Path tmpDir) {
    +    var services = getTestServices();
    +
    +    var catalogLocation = tmpDir.resolve(catalog).toAbsolutePath().toUri().toString();
    +    var namespaceLocation = catalogLocation + "/" + namespace;
    +    var externalLocation = tmpDir.resolve("external-location").toAbsolutePath().toUri().toString();
    +
    +    createCatalog(services, Map.of(), catalogLocation, List.of(catalogLocation));
    +    createNamespace(services, namespaceLocation);
    +
    +    var createTableRequest =
    +        CreateTableRequest.builder()
    +            .withLocation(externalLocation)
    +            .withName(getTableName())
    +            .withSchema(SCHEMA)
    +            .stageCreate()
    +            .build();
    +
    +    assertThrows(
    +        ForbiddenException.class,
    +        () ->
    +            services
    +                .restApi()
    +                .createTable(
    +                    catalog,
    +                    namespace,
    +                    createTableRequest,
    +                    null,
    +                    services.realmContext(),
    +                    services.securityContext()));
    +  }
    +
    +  @Test
    +  void testCreateTableStagedWithExplicitLocationInsideAllowedLocations(@TempDir Path tmpDir) {
    +    var services = getTestServices();
    +
    +    var catalogLocation = tmpDir.resolve(catalog).toAbsolutePath().toUri().toString();
    +    var namespaceLocation = catalogLocation + "/" + namespace;
    +    var customLocation = namespaceLocation + "/custom-location";
    +
    +    createCatalog(services, Map.of(), catalogLocation, List.of(catalogLocation));
    +    createNamespace(services, namespaceLocation);
    +
    +    var createTableRequest =
    +        CreateTableRequest.builder()
    +            .withLocation(customLocation)
    +            .withName(getTableName())
    +            .withSchema(SCHEMA)
    +            .stageCreate()
    +            .build();
    +
    +    try (Response response =
    +        services
    +            .restApi()
    +            .createTable(
    +                catalog,
    +                namespace,
    +                createTableRequest,
    +                "vended-credentials",
    +                services.realmContext(),
    +                services.securityContext())) {
    +      assertEquals(response.getStatus(), Response.Status.OK.getStatusCode());
    +    }
    +  }
    +
       private static TestServices getTestServices() {
         Map<String, Object> strictServicesWithOptimizedOverlapCheck =
             Map.of(
    
  • runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergOverlappingTableTest.java+211 0 modified
    @@ -24,6 +24,7 @@
     import static org.apache.polaris.core.config.FeatureConfiguration.OPTIMIZED_SIBLING_CHECK;
     import static org.apache.polaris.service.admin.PolarisAuthzTestBase.SCHEMA;
     import static org.assertj.core.api.Assertions.assertThat;
    +import static org.assertj.core.api.Assertions.assertThatThrownBy;
     
     import jakarta.ws.rs.core.Response;
     import java.nio.file.Path;
    @@ -32,6 +33,7 @@
     import java.util.UUID;
     import java.util.stream.Stream;
     import org.apache.iceberg.catalog.Namespace;
    +import org.apache.iceberg.exceptions.AlreadyExistsException;
     import org.apache.iceberg.exceptions.ForbiddenException;
     import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
     import org.apache.iceberg.rest.requests.CreateTableRequest;
    @@ -84,6 +86,31 @@ private int createTable(TestServices services, String location) {
         }
       }
     
    +  /** Attempt to stage-create a table at a given location, and return the response code. */
    +  private int createTableStaged(TestServices services, String location) {
    +    CreateTableRequest createTableRequest =
    +        CreateTableRequest.builder()
    +            .withName(getTableName())
    +            .withLocation(location)
    +            .withSchema(SCHEMA)
    +            .stageCreate()
    +            .build();
    +    try (Response response =
    +        services
    +            .restApi()
    +            .createTable(
    +                catalog,
    +                namespace,
    +                createTableRequest,
    +                "vended-credentials",
    +                services.realmContext(),
    +                services.securityContext())) {
    +      return response.getStatus();
    +    } catch (ForbiddenException e) {
    +      return Response.Status.FORBIDDEN.getStatusCode();
    +    }
    +  }
    +
       /**
        * Attempt to create a table without a location, and return the location it gets created at If the
        * creation fails, this should return null
    @@ -421,4 +448,188 @@ public void testHashedTableLocations(@TempDir Path tempDir) {
         assertThat(createTableWithName(services, "determinism_check").substring(baseLocation.length()))
             .isEqualTo("/test-catalog/1110/1010/0001/01111010/ns/determinism_check");
       }
    +
    +  @Test
    +  @DisplayName("Stage create accepts caller-specified allowed location")
    +  void testStagedCreateAcceptsCustomLocation(@TempDir Path tempDir) {
    +    Map<String, Object> strictServices =
    +        Map.of(
    +            "ALLOW_UNSTRUCTURED_TABLE_LOCATION",
    +            "true",
    +            "ALLOW_TABLE_LOCATION_OVERLAP",
    +            "false",
    +            "ALLOW_INSECURE_STORAGE_TYPES",
    +            "true",
    +            "SUPPORTED_CATALOG_STORAGE_TYPES",
    +            List.of("FILE", "S3"));
    +    TestServices services = TestServices.builder().config(strictServices).build();
    +
    +    String baseLocation = tempDir.toAbsolutePath().toUri().toString();
    +    if (baseLocation.endsWith("/")) {
    +      baseLocation = baseLocation.substring(0, baseLocation.length() - 1);
    +    }
    +    createCatalogAndNamespace(services, Map.of(), baseLocation);
    +
    +    String someLocation = baseLocation + "/" + catalog + "/" + namespace + "/arbitrary-location";
    +    assertThat(createTableStaged(services, someLocation))
    +        .isEqualTo(Response.Status.OK.getStatusCode());
    +  }
    +
    +  @Test
    +  @DisplayName("Stage create rejects same-name as existing table")
    +  void testStagedCreateRejectsExistingIdentifier(@TempDir Path tempDir) {
    +    Map<String, Object> strictServices =
    +        Map.of(
    +            "ALLOW_UNSTRUCTURED_TABLE_LOCATION",
    +            "true",
    +            "ALLOW_TABLE_LOCATION_OVERLAP",
    +            "false",
    +            "ALLOW_INSECURE_STORAGE_TYPES",
    +            "true",
    +            "SUPPORTED_CATALOG_STORAGE_TYPES",
    +            List.of("FILE", "S3"));
    +    TestServices services = TestServices.builder().config(strictServices).build();
    +
    +    String baseLocation = tempDir.toAbsolutePath().toUri().toString();
    +    if (baseLocation.endsWith("/")) {
    +      baseLocation = baseLocation.substring(0, baseLocation.length() - 1);
    +    }
    +    createCatalogAndNamespace(services, Map.of(), baseLocation);
    +
    +    String existing = "existing_table";
    +    assertThat(createTableWithName(services, existing)).isNotNull();
    +
    +    CreateTableRequest request =
    +        CreateTableRequest.builder().withName(existing).withSchema(SCHEMA).stageCreate().build();
    +    assertThatThrownBy(
    +            () ->
    +                services
    +                    .restApi()
    +                    .createTable(
    +                        catalog,
    +                        namespace,
    +                        request,
    +                        null,
    +                        services.realmContext(),
    +                        services.securityContext()))
    +        .isInstanceOf(AlreadyExistsException.class);
    +  }
    +
    +  @Test
    +  @DisplayName("Stage create rejects derived location overlap with sibling")
    +  void testStagedCreateRejectsDerivedLocationOverlap(@TempDir Path tempDir) {
    +    Map<String, Object> strictServices =
    +        Map.of(
    +            "ALLOW_UNSTRUCTURED_TABLE_LOCATION",
    +            "true",
    +            "ALLOW_TABLE_LOCATION_OVERLAP",
    +            "false",
    +            "ALLOW_INSECURE_STORAGE_TYPES",
    +            "true",
    +            "SUPPORTED_CATALOG_STORAGE_TYPES",
    +            List.of("FILE", "S3"));
    +    TestServices services = TestServices.builder().config(strictServices).build();
    +
    +    String baseLocation = tempDir.toAbsolutePath().toUri().toString();
    +    if (baseLocation.endsWith("/")) {
    +      baseLocation = baseLocation.substring(0, baseLocation.length() - 1);
    +    }
    +    createCatalogAndNamespace(services, Map.of(), baseLocation);
    +
    +    // Create a table with custom location
    +    assertThat(
    +            createTable(
    +                services, String.format("%s/%s/%s/table_1", baseLocation, catalog, namespace)))
    +        .isEqualTo(Response.Status.OK.getStatusCode());
    +
    +    // Stage-create "table_1" with no caller-specified location:
    +    // derived default location will overlap
    +    CreateTableRequest request2 =
    +        CreateTableRequest.builder().withName("table_1").withSchema(SCHEMA).stageCreate().build();
    +    assertThatThrownBy(
    +            () ->
    +                services
    +                    .restApi()
    +                    .createTable(
    +                        catalog,
    +                        namespace,
    +                        request2,
    +                        null,
    +                        services.realmContext(),
    +                        services.securityContext()))
    +        .isInstanceOf(ForbiddenException.class);
    +  }
    +
    +  @Test
    +  @DisplayName("Stage create accepts derived location overlap with sibling")
    +  void testStagedCreateAcceptsDerivedLocationOverlap(@TempDir Path tempDir) {
    +    Map<String, Object> laxServices =
    +        Map.of(
    +            "ALLOW_UNSTRUCTURED_TABLE_LOCATION",
    +            "true",
    +            "ALLOW_TABLE_LOCATION_OVERLAP",
    +            "true",
    +            "ALLOW_INSECURE_STORAGE_TYPES",
    +            "true",
    +            "SUPPORTED_CATALOG_STORAGE_TYPES",
    +            List.of("FILE", "S3"));
    +    TestServices services = TestServices.builder().config(laxServices).build();
    +
    +    String baseLocation = tempDir.toAbsolutePath().toUri().toString();
    +    if (baseLocation.endsWith("/")) {
    +      baseLocation = baseLocation.substring(0, baseLocation.length() - 1);
    +    }
    +    createCatalogAndNamespace(services, Map.of(), baseLocation);
    +
    +    // Create a table with custom location
    +    assertThat(
    +            createTable(
    +                services, String.format("%s/%s/%s/table_1", baseLocation, catalog, namespace)))
    +        .isEqualTo(Response.Status.OK.getStatusCode());
    +
    +    // Stage-create "table_1" with no caller-specified location:
    +    // derived default location will overlap
    +    CreateTableRequest request =
    +        CreateTableRequest.builder().withName("table_1").withSchema(SCHEMA).stageCreate().build();
    +    try (Response response =
    +        services
    +            .restApi()
    +            .createTable(
    +                catalog,
    +                namespace,
    +                request,
    +                null,
    +                services.realmContext(),
    +                services.securityContext())) {
    +      assertThat(response.getStatus()).isEqualTo(Response.Status.OK.getStatusCode());
    +    }
    +  }
    +
    +  @Test
    +  @DisplayName("Stage create rejects caller-specified location when structured layout is enforced")
    +  void testStagedCreateRejectsCustomLocationWhenStructured(@TempDir Path tempDir) {
    +    Map<String, Object> strictServices =
    +        Map.of(
    +            "ALLOW_UNSTRUCTURED_TABLE_LOCATION",
    +            "false",
    +            "ALLOW_TABLE_LOCATION_OVERLAP",
    +            "false",
    +            "ALLOW_INSECURE_STORAGE_TYPES",
    +            "true",
    +            "SUPPORTED_CATALOG_STORAGE_TYPES",
    +            List.of("FILE", "S3"),
    +            OPTIMIZED_SIBLING_CHECK.key(),
    +            "true");
    +    TestServices services = TestServices.builder().config(strictServices).build();
    +
    +    String baseLocation = tempDir.toAbsolutePath().toUri().toString();
    +    if (baseLocation.endsWith("/")) {
    +      baseLocation = baseLocation.substring(0, baseLocation.length() - 1);
    +    }
    +    createCatalogAndNamespace(services, Map.of(), baseLocation);
    +
    +    String someLocation = baseLocation + "/" + catalog + "/unstructured-location";
    +    assertThat(createTableStaged(services, someLocation))
    +        .isEqualTo(Response.Status.FORBIDDEN.getStatusCode());
    +  }
     }
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

6

News mentions

0

No linked articles in our index yet.