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

yuqi1129 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 3dab3db67a [#13238] fix(common): accept JDBC URLs with a literal 
percent without weakening the unsafe-parameter scan (#13239)
3dab3db67a is described below

commit 3dab3db67a4d73632a8f1a9559f30123172089d0
Author: YangJie <[email protected]>
AuthorDate: Sat Sep 19 22:40:02 2026 -0400

    [#13238] fix(common): accept JDBC URLs with a literal percent without 
weakening the unsafe-parameter scan (#13239)
    
    ### What changes were proposed in this pull request?
    
    Decoding now falls back to the last fully decoded form instead of
    throwing, and the unsafe-parameter scan (and the
    `jdbc:mysql`/`mariadb`/`postgresql`/`h2` prefix gates) additionally
    scans a sanitized form in which malformed percent escapes are
    re-sanitized before every decode pass. `DataSourceUtils`'s duplicate
    throwing decode is removed and its H2 check uses the shared helper.
    
    ### Why are the changes needed?
    
    The throwing decode rejected legal JDBC URLs with a literal `%` or a
    once-encoded `%25`. Sanitizing on every pass is required for security:
    MySQL Connector/J decodes query tokens independently and ignores the URL
    fragment, so a malformed escape in the fragment (e.g. `#%zz`) must not
    halt decoding before a legitimately multi-encoded unsafe parameter name
    in the query resolves (e.g. `?%2561utoDeserialize=true#%zz`, where
    `%2561` decodes to `%61` then to `a`).
    
    Fix: #13238
    
    ### Does this PR introduce _any_ user-facing change?
    
    No API change. Legal JDBC URLs containing a literal `%` (e.g.
    `password=100%`) or a once-encoded `%25` are now accepted instead of
    rejected. The unsafe-parameter scan is not weakened.
    
    ### How was this patch tested?
    
    Extended `TestJdbcUrlUtils`: the literal-percent acceptance cases fail
    on the pre-fix tree and pass after the fix; a double-encoded unsafe
    parameter hidden behind a poisoned fragment
    (`?%2561utoDeserialize=true#%zz`, plus mysql/mariadb and no-fragment
    variants) is rejected, and it was accepted before the every-pass
    sanitization; and the existing unsafe-parameter cases (raw, single- and
    double-encoded, `connectionProperties` smuggling, fragment poison) still
    reject.
---
 .../catalog/jdbc/utils/DataSourceUtils.java        |  27 ++--
 .../jdbc/utils/TestDataSourceUrlValidation.java    |  17 +++
 .../org/apache/gravitino/utils/JdbcUrlUtils.java   | 100 ++++++++++++---
 .../apache/gravitino/utils/TestJdbcUrlUtils.java   | 139 +++++++++++++++++++--
 4 files changed, 239 insertions(+), 44 deletions(-)

diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
index b585464a0f..7a7847ebf5 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.catalog.jdbc.utils;
 import java.sql.SQLException;
 import java.time.Duration;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Properties;
 import javax.sql.DataSource;
@@ -76,11 +77,14 @@ public class DataSourceUtils {
     // configuration. Its INIT parameter allows arbitrary SQL (and Java code 
via CREATE ALIAS)
     // to execute at connection time, and the H2 driver class must also be 
blocked to prevent
     // bypassing this check via a mismatched driver and URL combination.
-    String decodedUrl = recursiveDecode(jdbcConfig.getJdbcUrl().toLowerCase());
-    if (decodedUrl.startsWith("jdbc:h2")) {
+    String lowerUrl = jdbcConfig.getJdbcUrl().toLowerCase(Locale.ROOT);
+    boolean isH2Url =
+        JdbcUrlUtils.decodedFormsForScan(lowerUrl).stream()
+            .anyMatch(form -> form.startsWith("jdbc:h2"));
+    if (isH2Url) {
       throw new GravitinoRuntimeException("H2 JDBC URL is not allowed in 
catalog configuration");
     }
-    if (jdbcConfig.getJdbcDriver().toLowerCase().startsWith("org.h2.")) {
+    if 
(jdbcConfig.getJdbcDriver().toLowerCase(Locale.ROOT).startsWith("org.h2.")) {
       throw new GravitinoRuntimeException("H2 JDBC driver is not allowed in 
catalog configuration");
     }
     // Reject DBCP2 pool properties that load arbitrary classes via reflection 
before handing the
@@ -205,23 +209,6 @@ public class DataSourceUtils {
     }
   }
 
-  private static String recursiveDecode(String url) {
-    String prev;
-    String decoded = url;
-    int max = 5;
-
-    do {
-      prev = decoded;
-      try {
-        decoded = java.net.URLDecoder.decode(prev, "UTF-8");
-      } catch (Exception e) {
-        throw new GravitinoRuntimeException("Unable to decode JDBC URL");
-      }
-    } while (!prev.equals(decoded) && --max > 0);
-
-    return decoded;
-  }
-
   public static void closeDataSource(DataSource dataSource) {
     if (null != dataSource) {
       try {
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
index 128c3c1ade..d11e87a179 100644
--- 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
@@ -178,6 +178,23 @@ public class TestDataSourceUrlValidation {
         "H2 JDBC URL is not allowed in catalog configuration", 
gre.getMessage());
   }
 
+  @Test
+  public void testRejectH2UrlWithMalformedPercent() {
+    // A malformed percent escape in an H2 URL must not derail the 
URL-decoding scan (it falls back
+    // instead of throwing), so the H2 guard still fires on the untouched 
"jdbc:h2" prefix.
+    HashMap<String, String> properties = Maps.newHashMap();
+    properties.put(JdbcConfig.JDBC_DRIVER.getKey(), "org.postgresql.Driver");
+    properties.put(JdbcConfig.JDBC_URL.getKey(), 
"jdbc:h2:mem:test?password=100%");
+    properties.put(JdbcConfig.USERNAME.getKey(), "test");
+    properties.put(JdbcConfig.PASSWORD.getKey(), "test");
+
+    GravitinoRuntimeException gre =
+        Assertions.assertThrows(
+            GravitinoRuntimeException.class, () -> 
DataSourceUtils.createDataSource(properties));
+    Assertions.assertEquals(
+        "H2 JDBC URL is not allowed in catalog configuration", 
gre.getMessage());
+  }
+
   @Test
   public void testRejectH2Driver() {
     HashMap<String, String> properties = Maps.newHashMap();
diff --git a/common/src/main/java/org/apache/gravitino/utils/JdbcUrlUtils.java 
b/common/src/main/java/org/apache/gravitino/utils/JdbcUrlUtils.java
index b451ed7507..fcc2b1046f 100644
--- a/common/src/main/java/org/apache/gravitino/utils/JdbcUrlUtils.java
+++ b/common/src/main/java/org/apache/gravitino/utils/JdbcUrlUtils.java
@@ -22,6 +22,7 @@ package org.apache.gravitino.utils;
 import com.google.common.base.Preconditions;
 import java.io.IOException;
 import java.io.StringReader;
+import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.util.Arrays;
 import java.util.Collections;
@@ -86,23 +87,56 @@ public class JdbcUrlUtils {
     Preconditions.checkArgument(StringUtils.isNotBlank(url), "JDBC URL can't 
be blank");
 
     String lowerUrl = url.toLowerCase(Locale.ROOT);
-    String decodedUrl = recursiveDecode(lowerUrl);
-
-    if (decodedUrl.startsWith("jdbc:mysql")) {
-      checkUnsafeParameters(decodedUrl, all, UNSAFE_MYSQL_PARAMETERS, "MySQL");
-    } else if (decodedUrl.startsWith("jdbc:mariadb")) {
-      checkUnsafeParameters(decodedUrl, all, UNSAFE_MYSQL_PARAMETERS, 
"MariaDB");
-    } else if (decodedUrl.startsWith("jdbc:postgresql")) {
-      checkUnsafeParameters(decodedUrl, all, UNSAFE_POSTGRES_PARAMETERS, 
"PostgreSQL");
+    List<String> decodedForms = decodedFormsForScan(lowerUrl);
+
+    if (anyFormStartsWith(decodedForms, "jdbc:mysql")) {
+      checkUnsafeParameters(decodedForms, all, UNSAFE_MYSQL_PARAMETERS, 
"MySQL");
+    } else if (anyFormStartsWith(decodedForms, "jdbc:mariadb")) {
+      checkUnsafeParameters(decodedForms, all, UNSAFE_MYSQL_PARAMETERS, 
"MariaDB");
+    } else if (anyFormStartsWith(decodedForms, "jdbc:postgresql")) {
+      checkUnsafeParameters(decodedForms, all, UNSAFE_POSTGRES_PARAMETERS, 
"PostgreSQL");
     }
   }
 
-  private static void checkUnsafeParameters(
-      String url, Map<String, String> config, List<String> unsafeParams, 
String dbType) {
+  /**
+   * Returns the decoded forms of a JDBC URL that unsafe-parameter scans must 
cover. Drivers such as
+   * MySQL Connector/J decode query tokens independently and ignore the URL 
fragment, so a malformed
+   * percent escape in one part of the URL must not stop the scan from 
revealing parameter names
+   * hidden behind valid encodings in another part. The returned forms are the 
URL decoded until the
+   * first undecodable escape, plus the fully decoded form of the URL with 
malformed escapes treated
+   * as literal '{@code %}' characters.
+   *
+   * @param url the JDBC URL, already lower-cased by the caller.
+   * @return the candidate decoded forms, never empty.
+   */
+  public static List<String> decodedFormsForScan(String url) {
+    // Percent-decoding can reintroduce upper-case characters (e.g. "%4a" -> 
'J'), so the
+    // returned forms are lower-cased for substring and prefix matching.
+    String stoppedAtMalformed = recursiveDecode(url).toLowerCase(Locale.ROOT);
+    String fullyDecoded = 
recursiveDecodeSanitizingEachPass(url).toLowerCase(Locale.ROOT);
+    if (fullyDecoded.equals(stoppedAtMalformed)) {
+      return Collections.singletonList(stoppedAtMalformed);
+    }
+    return Arrays.asList(stoppedAtMalformed, fullyDecoded);
+  }
 
-    // Percent-decoding in recursiveDecode can reintroduce upper-case 
characters (e.g. "%4a" ->
-    // 'J'), so lower-case again here rather than relying on the pre-decode 
lower-casing.
-    String lowerUrl = url.toLowerCase(Locale.ROOT);
+  /**
+   * Replaces every '{@code %}' that is not followed by two hex digits with 
the escape for a literal
+   * '{@code %}', making the URL decodable by {@link URLDecoder}.
+   */
+  private static String sanitizeMalformedPercentEscapes(String url) {
+    return url.replaceAll("%(?![0-9a-fA-F]{2})", "%25");
+  }
+
+  private static boolean anyFormStartsWith(List<String> forms, String prefix) {
+    return forms.stream().anyMatch(form -> form.startsWith(prefix));
+  }
+
+  private static void checkUnsafeParameters(
+      List<String> decodedForms,
+      Map<String, String> config,
+      List<String> unsafeParams,
+      String dbType) {
 
     // Parameter names that reach the JDBC driver through the config map: the 
config keys
     // themselves (defense in depth) plus any names embedded in the DBCP2 
"connectionProperties"
@@ -111,7 +145,8 @@ public class JdbcUrlUtils {
 
     for (String param : unsafeParams) {
       String lowerParam = param.toLowerCase(Locale.ROOT);
-      if (lowerUrl.contains(lowerParam) || 
configParamNames.contains(lowerParam)) {
+      boolean inUrl = decodedForms.stream().anyMatch(form -> 
form.contains(lowerParam));
+      if (inUrl || configParamNames.contains(lowerParam)) {
         throw new GravitinoRuntimeException(
             "Unsafe %s parameter '%s' detected in JDBC configuration", dbType, 
param);
       }
@@ -127,8 +162,41 @@ public class JdbcUrlUtils {
       prev = decoded;
       try {
         decoded = URLDecoder.decode(prev, "UTF-8");
-      } catch (Exception e) {
-        throw new GravitinoRuntimeException("Unable to decode JDBC URL");
+      } catch (UnsupportedEncodingException e) {
+        // UTF-8 is guaranteed to be supported by the JVM specification.
+        throw new RuntimeException(e);
+      } catch (IllegalArgumentException e) {
+        // The URL contains a literal '%' that is not part of a valid escape 
(e.g. a password
+        // like "100%"). JDBC URLs are not required to be validly 
percent-encoded, so keep the
+        // last fully decoded form instead of rejecting the URL: any parameter 
name hidden
+        // behind valid encodings was already revealed by the previous passes, 
and a malformed
+        // escape cannot additionally hide a readable name from this check.
+        return prev;
+      }
+    } while (!prev.equals(decoded) && --max > 0);
+
+    return decoded;
+  }
+
+  /**
+   * Fully decodes {@code url}, neutralizing malformed percent escapes before 
EVERY decode pass.
+   * Sanitizing only once is not enough: decoding "{@code %25zz}" yields 
"{@code %zz}" again, so a
+   * later pass would halt at the regenerated malformed escape and leave a 
doubly-encoded parameter
+   * name elsewhere (e.g. "{@code %2561utoDeserialize}", which needs two 
passes to reveal 'a') only
+   * partially decoded. Because each pass is sanitized first, {@link 
URLDecoder} never throws here.
+   */
+  private static String recursiveDecodeSanitizingEachPass(String url) {
+    String prev;
+    String decoded = url;
+    int max = 5;
+
+    do {
+      prev = decoded;
+      try {
+        decoded = URLDecoder.decode(sanitizeMalformedPercentEscapes(prev), 
"UTF-8");
+      } catch (UnsupportedEncodingException e) {
+        // UTF-8 is guaranteed to be supported by the JVM specification.
+        throw new RuntimeException(e);
       }
     } while (!prev.equals(decoded) && --max > 0);
 
diff --git 
a/common/src/test/java/org/apache/gravitino/utils/TestJdbcUrlUtils.java 
b/common/src/test/java/org/apache/gravitino/utils/TestJdbcUrlUtils.java
index 056178ca72..93557de8c6 100644
--- a/common/src/test/java/org/apache/gravitino/utils/TestJdbcUrlUtils.java
+++ b/common/src/test/java/org/apache/gravitino/utils/TestJdbcUrlUtils.java
@@ -20,6 +20,7 @@
 package org.apache.gravitino.utils;
 
 import java.util.Collections;
+import java.util.List;
 import java.util.Locale;
 import org.apache.gravitino.exceptions.GravitinoRuntimeException;
 import org.junit.jupiter.api.Assertions;
@@ -31,14 +32,15 @@ import org.junit.jupiter.api.parallel.Resources;
 public class TestJdbcUrlUtils {
 
   @Test
-  public void whenMalformedUrlGiven_ShouldThrowGravitinoRuntimeException() {
-    GravitinoRuntimeException gre =
-        Assertions.assertThrows(
-            GravitinoRuntimeException.class,
-            () ->
-                JdbcUrlUtils.validateJdbcConfig(
-                    "testDriver", "malformed%ZZurl", 
Collections.singletonMap("test", "test")));
-    Assertions.assertEquals("Unable to decode JDBC URL", gre.getMessage());
+  public void whenMalformedUrlGiven_ShouldFallBackToLastDecodedForm() {
+    // A percent escape that URLDecoder cannot decode (e.g. a literal '%' in a 
password) no
+    // longer rejects the whole URL: JDBC URLs are not required to be 
percent-encoded. The
+    // validation still scans the last successfully decoded form, so unsafe 
parameters remain
+    // detectable (see 
unsafeParameterBehindEncodingIsStillDetectedWithLiteralPercent).
+    Assertions.assertDoesNotThrow(
+        () ->
+            JdbcUrlUtils.validateJdbcConfig(
+                "testDriver", "malformed%ZZurl", 
Collections.singletonMap("test", "test")));
   }
 
   @Test
@@ -62,6 +64,127 @@ public class TestJdbcUrlUtils {
                 Collections.singletonMap("test", "test")));
   }
 
+  @Test
+  public void testValidateJdbcConfigWithLiteralPercentInUrl() {
+    // A literal '%' (e.g. a password like "100%") is legal in a JDBC URL; 
before the fix the
+    // decoder threw "Unable to decode JDBC URL" for it.
+    Assertions.assertDoesNotThrow(
+        () ->
+            JdbcUrlUtils.validateJdbcConfig(
+                "testDriver",
+                "jdbc:mysql://localhost:3306/test?password=100%",
+                Collections.emptyMap()));
+
+    // A once-encoded '%25' decodes to a literal '%', which must not be 
rejected either.
+    Assertions.assertDoesNotThrow(
+        () ->
+            JdbcUrlUtils.validateJdbcConfig(
+                "testDriver",
+                "jdbc:postgresql://localhost:5432/test?password=pa%25ss",
+                Collections.emptyMap()));
+  }
+
+  @Test
+  public void unsafeParameterBehindEncodingWithFragmentPoisonIsStillDetected() 
{
+    // MySQL Connector/J decodes query tokens independently and ignores the 
URL fragment, so a
+    // malformed escape in the fragment must not stop the scan from revealing 
an encoded unsafe
+    // parameter name in the query.
+    Assertions.assertThrows(
+        GravitinoRuntimeException.class,
+        () ->
+            JdbcUrlUtils.validateJdbcConfig(
+                "testDriver",
+                "jdbc:mysql://localhost:3306/test?%61utoDeserialize=true#%zz",
+                Collections.emptyMap()));
+    Assertions.assertThrows(
+        GravitinoRuntimeException.class,
+        () ->
+            JdbcUrlUtils.validateJdbcConfig(
+                "testDriver",
+                
"jdbc:mysql://localhost:3306/test?%71ueryInterceptors=x#frag%25",
+                Collections.emptyMap()));
+  }
+
+  @Test
+  public void doubleEncodedUnsafeParamWithPoisonedFragmentIsRejected() {
+    // Copilot's alleged bypass on PR #13239: a double-encoded parameter name 
combined with a
+    // malformed '#%zz' fragment. The discriminating case hides the FIRST 
letter of the name behind
+    // the double encoding (%2561 -> %61 -> 'a'), so no scanned form contains 
the literal name until
+    // the query token is fully decoded. Because a single pre-pass 
sanitization turns '%zz' into
+    // '%25zz' but the first decode pass regenerates '%zz', the second pass 
would halt at the
+    // malformed fragment before '%61' becomes 'a' -- unless malformed escapes 
are sanitized on
+    // every pass. MySQL Connector/J decodes query tokens independently and 
ignores the fragment.
+    assertUnsafeRejected("jdbc:mysql://h/db?%2561utoDeserialize=true#%zz");
+    assertUnsafeRejected("jdbc:mariadb://h/db?%2561utoDeserialize=true#%zz");
+    // Without the poisoned fragment, recursive decoding already reveals the 
hidden name.
+    assertUnsafeRejected("jdbc:mysql://h/db?%2561utoDeserialize=true");
+
+    // The exact strings from the report keep the literal 'autoDeserialize' 
after the double
+    // encoding, so they are rejected on the cleartext substring alone (a 
weaker path than the one
+    // under test); kept as a regression anchor for the reported input.
+    assertUnsafeRejected("jdbc:mysql://h/db?%2561autoDeserialize=true#%zz");
+    assertUnsafeRejected("jdbc:mysql://h/db?%2561autoDeserialize=true");
+  }
+
+  private static void assertUnsafeRejected(String url) {
+    Assertions.assertThrows(
+        GravitinoRuntimeException.class,
+        () -> JdbcUrlUtils.validateJdbcConfig("testDriver", url, 
Collections.emptyMap()),
+        () -> "Expected unsafe URL to be rejected but it was accepted: " + 
url);
+  }
+
+  @Test
+  public void unsafeParamBehindUpperCaseHexEscapeMidDecodeIsRejected() {
+    // Regression for a case-sensitivity gap in the malformed-escape 
sanitizer. A decode pass can
+    // regenerate a percent escape whose hex digits include an upper-case 
letter: "%25%36%46"
+    // decodes to "%6F", the escape for 'o'. The caller only lower-cases the 
ORIGINAL URL, so this
+    // "%6F" appears mid-decode. A sanitizer that recognizes only lower-case 
hex treats "%6F" as
+    // malformed and rewrites it to "%256F", so it never decodes to 'o' and 
the hidden
+    // 'autoDeserialize' is missed. The '#%zz' fragment poisons the 
non-sanitizing decode path
+    // (MySQL Connector/J decodes query tokens independently and ignores the 
fragment), forcing
+    // detection through the sanitizing path under test.
+    assertUnsafeRejected("jdbc:mysql://h/db?aut%25%36%46deserialize=true#%zz");
+    
assertUnsafeRejected("jdbc:mariadb://h/db?aut%25%36%46deserialize=true#%zz");
+  }
+
+  @Test
+  public void validUpperCaseHexEscapeIsNotMangledBySanitizer() {
+    // A valid percent escape whose hex digits include an upper-case letter 
("%2F", identical to
+    // "%2f") must decode the same as its lower-case form and must not be 
mangled into a literal
+    // '%'. Such an escape reaches the sanitizer only mid-decode, past the 
caller's initial
+    // lower-casing, so exercise the sanitizing decode path directly: 
"%25%32%46" -> "%2F" -> '/',
+    // and "%25%32%66" -> "%2f" -> '/'. The '#%zz' fragment keeps the 
non-sanitizing path from
+    // decoding, so the fully decoded form is produced by the path under test.
+    List<String> upperHexForms =
+        
JdbcUrlUtils.decodedFormsForScan("jdbc:mysql://h/db?p=a%25%32%46b#%zz");
+    List<String> lowerHexForms =
+        
JdbcUrlUtils.decodedFormsForScan("jdbc:mysql://h/db?p=a%25%32%66b#%zz");
+
+    String upperDecoded = upperHexForms.get(upperHexForms.size() - 1);
+    String lowerDecoded = lowerHexForms.get(lowerHexForms.size() - 1);
+    Assertions.assertEquals(
+        lowerDecoded,
+        upperDecoded,
+        "Upper-case hex escape must decode identically to its lower-case 
form");
+    Assertions.assertTrue(
+        upperDecoded.contains("a/b"),
+        () -> "Expected '%2F' to decode to '/', but the sanitizer mangled it: 
" + upperDecoded);
+  }
+
+  @Test
+  public void unsafeParameterBehindEncodingIsStillDetectedWithLiteralPercent() 
{
+    // The decoded fallback must not weaken detection: an unsafe parameter 
that remains readable
+    // after the last successful decode is still rejected even when the URL 
also carries a
+    // literal '%'.
+    Assertions.assertThrows(
+        GravitinoRuntimeException.class,
+        () ->
+            JdbcUrlUtils.validateJdbcConfig(
+                "testDriver",
+                
"jdbc:mysql://localhost:3306/test?password=100%&autoDeserialize=true",
+                Collections.emptyMap()));
+  }
+
   @Test
   public void 
whenUnsafeParameterGivenForMySQL_ShouldThrowGravitinoRuntimeException() {
 

Reply via email to