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

roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new f00f45c33c [#14562] fix(iceberg-rest): reject dotted namespace levels 
before authorization (#14566)
f00f45c33c is described below

commit f00f45c33c52f642f61bea6c5aa086d4b57f5387
Author: roryqi <[email protected]>
AuthorDate: Tue Nov 3 18:49:18 2026 +0800

    [#14562] fix(iceberg-rest): reject dotted namespace levels before 
authorization (#14566)
    
    ### What changes were proposed in this pull request?
    
    Share namespace-level validation between creation and authorization.
    Validate decoded namespace levels before authorization expression
    evaluation, and validate create requests before resolving their parent
    schema.
    
    ### Why are the changes needed?
    
    Requests naming a namespace level containing `.` currently fail with an
    internal authorization error. Reject these invalid names as bad requests
    before they reach metadata-object conversion or the catalog.
    
    Fixes #14562
    
    ### Does this PR introduce _any_ user-facing change?
    
    Invalid namespace requests return HTTP 400 instead of 500. Encoded or
    literal `..` paths rejected during routing may return 404.
    
    ### How was this patch tested?
    
    - 130 targeted Iceberg REST tests passed, covering authorization,
    request-body handlers, namespace creation and operations, REST
    utilities, and exception mapping.
    - 24 HTTP requests cover GET, HEAD, property updates and DELETE with
    both explicit and default catalog paths. Inputs include `a%2Eb`,
    `%2e%2e` and literal `..`; raw socket requests preserve `..` without
    client normalization. The tests verify that the backend dispatcher is
    not called.
    - Spotless and `git diff --check` passed.
---
 .../iceberg/service/IcebergRESTUtils.java          |  17 ++
 .../dispatcher/IcebergNamespaceHookDispatcher.java |  10 +-
 .../web/filter/CreateNamespaceAuthzHandler.java    |   2 +
 ...bergMetadataAuthorizationMethodInterceptor.java |   1 +
 .../iceberg/service/TestIcebergRESTUtils.java      |  29 ++++
 .../TestIcebergNamespaceAuthorizationPaths.java    | 177 +++++++++++++++++++++
 ...bergMetadataAuthorizationMethodInterceptor.java |  71 +++++++++
 .../TestIcebergRequestBodyAuthzHandlers.java       |  37 +++++
 8 files changed, 336 insertions(+), 8 deletions(-)

diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
index d38cea75bf..5b0d479675 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
@@ -118,6 +118,23 @@ public class IcebergRESTUtils {
 
   private IcebergRESTUtils() {}
 
+  /**
+   * Checks that namespace levels can be represented in Gravitino metadata 
object names.
+   *
+   * @param namespace the decoded namespace to check
+   * @throws BadRequestException if a level contains the reserved 
qualified-name separator
+   */
+  public static void checkNamespaceLevels(Namespace namespace) {
+    for (String level : namespace.levels()) {
+      if (level.contains(".")) {
+        throw new BadRequestException(
+            "Invalid namespace %s: the level '%s' contains '.', which is 
reserved as the"
+                + " qualified-name separator",
+            namespace, level);
+      }
+    }
+  }
+
   /**
    * Builds an Iceberg REST {@link 
org.apache.iceberg.rest.credentials.Credential} for load-table,
    * scan-plan, or credentials API responses.
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
index 8394307c8b..2a520098b0 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
@@ -32,6 +32,7 @@ import org.apache.gravitino.catalog.SchemaDispatcher;
 import org.apache.gravitino.catalog.TableDispatcher;
 import org.apache.gravitino.catalog.ViewDispatcher;
 import org.apache.gravitino.iceberg.common.utils.IcebergIdentifierUtils;
+import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
 import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
 import org.apache.gravitino.listener.api.event.IcebergRequestContext;
 import org.apache.gravitino.lock.LockType;
@@ -124,14 +125,7 @@ public class IcebergNamespaceHookDispatcher implements 
IcebergNamespaceOperation
     if (GravitinoEnv.getInstance().internalSchemaDispatcher() == null) {
       return;
     }
-    for (String level : namespace.levels()) {
-      if (level.contains(".")) {
-        throw new BadRequestException(
-            "Invalid namespace %s: the level '%s' contains '.', which is 
reserved as the"
-                + " qualified-name separator",
-            namespace, level);
-      }
-    }
+    IcebergRESTUtils.checkNamespaceLevels(namespace);
     String name = String.join(HierarchicalSchemaUtil.schemaSeparator(), 
namespace.levels());
     if (name.codePointCount(0, name.length()) > 
EntityFieldLimits.MAX_NAME_LENGTH) {
       throw new BadRequestException(
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java
index 1d12ee4364..b7761e5ae7 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java
@@ -24,6 +24,7 @@ import java.util.Arrays;
 import java.util.Map;
 import org.apache.gravitino.Entity.EntityType;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
 import 
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
 import 
org.apache.gravitino.server.web.filter.BaseMetadataAuthorizationMethodInterceptor.AuthorizationHandler;
 import org.apache.gravitino.utils.HierarchicalSchemaUtil;
@@ -67,6 +68,7 @@ public class CreateNamespaceAuthzHandler implements 
AuthorizationHandler {
     String catalog = catalogIdent.name();
 
     Namespace namespace = request.namespace();
+    IcebergRESTUtils.checkNamespaceLevels(namespace);
     int levels = namespace.length();
 
     String separator = HierarchicalSchemaUtil.schemaSeparator();
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
index baeb09901d..66cefe6a58 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
@@ -106,6 +106,7 @@ public class IcebergMetadataAuthorizationMethodInterceptor
             rawNamespace =
                 RESTUtil.decodeNamespace(
                     value, 
IcebergRESTUtils.NAMESPACE_SEPARATOR_URLENCODED_UTF_8);
+            IcebergRESTUtils.checkNamespaceLevels(rawNamespace);
             schema = String.join(separator, rawNamespace.levels());
             nameIdentifierMap.put(
                 Entity.EntityType.SCHEMA,
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
index ac57441f55..10a76eaa84 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
@@ -62,6 +62,8 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
 import org.mockito.Mockito;
 
 @SuppressWarnings("deprecation")
@@ -88,6 +90,24 @@ public class TestIcebergRESTUtils {
         NameIdentifier.of(metalakeName, catalogName, "ns1", "ns2", "table"), 
nameIdentifier);
   }
 
+  @ParameterizedTest
+  @MethodSource("invalidNamespaces")
+  void testRejectsDotInAnyNamespaceLevel(Namespace namespace) {
+    BadRequestException error =
+        Assertions.assertThrows(
+            BadRequestException.class, () -> 
IcebergRESTUtils.checkNamespaceLevels(namespace));
+    Assertions.assertTrue(error.getMessage().contains("contains '.'"));
+  }
+
+  @Test
+  void testAllowsValidNamespaceLevelsWithoutDecodingAgain() {
+    Assertions.assertDoesNotThrow(() -> 
IcebergRESTUtils.checkNamespaceLevels(Namespace.empty()));
+    Assertions.assertDoesNotThrow(
+        () -> IcebergRESTUtils.checkNamespaceLevels(Namespace.of("parent", 
"child")));
+    Assertions.assertDoesNotThrow(
+        () -> 
IcebergRESTUtils.checkNamespaceLevels(Namespace.of("literal%2Ename")));
+  }
+
   @Test
   void testGetCatalogName() {
     String prefix = "catalog/";
@@ -519,4 +539,13 @@ public class TestIcebergRESTUtils {
     RESTRequest valid = () -> {};
     Assertions.assertDoesNotThrow(() -> 
IcebergRESTUtils.validateRequest(valid, "X"));
   }
+
+  private static List<Namespace> invalidNamespaces() {
+    return List.of(
+        Namespace.of("a.b"),
+        Namespace.of("."),
+        Namespace.of(".."),
+        Namespace.of("a.b", "child"),
+        Namespace.of("parent", "a.b"));
+  }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceAuthorizationPaths.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceAuthorizationPaths.java
new file mode 100644
index 0000000000..d85e499a9c
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceAuthorizationPaths.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.iceberg.service.rest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import javax.inject.Singleton;
+import javax.ws.rs.core.Application;
+import org.apache.gravitino.iceberg.service.IcebergExceptionMapper;
+import org.apache.gravitino.iceberg.service.IcebergObjectMapper;
+import org.apache.gravitino.iceberg.service.IcebergObjectMapperProvider;
+import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import 
org.apache.gravitino.iceberg.service.dispatcher.IcebergNamespaceOperationDispatcher;
+import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
+import org.apache.gravitino.rest.RESTUtils;
+import 
org.apache.gravitino.server.web.filter.IcebergRESTAuthInterceptionService;
+import org.apache.iceberg.rest.responses.ErrorResponse;
+import org.glassfish.hk2.api.InterceptionService;
+import org.glassfish.hk2.utilities.binding.AbstractBinder;
+import org.glassfish.jersey.jackson.JacksonFeature;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.glassfish.jersey.test.TestProperties;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** Tests encoded and literal dotted namespace paths through HTTP and the 
authorization proxy. */
+public class TestIcebergNamespaceAuthorizationPaths extends JerseyTest {
+
+  private static final String UPDATE_BODY = "{\"updates\":{\"k\":\"v\"}}";
+
+  private IcebergNamespaceOperationDispatcher dispatcher;
+
+  /**
+   * Registers the production namespace resource and authorization 
interception service.
+   *
+   * @return the test application
+   */
+  @Override
+  protected Application configure() {
+    try {
+      forceSet(
+          TestProperties.CONTAINER_PORT, 
String.valueOf(RESTUtils.findAvailablePort(2000, 3000)));
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+    IcebergConfigProvider provider = mock(IcebergConfigProvider.class);
+    when(provider.getMetalakeName()).thenReturn("metalake");
+    when(provider.getDefaultCatalogName()).thenReturn("catalog");
+    IcebergRESTServerContext.create(provider, true, true, false, null);
+    dispatcher = mock(IcebergNamespaceOperationDispatcher.class);
+    return new ResourceConfig(IcebergNamespaceOperations.class)
+        .register(IcebergExceptionMapper.class)
+        .register(IcebergObjectMapperProvider.class)
+        .register(JacksonFeature.withoutExceptionMappers())
+        .register(
+            new AbstractBinder() {
+              /** {@inheritDoc} */
+              @Override
+              protected void configure() {
+                bind(dispatcher).to(IcebergNamespaceOperationDispatcher.class);
+                bind(IcebergRESTAuthInterceptionService.class)
+                    .to(InterceptionService.class)
+                    .in(Singleton.class);
+              }
+            });
+  }
+
+  @ParameterizedTest
+  @CsvSource({"catalog/, a%2Eb", "'', a%2Eb", "catalog/, %2e%2e", "'', 
%2e%2e"})
+  void testEncodedDotPathsNeverReturnInternalError(String prefix, String 
namespace)
+      throws Exception {
+    HttpClient client =
+        HttpClient.newBuilder()
+            .version(HttpClient.Version.HTTP_1_1)
+            .connectTimeout(Duration.ofSeconds(5))
+            .build();
+    for (String method : new String[] {"GET", "HEAD", "POST", "DELETE"}) {
+      String path = namespacePath(prefix, namespace, method);
+      HttpRequest request =
+          HttpRequest.newBuilder(URI.create(getBaseUri() + path))
+              .timeout(Duration.ofSeconds(10))
+              .header("Accept", "application/json")
+              .header("Content-Type", "application/json")
+              .method(
+                  method,
+                  method.equals("POST")
+                      ? HttpRequest.BodyPublishers.ofString(UPDATE_BODY)
+                      : HttpRequest.BodyPublishers.noBody())
+              .build();
+      HttpResponse<String> response = client.send(request, 
HttpResponse.BodyHandlers.ofString());
+      assertTrue(
+          response.statusCode() == 400 || response.statusCode() == 404,
+          () -> method + " " + path + ": " + response.statusCode() + " " + 
response.body());
+      if (namespace.equals("a%2Eb")) {
+        assertEquals(400, response.statusCode());
+        if (!method.equals("HEAD")) {
+          ErrorResponse error =
+              IcebergObjectMapper.getInstance().readValue(response.body(), 
ErrorResponse.class);
+          assertEquals(400, error.code());
+          assertEquals("BadRequestException", error.type());
+        }
+      }
+    }
+    verifyNoInteractions(dispatcher);
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"catalog/", ""})
+  void testLiteralDotDotPathWithoutClientNormalization(String prefix) throws 
IOException {
+    for (String method : new String[] {"GET", "HEAD", "POST", "DELETE"}) {
+      String path = "/" + namespacePath(prefix, "..", method);
+      String body = method.equals("POST") ? UPDATE_BODY : "";
+      // Send the request target directly so the client cannot normalize the 
'..' segment away.
+      String request =
+          method
+              + " "
+              + path
+              + " HTTP/1.1\r\nHost: localhost\r\nContent-Type: 
application/json\r\n"
+              + "Connection: close\r\nContent-Length: "
+              + body.length()
+              + "\r\n\r\n"
+              + body;
+      try (Socket socket = new Socket()) {
+        socket.connect(new InetSocketAddress(getBaseUri().getHost(), 
getBaseUri().getPort()), 5000);
+        socket.setSoTimeout(10000);
+        
socket.getOutputStream().write(request.getBytes(StandardCharsets.US_ASCII));
+        socket.getOutputStream().flush();
+        String response =
+            new String(socket.getInputStream().readAllBytes(), 
StandardCharsets.UTF_8);
+        String statusLine = response.split("\r\n", 2)[0];
+        assertTrue(
+            statusLine.startsWith("HTTP/1.1 400") || 
statusLine.startsWith("HTTP/1.1 404"),
+            () -> method + " " + path + ": " + statusLine);
+      }
+    }
+    verifyNoInteractions(dispatcher);
+  }
+
+  private static String namespacePath(String prefix, String namespace, String 
method) {
+    return "v1/"
+        + prefix
+        + "namespaces/"
+        + namespace
+        + (method.equals("POST") ? "/properties" : "");
+  }
+}
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
index b9d25aa64b..6e2b26fd05 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
@@ -30,6 +30,7 @@ import java.lang.reflect.Parameter;
 import java.util.Collections;
 import java.util.Map;
 import java.util.Optional;
+import java.util.stream.Stream;
 import javax.ws.rs.core.Response;
 import org.aopalliance.intercept.MethodInvocation;
 import org.apache.commons.lang3.reflect.FieldUtils;
@@ -40,6 +41,7 @@ import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.UserPrincipal;
 import org.apache.gravitino.auth.ActiveRoles;
+import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.authorization.GravitinoAuthorizer;
 import org.apache.gravitino.exceptions.NoSuchCatalogException;
 import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
@@ -48,6 +50,7 @@ import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
 import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
 import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
 import org.apache.gravitino.iceberg.service.rest.IcebergConfigOperations;
+import org.apache.gravitino.iceberg.service.rest.IcebergNamespaceOperations;
 import org.apache.gravitino.iceberg.service.rest.IcebergTableOperations;
 import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
@@ -61,12 +64,16 @@ import org.apache.iceberg.exceptions.ForbiddenException;
 import org.apache.iceberg.rest.RESTCatalog;
 import org.apache.iceberg.rest.RESTUtil;
 import org.apache.iceberg.rest.requests.ReportMetricsRequest;
+import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest;
 import org.apache.iceberg.rest.responses.ErrorResponse;
 import org.glassfish.hk2.api.ActiveDescriptor;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 
@@ -135,6 +142,54 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
         tableId.toString());
   }
 
+  @ParameterizedTest
+  @MethodSource("invalidNamespaceRequests")
+  void testInvalidNamespaceIsBadRequestBeforeAuthorization(String operation, 
String namespace)
+      throws Throwable {
+    resetContext(null, false);
+    boolean update = operation.equals("updateNamespace");
+    Method method =
+        update
+            ? IcebergNamespaceOperations.class.getMethod(
+                operation, String.class, String.class, 
UpdateNamespacePropertiesRequest.class)
+            : IcebergNamespaceOperations.class.getMethod(operation, 
String.class, String.class);
+    Object[] args =
+        update
+            ? new Object[] {
+              TEST_CATALOG + "/", namespace, 
Mockito.mock(UpdateNamespacePropertiesRequest.class)
+            }
+            : new Object[] {TEST_CATALOG + "/", namespace};
+    MethodInvocation invocation = Mockito.mock(MethodInvocation.class);
+    Mockito.when(invocation.getMethod()).thenReturn(method);
+    Mockito.when(invocation.getArguments()).thenReturn(args);
+    IcebergMetadataAuthorizationMethodInterceptor interceptor =
+        new IcebergMetadataAuthorizationMethodInterceptor();
+
+    try (MockedStatic<AuthorizationUtils> authorization =
+            Mockito.mockStatic(AuthorizationUtils.class);
+        Response response = (Response) interceptor.invoke(invocation)) {
+      assertEquals(400, response.getStatus());
+      ErrorResponse error = (ErrorResponse) response.getEntity();
+      assertEquals(400, error.code());
+      assertEquals("BadRequestException", error.type());
+      assertTrue(error.message().contains("contains '.'"));
+      authorization.verifyNoInteractions();
+    }
+    Mockito.verify(invocation, Mockito.never()).proceed();
+  }
+
+  @Test
+  void testNamespaceLevelsAreDecodedOnlyOnce() throws Exception {
+    IcebergMetadataAuthorizationMethodInterceptor interceptor =
+        new IcebergMetadataAuthorizationMethodInterceptor();
+    Method method =
+        IcebergNamespaceOperations.class.getMethod("loadNamespace", 
String.class, String.class);
+    Map<Entity.EntityType, NameIdentifier> identifiers =
+        interceptor.extractNameIdentifierFromParameters(
+            method.getParameters(), new Object[] {TEST_CATALOG + "/", 
"a%252Eb"});
+    assertEquals("a%2Eb", identifiers.get(Entity.EntityType.SCHEMA).name());
+  }
+
   @Test
   public void testExtractTableNameIdentifierWithSpecialCharacters() throws 
Exception {
     IcebergMetadataAuthorizationMethodInterceptor interceptor =
@@ -615,6 +670,22 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
     }
   }
 
+  private static Stream<Arguments> invalidNamespaceRequests() {
+    return Stream.of("loadNamespace", "namespaceExists", "updateNamespace", 
"dropNamespace")
+        .flatMap(
+            operation ->
+                Stream.of(
+                        "a.b",
+                        "a%2Eb",
+                        "a%2eb",
+                        ".",
+                        "..",
+                        "%2e%2e",
+                        "parent%1Fa.b",
+                        "a%2Eb%1Fchild")
+                    .map(namespace -> Arguments.of(operation, namespace)));
+  }
+
   /** Test operations class to provide method annotations for testing. */
   @SuppressWarnings("unused")
   public static class TestOperations {
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergRequestBodyAuthzHandlers.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergRequestBodyAuthzHandlers.java
index a34ccf037f..0ef678a3f8 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergRequestBodyAuthzHandlers.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergRequestBodyAuthzHandlers.java
@@ -19,11 +19,17 @@
 
 package org.apache.gravitino.server.web.filter;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.lang.reflect.Method;
 import java.lang.reflect.Parameter;
 import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.apache.gravitino.Entity.EntityType;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.iceberg.service.rest.IcebergNamespaceOperations;
 import 
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
 import 
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata.RequestType;
 import org.apache.iceberg.catalog.Namespace;
@@ -32,6 +38,8 @@ import org.apache.iceberg.exceptions.BadRequestException;
 import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
 import org.apache.iceberg.rest.requests.RenameTableRequest;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
 import org.mockito.Mockito;
 
 /**
@@ -77,6 +85,27 @@ public class TestIcebergRequestBodyAuthzHandlers {
     }
   }
 
+  @ParameterizedTest
+  @MethodSource("invalidNamespaceLevels")
+  void testCreateRejectsDottedLevelsBeforeResolvingParent(Namespace namespace) 
throws Exception {
+    Method method =
+        IcebergNamespaceOperations.class.getMethod(
+            "createNamespace", String.class, CreateNamespaceRequest.class);
+    CreateNamespaceRequest request =
+        CreateNamespaceRequest.builder().withNamespace(namespace).build();
+    NameIdentifier catalog = NameIdentifier.of("metalake", "catalog");
+    Map<EntityType, NameIdentifier> identifiers = new HashMap<>();
+    identifiers.put(EntityType.CATALOG, catalog);
+
+    assertThrows(
+        BadRequestException.class,
+        () ->
+            new CreateNamespaceAuthzHandler(
+                    method.getParameters(), new Object[] {"catalog/", request})
+                .process(identifiers));
+    assertEquals(Map.of(EntityType.CATALOG, catalog), identifiers);
+  }
+
   @Test
   public void testRenameViewWithoutSourceOrDestinationIsBadRequest() throws 
Exception {
     Parameter[] parameters = parameters("renameView", 
RenameTableRequest.class);
@@ -103,6 +132,14 @@ public class TestIcebergRequestBodyAuthzHandlers {
     return method.getParameters();
   }
 
+  private static Stream<Namespace> invalidNamespaceLevels() {
+    return Stream.of(
+        Namespace.of("a.b"),
+        Namespace.of("a.b", "child"),
+        Namespace.of("parent", "a.b"),
+        Namespace.of("parent", "a.b", "child"));
+  }
+
   /** Provides annotated parameters like the REST operations do. */
   @SuppressWarnings("unused")
   public static class TestOperations {

Reply via email to