This is an automated email from the ASF dual-hosted git repository.
yuluo-yx 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 0734013a2a fix: avoid LLM generates sql has sql injection error (#4200)
0734013a2a is described below
commit 0734013a2ac157a5df2ccd800bbb5dae201cd7c3
Author: shown <[email protected]>
AuthorDate: Sun Jul 12 22:45:19 2026 +0800
fix: avoid LLM generates sql has sql injection error (#4200)
Signed-off-by: yuluo-yx <[email protected]>
---
mcp-servers/mcp-log-server/README.md | 63 ++++++-
.../mcp/log/server/service/LogService.java | 183 ++++++++++++++-----
.../mcp/server/service/LogServiceTest.java | 201 +++++++++++++++++++++
3 files changed, 398 insertions(+), 49 deletions(-)
diff --git a/mcp-servers/mcp-log-server/README.md
b/mcp-servers/mcp-log-server/README.md
index 89bac97f12..b353142df1 100644
--- a/mcp-servers/mcp-log-server/README.md
+++ b/mcp-servers/mcp-log-server/README.md
@@ -1,12 +1,50 @@
-# HertzBeat MCP
+# HertzBeat 日志 MCP
-## HertzBeat Log MCP
+该 MCP 服务基于 GreptimeDB 查询 HertzBeat 自身运行日志。使用前需要启用 GreptimeDB 日志写入。
-Log MCP Service Based on GreptimeDB.
+## 结构化只读查询
-- GreptimeDB log writing needs to be enabled.
+服务只暴露 `query_logs`,不再接收或执行调用方提供的 SQL。服务端生成的查询固定为:
-## Claude Desktop Integration (stdio)
+```sql
+SELECT timestamp, severity_text, body
+FROM hzb_logs
+WHERE <结构化过滤条件>
+ORDER BY timestamp DESC
+LIMIT <1-100>
+```
+
+`query_logs` 支持以下可选参数:
+
+| 参数 | 类型 | 说明 |
+| --- | --- | --- |
+| `severity` | 字符串 | `TRACE`、`DEBUG`、`INFO`、`WARN`、`ERROR` 或 `FATAL` |
+| `keyword` | 字符串 | 日志正文关键词,最长 256 个字符 |
+| `startTime` | 整数 | 开始时间,Unix 毫秒时间戳 |
+| `endTime` | 整数 | 结束时间,Unix 毫秒时间戳 |
+| `limit` | 整数 | 返回条数,默认为 20,范围为 1~100 |
+
+调用示例:
+
+```json
+{
+ "severity": "ERROR",
+ "keyword": "connection refused",
+ "startTime": 1783785600000,
+ "endTime": 1783872000000,
+ "limit": 20
+}
+```
+
+当前 `hzb_logs` 表没有 `monitorId` 字段,因此本接口不提供无效的监控 ID 过滤。如果后续需要该能力,应先在
OpenTelemetry 日志写入链中定义并提取统一的监控 ID 字段。
+
+## GreptimeDB 账号
+
+服务支持通过 `greptime.username` 和 `greptime.password` 发送 HTTP Basic
Authentication。用户名和密码必须同时配置;建议配合 HTTPS 或可信内网使用。
+
+项目当前 Docker Compose 使用 GreptimeDB
`v0.14.3`,该版本只提供身份认证,不能限制用户为只读权限。因此当前真正生效的安全边界是“删除原始 SQL参数并固定生成单条 `SELECT`”。使用
GreptimeDB 1.0 及以上版本时,应为该 MCP 配置独立的 `ro`/`readonly` 账号。
+
+## Claude Desktop 集成(stdio)
```json
{
@@ -17,11 +55,20 @@ Log MCP Service Based on GreptimeDB.
"-Dspring.ai.mcp.server.stdio=true",
"-Dspring.main.web-application-type=none",
"-Dlogging.pattern.console=",
- "-Dgreptime.url=http://${IP}:4000",
"-jar",
"${PATH}/hertzbeat-mcp-2.0-SNAPSHOT.jar"
- ]
+ ],
+ "env": {
+ "GREPTIME_URL": "http://${IP}:4000",
+ "GREPTIME_DATABASE": "public",
+ "GREPTIME_USERNAME": "${READ_ONLY_USERNAME}",
+ "GREPTIME_PASSWORD": "${READ_ONLY_PASSWORD}"
+ }
}
}
}
-```
\ No newline at end of file
+```
+
+不启用 GreptimeDB 认证时,可以省略 `GREPTIME_USERNAME` 和 `GREPTIME_PASSWORD`;不能只配置其中一个。
+
+旧的 `getHertzbeatLog({"querySql": "..."})` Tool 已直接移除,不保留兼容入口,以免继续暴露任意 SQL执行能力。
diff --git
a/mcp-servers/mcp-log-server/src/main/java/org/apache/hertzbeat/mcp/log/server/service/LogService.java
b/mcp-servers/mcp-log-server/src/main/java/org/apache/hertzbeat/mcp/log/server/service/LogService.java
index c5023dc106..d87154561f 100644
---
a/mcp-servers/mcp-log-server/src/main/java/org/apache/hertzbeat/mcp/log/server/service/LogService.java
+++
b/mcp-servers/mcp-log-server/src/main/java/org/apache/hertzbeat/mcp/log/server/service/LogService.java
@@ -22,7 +22,9 @@ import com.jayway.jsonpath.ReadContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
@@ -33,79 +35,178 @@ import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
+import java.util.Set;
/**
- * Log query service
+ * Log query service.
*/
@Service
@Slf4j
public class LogService {
+ private static final String BASE_QUERY = "SELECT timestamp, severity_text,
body FROM hzb_logs";
private static final String TIMESTAMP_COLUMN = "timestamp";
private static final String SEVERITY_TEXT_COLUMN = "severity_text";
private static final String BODY_COLUMN = "body";
+ private static final int DEFAULT_LIMIT = 20;
+ private static final int MAX_LIMIT = 100;
+ private static final int MAX_KEYWORD_LENGTH = 256;
+ private static final long NANOS_PER_MILLISECOND = 1_000_000L;
+ private static final Set<String> SUPPORTED_SEVERITIES =
+ Set.of("TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL");
private static final DateTimeFormatter DATE_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
-
+
private final RestClient restClient;
+ private final String database;
- public LogService(@Value("${greptime.url}") String greptimeUrl) {
- this.restClient = RestClient.builder()
- .baseUrl(greptimeUrl)
- .defaultHeader("Accept", "application/json")
- .defaultHeader("Content-Type",
"application/x-www-form-urlencoded")
- .build();
+ /**
+ * Creates a log query service.
+ *
+ * @param greptimeUrl GreptimeDB URL
+ * @param database GreptimeDB database name
+ * @param username GreptimeDB username
+ * @param password GreptimeDB password
+ */
+ @Autowired
+ public LogService(@Value("${greptime.url}") String greptimeUrl,
+ @Value("${greptime.database:public}") String database,
+ @Value("${greptime.username:}") String username,
+ @Value("${greptime.password:}") String password) {
+ this(RestClient.builder(), greptimeUrl, database, username, password);
}
- @Tool(description = "System log query tool that supports filtering by
time, log level, and content")
- public String getHertzbeatLog(
- @ToolParam(description = """
- Query system logs with support for filtering by time, log
level, and content.
-
- Usage:
- 1. Table name: hzb_log
- 2. Common query examples:
- - Get latest 10 logs: SELECT * FROM hzb_log ORDER BY
timestamp DESC LIMIT 10
- - Query ERROR level logs: SELECT * FROM hzb_log WHERE
severity_number=17
- - Query specific time range: SELECT * FROM hzb_log WHERE
timestamp > '2024-01-01 00:00:00'
- - Query last ten minutes: SELECT * FROM hzb_log WHERE
timestamp >= now() - Interval '10m' AND timestamp < now()
- - Query logs at or above the INFO level: SELECT * FROM
hzb_log WHERE severity_number >= 9
-
- Field descriptions:
- 1. severity_number (log level):
- - 5: DEBUG
- - 9: INFO
- - 13: WARN
- - 17: ERROR
- 2. timestamp: log timestamp
- 3. body: log content
- """) String querySql) {
-
- if (!isValidQuery(querySql)) {
- return "Invalid query statement";
+ LogService(RestClient.Builder restClientBuilder, String greptimeUrl,
String database,
+ String username, String password) {
+ boolean hasUsername = username != null && !username.isBlank();
+ boolean hasPassword = password != null && !password.isBlank();
+ if (hasUsername != hasPassword) {
+ throw new IllegalArgumentException("GreptimeDB username and
password must be configured together");
+ }
+
+ RestClient.Builder builder = restClientBuilder
+ .baseUrl(greptimeUrl)
+ .defaultHeader(HttpHeaders.ACCEPT,
MediaType.APPLICATION_JSON_VALUE)
+ .defaultHeader(HttpHeaders.CONTENT_TYPE,
MediaType.APPLICATION_FORM_URLENCODED_VALUE);
+ if (hasUsername) {
+ builder.defaultHeaders(headers -> headers.setBasicAuth(username,
password));
}
+ this.restClient = builder.build();
+ this.database = database == null || database.isBlank() ? "public" :
database;
+ }
+ /**
+ * Queries HertzBeat system logs using structured filters.
+ *
+ * @param severity log severity
+ * @param keyword log body keyword
+ * @param startTime start time as a Unix timestamp in milliseconds
+ * @param endTime end time as a Unix timestamp in milliseconds
+ * @param limit maximum number of records to return
+ * @return formatted log query results
+ */
+ @Tool(name = "query_logs", description = "Query HertzBeat system logs with
structured read-only filters")
+ public String queryLogs(
+ @ToolParam(required = false,
+ description = "Log severity; supported values: TRACE,
DEBUG, INFO, WARN, ERROR, FATAL")
+ String severity,
+ @ToolParam(required = false, description = "Keyword to search in
the log body; maximum 256 characters")
+ String keyword,
+ @ToolParam(required = false, description = "Start time as a Unix
timestamp in milliseconds")
+ Long startTime,
+ @ToolParam(required = false, description = "End time as a Unix
timestamp in milliseconds")
+ Long endTime,
+ @ToolParam(required = false,
+ description = "Maximum number of records to return;
defaults to 20 and cannot exceed 100")
+ Integer limit) {
try {
- String response = executeQuery(querySql);
+ String response = executeQuery(buildQuery(severity, keyword,
startTime, endTime, limit));
return formatQueryResults(response);
+ } catch (IllegalArgumentException e) {
+ return "Invalid query parameters: " + e.getMessage();
} catch (Exception e) {
log.error("Failed to query logs", e);
- return "Failed to query logs: " + e.getMessage();
+ return "Failed to query logs";
+ }
+ }
+
+ static String buildQuery(String severity, String keyword, Long startTime,
Long endTime, Integer limit) {
+ if (startTime != null && startTime < 0) {
+ throw new IllegalArgumentException("Start time must not be
negative");
+ }
+ if (endTime != null && endTime < 0) {
+ throw new IllegalArgumentException("End time must not be
negative");
+ }
+ if (startTime != null && endTime != null && startTime > endTime) {
+ throw new IllegalArgumentException("Start time must not be later
than end time");
+ }
+
+ int queryLimit = limit == null ? DEFAULT_LIMIT : limit;
+ if (queryLimit < 1 || queryLimit > MAX_LIMIT) {
+ throw new IllegalArgumentException("Limit must be between 1 and
100");
+ }
+
+ List<String> conditions = new ArrayList<>(4);
+ if (startTime != null) {
+ conditions.add("timestamp >= " + toNanoseconds(startTime, "Start
time"));
+ }
+ if (endTime != null) {
+ conditions.add("timestamp <= " + toNanoseconds(endTime, "End
time"));
+ }
+
+ String normalizedSeverity = normalizeSeverity(severity);
+ if (normalizedSeverity != null) {
+ conditions.add("severity_text = '" + normalizedSeverity + "'");
+ }
+
+ if (keyword != null && !keyword.isBlank()) {
+ String normalizedKeyword = keyword.strip();
+ if (normalizedKeyword.length() > MAX_KEYWORD_LENGTH) {
+ throw new IllegalArgumentException("Log keyword must not
exceed 256 characters");
+ }
+ conditions.add("matches_term(body, '" +
escapeSqlLiteral(normalizedKeyword) + "')");
+ }
+
+ StringBuilder query = new StringBuilder(BASE_QUERY);
+ if (!conditions.isEmpty()) {
+ query.append(" WHERE ").append(String.join(" AND ", conditions));
+ }
+ return query.append(" ORDER BY timestamp DESC LIMIT
").append(queryLimit).toString();
+ }
+
+ private static String normalizeSeverity(String severity) {
+ if (severity == null || severity.isBlank()) {
+ return null;
+ }
+ String normalizedSeverity = severity.strip().toUpperCase(Locale.ROOT);
+ if (!SUPPORTED_SEVERITIES.contains(normalizedSeverity)) {
+ throw new IllegalArgumentException("Unsupported log severity");
+ }
+ return normalizedSeverity;
+ }
+
+ private static long toNanoseconds(long epochMilliseconds, String
fieldName) {
+ try {
+ return Math.multiplyExact(epochMilliseconds,
NANOS_PER_MILLISECOND);
+ } catch (ArithmeticException e) {
+ throw new IllegalArgumentException(fieldName + " is out of the
supported range", e);
}
}
- private boolean isValidQuery(String sql) {
- return sql != null && sql.toLowerCase().contains("hzb_log");
+ private static String escapeSqlLiteral(String value) {
+ return value.replace("'", "''");
}
private String executeQuery(String sql) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("sql", sql);
- log.debug("Executing SQL query: {}", sql);
-
+ log.debug("Executing structured log query");
+
return restClient.post()
- .uri("/v1/sql?db=public")
+ .uri(uriBuilder -> uriBuilder.path("/v1/sql").queryParam("db",
database).build())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(formData)
.retrieve()
diff --git
a/mcp-servers/mcp-log-server/src/test/java/org/apache/hertzbeat/mcp/server/service/LogServiceTest.java
b/mcp-servers/mcp-log-server/src/test/java/org/apache/hertzbeat/mcp/server/service/LogServiceTest.java
new file mode 100644
index 0000000000..9608222888
--- /dev/null
+++
b/mcp-servers/mcp-log-server/src/test/java/org/apache/hertzbeat/mcp/server/service/LogServiceTest.java
@@ -0,0 +1,201 @@
+/*
+ * 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.mcp.server.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static
org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.content;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.header;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.method;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static
org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
+import static
org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.method.MethodToolCallbackProvider;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestClient;
+import org.springframework.test.web.client.MockRestServiceServer;
+
+/**
+ * Security tests for {@link LogService} queries.
+ */
+class LogServiceTest {
+
+ private static final String BASE_QUERY = "SELECT timestamp, severity_text,
body FROM hzb_logs";
+
+ @Test
+ void shouldBuildDefaultReadOnlyQuery() {
+ assertThat(LogService.buildQuery(null, null, null, null, null))
+ .isEqualTo(BASE_QUERY + " ORDER BY timestamp DESC LIMIT 20");
+ }
+
+ @Test
+ void shouldExposeOnlyStructuredToolParameters() {
+ LogService service = new LogService(
+ RestClient.builder(), "http://localhost:4000", "public", "",
"");
+ ToolCallback[] callbacks =
MethodToolCallbackProvider.builder().toolObjects(service).build().getToolCallbacks();
+
+ assertThat(callbacks).singleElement().satisfies(callback -> {
+
assertThat(callback.getToolDefinition().name()).isEqualTo("query_logs");
+ assertThat(callback.getToolDefinition().inputSchema())
+ .contains("severity", "keyword", "startTime", "endTime",
"limit")
+ .doesNotContain("querySql");
+ });
+ }
+
+ @Test
+ void shouldBuildQueryFromValidatedFilters() {
+ String query = LogService.buildQuery(
+ " error ", " x'); DELETE FROM hzb_logs; -- ", 1L, 2L, 10);
+
+ assertThat(query).isEqualTo(BASE_QUERY
+ + " WHERE timestamp >= 1000000"
+ + " AND timestamp <= 2000000"
+ + " AND severity_text = 'ERROR'"
+ + " AND matches_term(body, 'x''); DELETE FROM hzb_logs; --')"
+ + " ORDER BY timestamp DESC LIMIT 10");
+ }
+
+ @Test
+ void shouldRejectInvalidFilters() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> LogService.buildQuery("NOTICE", null, null,
null, null));
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> LogService.buildQuery(null, null, -1L, null,
null));
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> LogService.buildQuery(null, null, null, -1L,
null));
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> LogService.buildQuery(null, null, 2L, 1L,
null));
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> LogService.buildQuery(null, null,
Long.MAX_VALUE, null, null));
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> LogService.buildQuery(null, "x".repeat(257),
null, null, null));
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> LogService.buildQuery(null, null, null,
null, 0));
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> LogService.buildQuery(null, null, null,
null, 101));
+ }
+
+ @Test
+ void shouldSendGeneratedSelectWithBasicAuthentication() {
+ RestClient.Builder restClientBuilder = RestClient.builder();
+ MockRestServiceServer server =
MockRestServiceServer.bindTo(restClientBuilder).build();
+ LogService service = new LogService(
+ restClientBuilder, "http://localhost:4000", "observability",
"reader", "secret");
+ String query = BASE_QUERY + " ORDER BY timestamp DESC LIMIT 20";
+ MultiValueMap<String, String> expectedForm = new
LinkedMultiValueMap<>();
+ expectedForm.setAll(Map.of("sql", query));
+ String credentials = Base64.getEncoder()
+
.encodeToString("reader:secret".getBytes(StandardCharsets.UTF_8));
+
+
server.expect(requestTo("http://localhost:4000/v1/sql?db=observability"))
+ .andExpect(method(HttpMethod.POST))
+ .andExpect(header(HttpHeaders.AUTHORIZATION, "Basic " +
credentials))
+ .andExpect(content().formData(expectedForm))
+ .andRespond(withSuccess(emptyResult(),
MediaType.APPLICATION_JSON));
+
+ assertThat(service.queryLogs(null, null, null, null,
null)).contains("No data");
+ server.verify();
+ }
+
+ @Test
+ void shouldFormatReturnedLogs() {
+ RestClient.Builder restClientBuilder = RestClient.builder();
+ MockRestServiceServer server =
MockRestServiceServer.bindTo(restClientBuilder).build();
+ LogService service = new LogService(restClientBuilder,
"http://localhost:4000", "public", "", "");
+ server.expect(requestTo("http://localhost:4000/v1/sql?db=public"))
+ .andRespond(withSuccess(resultWithOneLog(),
MediaType.APPLICATION_JSON));
+
+ assertThat(service.queryLogs("ERROR", "failure", null, null, 1))
+ .contains("ERROR", "failure", "Total 1 records");
+ server.verify();
+ }
+
+ @Test
+ void shouldReturnSafeErrorMessages() {
+ RestClient.Builder restClientBuilder = RestClient.builder();
+ MockRestServiceServer server =
MockRestServiceServer.bindTo(restClientBuilder).build();
+ LogService service = new LogService(restClientBuilder,
"http://localhost:4000", "public", "", "");
+
+ assertThat(service.queryLogs("NOTICE", null, null, null, null))
+ .isEqualTo("Invalid query parameters: Unsupported log
severity");
+
+ server.expect(requestTo("http://localhost:4000/v1/sql?db=public"))
+ .andRespond(withServerError());
+ assertThat(service.queryLogs(null, null, null, null,
null)).isEqualTo("Failed to query logs");
+ server.verify();
+ }
+
+ @Test
+ void shouldRequireCompleteCredentials() {
+ assertThatIllegalArgumentException().isThrownBy(() -> new LogService(
+ RestClient.builder(), "http://localhost:4000", "public",
"reader", ""));
+ assertThatIllegalArgumentException().isThrownBy(() -> new LogService(
+ RestClient.builder(), "http://localhost:4000", "public", "",
"secret"));
+ }
+
+ @Test
+ void shouldCreateWithProductionConstructor() {
+ assertThat(new LogService("http://localhost:4000", "public", "",
"")).isNotNull();
+ }
+
+ private static String emptyResult() {
+ return """
+ {
+ "output": [{
+ "records": {
+ "schema": {"column_schemas": [
+ {"name": "timestamp"},
+ {"name": "severity_text"},
+ {"name": "body"}
+ ]},
+ "rows": [],
+ "total_rows": 0
+ }
+ }]
+ }
+ """;
+ }
+
+ private static String resultWithOneLog() {
+ return """
+ {
+ "output": [{
+ "records": {
+ "schema": {"column_schemas": [
+ {"name": "timestamp"},
+ {"name": "severity_text"},
+ {"name": "body"}
+ ]},
+ "rows": [[0, "ERROR", "failure"]],
+ "total_rows": 1
+ }
+ }]
+ }
+ """;
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]