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

Duansg 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 b1a4f9630e [fix] stop exposing the jwt and aes master keys over the 
config api (#4270)
b1a4f9630e is described below

commit b1a4f9630ed361e004a47e1ca7cc422771a8b714
Author: Duansg <[email protected]>
AuthorDate: Mon Aug 3 11:30:56 2026 +0800

    [fix] stop exposing the jwt and aes master keys over the config api (#4270)
---
 .../controller/GeneralConfigController.java        | 21 +++++
 .../controller/GeneralConfigControllerTest.java    | 14 ++++
 hertzbeat-startup/src/main/resources/sureness.yml  | 10 +++
 .../startup/security/SurenessConfigRuleTest.java   | 97 ++++++++++++++++++++++
 .../hertzbeat-mysql-iotdb/conf/sureness.yml        | 10 +++
 .../hertzbeat-mysql-tdengine/conf/sureness.yml     | 10 +++
 .../conf/sureness.yml                              | 10 +++
 .../conf/sureness.yml                              | 10 +++
 .../conf/sureness.yml                              | 10 +++
 script/sureness.yml                                | 10 +++
 10 files changed, 202 insertions(+)

diff --git 
a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/GeneralConfigController.java
 
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/GeneralConfigController.java
index 5d365431f1..e17c018c0e 100644
--- 
a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/GeneralConfigController.java
+++ 
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/GeneralConfigController.java
@@ -23,11 +23,14 @@ import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.annotation.Resource;
 import jakarta.validation.constraints.NotNull;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.hertzbeat.common.constants.CommonConstants;
+import org.apache.hertzbeat.common.constants.GeneralConfigTypeEnum;
 import org.apache.hertzbeat.common.entity.dto.Message;
 import org.apache.hertzbeat.common.util.CommonUtil;
 import org.apache.hertzbeat.common.util.ResponseUtil;
 import org.apache.hertzbeat.manager.pojo.dto.TemplateConfig;
 import org.apache.hertzbeat.manager.service.ConfigService;
+import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
@@ -61,6 +64,18 @@ public class GeneralConfigController {
 
     private static final Set<String> ZONE_IDS = ZoneId.getAvailableZoneIds();
 
+    /**
+     * Config types that must never travel over the rest api.
+     *
+     * <p>The {@code secret} config holds the jwt signing key and the aes key 
that protects
+     * every stored monitor credential. Both are loaded straight from the 
persistence layer
+     * while the application boots and no part of the ui reads them, so 
handing them out
+     * over http has no legitimate use and would let a reader mint admin 
tokens and decrypt
+     * stored credentials. Refusing the read here keeps the guarantee even if 
the rbac rules
+     * for this route are ever loosened again.
+     */
+    private static final Set<String> NON_READABLE_TYPES = 
Set.of(GeneralConfigTypeEnum.secret.name());
+
     @Resource
     private ConfigService configService;
 
@@ -80,6 +95,12 @@ public class GeneralConfigController {
     public ResponseEntity<Message<Object>> getConfig(
             @Parameter(description = "Config Type", example = "email")
             @PathVariable("type") @NotNull final String type) {
+        if (NON_READABLE_TYPES.contains(type)) {
+            log.warn("Refused to serve the {} config over the rest api", type);
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(Message.fail(CommonConstants.FAIL_CODE,
+                            "The " + type + " config can not be read through 
the rest api."));
+        }
         return ResponseUtil.handle(() -> configService.getConfig(type));
     }
 
diff --git 
a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/GeneralConfigControllerTest.java
 
b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/GeneralConfigControllerTest.java
index 77f912c776..a81b51ec77 100644
--- 
a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/GeneralConfigControllerTest.java
+++ 
b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/GeneralConfigControllerTest.java
@@ -20,6 +20,8 @@ package org.apache.hertzbeat.manager.controller;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -84,6 +86,18 @@ class GeneralConfigControllerTest {
                 .andExpect(jsonPath("$.code").value((int) 
CommonConstants.SUCCESS_CODE));
     }
 
+    @Test
+    public void testGetSecretConfigIsRefused() throws Exception {
+
+        mockMvc.perform(get("/api/config/secret")
+                        .accept(MediaType.APPLICATION_JSON))
+                .andExpect(status().isForbidden())
+                .andExpect(jsonPath("$.code").value((int) 
CommonConstants.FAIL_CODE));
+
+        // the jwt signing key and the aes key must not even be loaded for a 
rest read
+        verify(configService, never()).getConfig(anyString());
+    }
+
     @Test
     public void testUpdateTemplateAppConfig() throws Exception {
 
diff --git a/hertzbeat-startup/src/main/resources/sureness.yml 
b/hertzbeat-startup/src/main/resources/sureness.yml
index c51f6318e2..a3198da74b 100644
--- a/hertzbeat-startup/src/main/resources/sureness.yml
+++ b/hertzbeat-startup/src/main/resources/sureness.yml
@@ -54,6 +54,16 @@ resourceRole:
   - /api/collector/**===post===[admin,user]
   - /api/collector/**===put===[admin,user]
   - /api/collector/**===delete===[admin]
+  # the secret config holds the jwt signing key and the aes key protecting 
stored
+  # credentials, so it stays admin only and is additionally refused by the 
controller
+  - /api/config/secret===get===[admin]
+  - /api/config/secret===post===[admin]
+  # the mute toggle sits in the notification widget every signed in user sees
+  - /api/config/mute===post===[admin,user,guest]
+  - /api/config/**===get===[admin,user,guest]
+  - /api/config/**===post===[admin]
+  - /api/config/**===put===[admin]
+  - /api/config/**===delete===[admin]
   - /api/status/page/**===get===[admin,user,guest]
   - /api/status/page/**===post===[admin,user]
   - /api/status/page/**===put===[admin,user]
diff --git 
a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/security/SurenessConfigRuleTest.java
 
b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/security/SurenessConfigRuleTest.java
new file mode 100644
index 0000000000..d5aa6056dc
--- /dev/null
+++ 
b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/security/SurenessConfigRuleTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.startup.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import com.usthe.sureness.matcher.util.TirePathTree;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.yaml.snakeyaml.Yaml;
+
+/**
+ * Guards the rbac rules covering {@code /api/config/**}.
+ *
+ * <p>A route missing from {@code sureness.yml} carries no role requirement, 
and
+ * {@code BaseProcessor.authorized} returns early when no role is required, so 
any
+ * authenticated caller reaches it. The config routes used to be absent 
entirely, which
+ * exposed {@code GET /api/config/secret} - the jwt signing key and the aes 
key that
+ * protects stored monitor credentials - to every account including {@code 
guest}.
+ */
+class SurenessConfigRuleTest {
+
+    private static final String SEPARATOR = "===";
+
+    private static TirePathTree roleTree;
+
+    @BeforeAll
+    @SuppressWarnings("unchecked")
+    static void loadSurenessConfig() throws IOException {
+        List<String> resourceRole;
+        try (InputStream in = 
SurenessConfigRuleTest.class.getResourceAsStream("/sureness.yml")) {
+            assertNotNull(in, "sureness.yml must be on the classpath");
+            Map<String, Object> document = new Yaml().load(in);
+            resourceRole = (List<String>) document.get("resourceRole");
+        }
+        assertNotNull(resourceRole, "resourceRole must be present");
+        roleTree = new TirePathTree();
+        roleTree.buildTree(new LinkedHashSet<>(resourceRole));
+    }
+
+    private static String rolesFor(String path, String method) {
+        return roleTree.searchPathFilterRoles(path + SEPARATOR + method);
+    }
+
+    @Test
+    void readingTheSecretConfigIsRestrictedToAdmin() {
+        assertEquals("[admin]", rolesFor("/api/config/secret", "get"));
+    }
+
+    @Test
+    void writingTheSecretConfigIsRestrictedToAdmin() {
+        assertEquals("[admin]", rolesFor("/api/config/secret", "post"));
+    }
+
+    @Test
+    void writingAnyOtherConfigIsRestrictedToAdmin() {
+        assertEquals("[admin]", rolesFor("/api/config/email", "post"));
+        assertEquals("[admin]", rolesFor("/api/config/oss", "post"));
+        assertEquals("[admin]", rolesFor("/api/config/template/linux", "put"));
+    }
+
+    /**
+     * The notification widget in the top bar lets every signed in user flip 
the mute flag,
+     * so this one write has to stay reachable by all roles.
+     */
+    @Test
+    void togglingMuteStaysOpenToEveryRole() {
+        assertEquals("[admin,user,guest]", rolesFor("/api/config/mute", 
"post"));
+        assertEquals("[admin,user,guest]", rolesFor("/api/config/mute", 
"get"));
+    }
+
+    @Test
+    void readingNonSecretConfigStaysOpenToEveryRole() {
+        assertEquals("[admin,user,guest]", rolesFor("/api/config/system", 
"get"));
+        assertEquals("[admin,user,guest]", rolesFor("/api/config/timezones", 
"get"));
+    }
+}
diff --git a/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml 
b/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml
index def13ade17..28936362c9 100644
--- a/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml
@@ -54,6 +54,16 @@ resourceRole:
   - /api/collector/**===post===[admin,user]
   - /api/collector/**===put===[admin,user]
   - /api/collector/**===delete===[admin]
+  # the secret config holds the jwt signing key and the aes key protecting 
stored
+  # credentials, so it stays admin only and is additionally refused by the 
controller
+  - /api/config/secret===get===[admin]
+  - /api/config/secret===post===[admin]
+  # the mute toggle sits in the notification widget every signed in user sees
+  - /api/config/mute===post===[admin,user,guest]
+  - /api/config/**===get===[admin,user,guest]
+  - /api/config/**===post===[admin]
+  - /api/config/**===put===[admin]
+  - /api/config/**===delete===[admin]
   - /api/status/page/**===get===[admin,user,guest]
   - /api/status/page/**===post===[admin,user]
   - /api/status/page/**===put===[admin,user]
diff --git a/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml 
b/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml
index def13ade17..28936362c9 100644
--- a/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml
@@ -54,6 +54,16 @@ resourceRole:
   - /api/collector/**===post===[admin,user]
   - /api/collector/**===put===[admin,user]
   - /api/collector/**===delete===[admin]
+  # the secret config holds the jwt signing key and the aes key protecting 
stored
+  # credentials, so it stays admin only and is additionally refused by the 
controller
+  - /api/config/secret===get===[admin]
+  - /api/config/secret===post===[admin]
+  # the mute toggle sits in the notification widget every signed in user sees
+  - /api/config/mute===post===[admin,user,guest]
+  - /api/config/**===get===[admin,user,guest]
+  - /api/config/**===post===[admin]
+  - /api/config/**===put===[admin]
+  - /api/config/**===delete===[admin]
   - /api/status/page/**===get===[admin,user,guest]
   - /api/status/page/**===post===[admin,user]
   - /api/status/page/**===put===[admin,user]
diff --git 
a/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml 
b/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml
index def13ade17..28936362c9 100644
--- a/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml
@@ -54,6 +54,16 @@ resourceRole:
   - /api/collector/**===post===[admin,user]
   - /api/collector/**===put===[admin,user]
   - /api/collector/**===delete===[admin]
+  # the secret config holds the jwt signing key and the aes key protecting 
stored
+  # credentials, so it stays admin only and is additionally refused by the 
controller
+  - /api/config/secret===get===[admin]
+  - /api/config/secret===post===[admin]
+  # the mute toggle sits in the notification widget every signed in user sees
+  - /api/config/mute===post===[admin,user,guest]
+  - /api/config/**===get===[admin,user,guest]
+  - /api/config/**===post===[admin]
+  - /api/config/**===put===[admin]
+  - /api/config/**===delete===[admin]
   - /api/status/page/**===get===[admin,user,guest]
   - /api/status/page/**===post===[admin,user]
   - /api/status/page/**===put===[admin,user]
diff --git 
a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml 
b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml
index d86ef543ce..ee2e532df0 100644
--- a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml
@@ -54,6 +54,16 @@ resourceRole:
   - /api/collector/**===post===[admin,user]
   - /api/collector/**===put===[admin,user]
   - /api/collector/**===delete===[admin]
+  # the secret config holds the jwt signing key and the aes key protecting 
stored
+  # credentials, so it stays admin only and is additionally refused by the 
controller
+  - /api/config/secret===get===[admin]
+  - /api/config/secret===post===[admin]
+  # the mute toggle sits in the notification widget every signed in user sees
+  - /api/config/mute===post===[admin,user,guest]
+  - /api/config/**===get===[admin,user,guest]
+  - /api/config/**===post===[admin]
+  - /api/config/**===put===[admin]
+  - /api/config/**===delete===[admin]
   - /api/status/page/**===get===[admin,user,guest]
   - /api/status/page/**===post===[admin,user]
   - /api/status/page/**===put===[admin,user]
diff --git 
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml 
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml
index def13ade17..28936362c9 100644
--- 
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml
+++ 
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml
@@ -54,6 +54,16 @@ resourceRole:
   - /api/collector/**===post===[admin,user]
   - /api/collector/**===put===[admin,user]
   - /api/collector/**===delete===[admin]
+  # the secret config holds the jwt signing key and the aes key protecting 
stored
+  # credentials, so it stays admin only and is additionally refused by the 
controller
+  - /api/config/secret===get===[admin]
+  - /api/config/secret===post===[admin]
+  # the mute toggle sits in the notification widget every signed in user sees
+  - /api/config/mute===post===[admin,user,guest]
+  - /api/config/**===get===[admin,user,guest]
+  - /api/config/**===post===[admin]
+  - /api/config/**===put===[admin]
+  - /api/config/**===delete===[admin]
   - /api/status/page/**===get===[admin,user,guest]
   - /api/status/page/**===post===[admin,user]
   - /api/status/page/**===put===[admin,user]
diff --git a/script/sureness.yml b/script/sureness.yml
index a356931407..2d6b4441ee 100644
--- a/script/sureness.yml
+++ b/script/sureness.yml
@@ -54,6 +54,16 @@ resourceRole:
   - /api/collector/**===post===[admin,user]
   - /api/collector/**===put===[admin,user]
   - /api/collector/**===delete===[admin]
+  # the secret config holds the jwt signing key and the aes key protecting 
stored
+  # credentials, so it stays admin only and is additionally refused by the 
controller
+  - /api/config/secret===get===[admin]
+  - /api/config/secret===post===[admin]
+  # the mute toggle sits in the notification widget every signed in user sees
+  - /api/config/mute===post===[admin,user,guest]
+  - /api/config/**===get===[admin,user,guest]
+  - /api/config/**===post===[admin]
+  - /api/config/**===put===[admin]
+  - /api/config/**===delete===[admin]
   - /api/status/page/**===get===[admin,user,guest]
   - /api/status/page/**===post===[admin,user]
   - /api/status/page/**===put===[admin,user]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to