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 59c003aaef [fix] prevent masked notice secret replay to attacker URLs 
(#4262)
59c003aaef is described below

commit 59c003aaef5404a00890afc3ff7c11f4c0cd4e89
Author: shown <[email protected]>
AuthorDate: Wed Jul 29 20:54:26 2026 +0800

    [fix] prevent masked notice secret replay to attacker URLs (#4262)
    
    Signed-off-by: yuluo-yx <[email protected]>
---
 .../service/impl/NoticeConfigServiceImpl.java      | 19 ++++++-
 .../alert/util/NoticeReceiverMaskUtil.java         | 61 +++++++++++++++++++-
 .../alert/service/NoticeConfigServiceTest.java     | 35 ++++++++++++
 .../alert/util/NoticeReceiverMaskUtilTest.java     | 66 +++++++++++++++++++++-
 4 files changed, 175 insertions(+), 6 deletions(-)

diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/NoticeConfigServiceImpl.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/NoticeConfigServiceImpl.java
index bfbed03191..eab8ad014e 100644
--- 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/NoticeConfigServiceImpl.java
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/NoticeConfigServiceImpl.java
@@ -190,13 +190,28 @@ public class NoticeConfigServiceImpl implements 
NoticeConfigService, CommandLine
      *                                  be resolved, and saving would persist 
the placeholder
      */
     private void resolveMaskedSecrets(NoticeReceiver noticeReceiver) {
+        resolveMaskedSecrets(noticeReceiver, false);
+    }
+
+    /**
+     * Bind stored secrets to their original notification type and destination 
for test messages.
+     */
+    private void resolveMaskedSecretsForTest(NoticeReceiver noticeReceiver) {
+        resolveMaskedSecrets(noticeReceiver, true);
+    }
+
+    private void resolveMaskedSecrets(NoticeReceiver noticeReceiver, boolean 
testMessage) {
         if (noticeReceiver == null || noticeReceiver.getId() == null) {
             return;
         }
         NoticeReceiver existing = 
noticeReceiverDao.findById(noticeReceiver.getId())
                 .orElseThrow(() -> new IllegalArgumentException(
                         "The receiver with id " + noticeReceiver.getId() + " 
does not exist."));
-        NoticeReceiverMaskUtil.resolveMask(noticeReceiver, existing);
+        if (testMessage) {
+            NoticeReceiverMaskUtil.resolveMaskForTest(noticeReceiver, 
existing);
+        } else {
+            NoticeReceiverMaskUtil.resolveMask(noticeReceiver, existing);
+        }
     }
 
     @Override
@@ -339,7 +354,7 @@ public class NoticeConfigServiceImpl implements 
NoticeConfigService, CommandLine
 
     @Override
     public boolean sendTestMsg(NoticeReceiver noticeReceiver) {
-        resolveMaskedSecrets(noticeReceiver);
+        resolveMaskedSecretsForTest(noticeReceiver);
         Map<String, String> labels = new HashMap<>(8);
         labels.put(CommonConstants.LABEL_INSTANCE, "127.0.0.1");
         labels.put(CommonConstants.LABEL_ALERT_NAME, "CPU Usage Alert");
diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/util/NoticeReceiverMaskUtil.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/util/NoticeReceiverMaskUtil.java
index db5fe30a43..51da9d0f52 100644
--- 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/util/NoticeReceiverMaskUtil.java
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/util/NoticeReceiverMaskUtil.java
@@ -18,6 +18,7 @@
 package org.apache.hertzbeat.alert.util;
 
 import java.util.List;
+import java.util.Objects;
 import java.util.function.BiConsumer;
 import java.util.function.Function;
 import org.apache.commons.lang3.StringUtils;
@@ -92,6 +93,7 @@ public final class NoticeReceiverMaskUtil {
      * A re-entered secret or a cleared field is left untouched.
      * @param incoming receiver submitted by the ui, modified in place
      * @param existing receiver currently stored in the database
+     * @throws IllegalArgumentException if a submitted mask does not match the 
stored secret
      */
     public static void resolveMask(NoticeReceiver incoming, NoticeReceiver 
existing) {
         if (incoming == null || existing == null) {
@@ -102,15 +104,72 @@ public final class NoticeReceiverMaskUtil {
             String stored = field.getter().apply(existing);
             if (isMaskOf(submitted, stored)) {
                 field.setter().accept(incoming, stored);
+            } else if (isMaskValue(submitted)) {
+                throw new IllegalArgumentException(
+                        "The submitted secret mask does not match the stored 
secret.");
             }
         }
     }
 
+    /**
+     * Resolve masked secrets for a test message while binding them to their 
persisted destination.
+     * A caller that changes the notification type or a URL receiving 
authentication data
+     * must submit the new secret explicitly instead of reusing a stored 
secret mask.
+     * @param incoming receiver submitted for a test message, modified in place
+     * @param existing receiver currently stored in the database
+     * @throws IllegalArgumentException if a masked secret is combined with a 
changed destination
+     */
+    public static void resolveMaskForTest(NoticeReceiver incoming, 
NoticeReceiver existing) {
+        if (incoming == null || existing == null) {
+            return;
+        }
+
+        boolean containsStoredSecretMask = SECRET_FIELDS.stream()
+                .anyMatch(field -> isMaskOf(field.getter().apply(incoming), 
field.getter().apply(existing)));
+        if (containsStoredSecretMask && !Objects.equals(incoming.getType(), 
existing.getType())) {
+            throw new IllegalArgumentException(
+                    "The notification type cannot be changed when reusing a 
masked secret.");
+        }
+        rejectChangedSecretDestination(
+                incoming.getHookAuthToken(),
+                existing.getHookAuthToken(),
+                incoming.getHookUrl(),
+                existing.getHookUrl(),
+                "webhook URL");
+        rejectChangedSecretDestination(
+                incoming.getNtfyToken(),
+                existing.getNtfyToken(),
+                incoming.getNtfyServerUrl(),
+                existing.getNtfyServerUrl(),
+                "ntfy server URL");
+        resolveMask(incoming, existing);
+    }
+
+    private static void rejectChangedSecretDestination(
+            String submittedSecret,
+            String storedSecret,
+            String submittedDestination,
+            String storedDestination,
+            String destinationName) {
+        if (isMaskOf(submittedSecret, storedSecret)
+                && !Objects.equals(submittedDestination, storedDestination)) {
+            throw new IllegalArgumentException(
+                    "The " + destinationName + " cannot be changed when 
reusing a masked secret.");
+        }
+    }
+
     private static boolean isMaskOf(String submitted, String stored) {
         if (submitted == null || StringUtils.isBlank(stored)) {
             return false;
         }
-        return submitted.equals(SECRET_MASK) || 
submitted.equals(maskValue(stored));
+        return submitted.equals(maskValue(stored));
+    }
+
+    private static boolean isMaskValue(String value) {
+        return value != null
+                && value.startsWith(SECRET_MASK)
+                && (value.length() == SECRET_MASK.length()
+                    || value.length() == SECRET_MASK.length() + 
VISIBLE_SUFFIX_LENGTH);
     }
 
     private static String maskValue(String value) {
diff --git 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/NoticeConfigServiceTest.java
 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/NoticeConfigServiceTest.java
index 72f21bc3c8..2371e2692c 100644
--- 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/NoticeConfigServiceTest.java
+++ 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/NoticeConfigServiceTest.java
@@ -279,6 +279,41 @@ class NoticeConfigServiceTest {
         verify(dispatcherAlarm, never()).sendNoticeMsg(any(), any(), any());
     }
 
+    @Test
+    void sendTestMsgRejectsMaskedSecretReplayToChangedWebhookUrl() {
+        final NoticeReceiver stored = new NoticeReceiver();
+        stored.setId(5L);
+        stored.setType((byte) 2);
+        stored.setHookUrl("https://trusted.example/hook";);
+        stored.setHookAuthToken("hook-auth-token-abcd");
+        when(noticeReceiverDao.findById(5L)).thenReturn(Optional.of(stored));
+
+        final NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(stored);
+        incoming.setHookUrl("https://attacker.example/collect";);
+
+        assertThrows(IllegalArgumentException.class, () -> 
noticeConfigService.sendTestMsg(incoming));
+        verify(dispatcherAlarm, never()).sendNoticeMsg(any(), any(), any());
+    }
+
+    @Test
+    void sendTestMsgRejectsBareMaskReplayToChangedWebhookUrl() {
+        final NoticeReceiver stored = new NoticeReceiver();
+        stored.setId(5L);
+        stored.setType((byte) 2);
+        stored.setHookUrl("https://trusted.example/hook";);
+        stored.setHookAuthToken("hook-auth-token-abcd");
+        when(noticeReceiverDao.findById(5L)).thenReturn(Optional.of(stored));
+
+        final NoticeReceiver incoming = new NoticeReceiver();
+        incoming.setId(5L);
+        incoming.setType((byte) 2);
+        incoming.setHookUrl("https://attacker.example/collect";);
+        incoming.setHookAuthToken(NoticeReceiverMaskUtil.SECRET_MASK);
+
+        assertThrows(IllegalArgumentException.class, () -> 
noticeConfigService.sendTestMsg(incoming));
+        verify(dispatcherAlarm, never()).sendNoticeMsg(any(), any(), any());
+    }
+
     @Test
     void deleteReceiver() {
         final Long receiverId = 23342525L;
diff --git 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/util/NoticeReceiverMaskUtilTest.java
 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/util/NoticeReceiverMaskUtilTest.java
index 7d42ed6e87..cdac126c46 100644
--- 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/util/NoticeReceiverMaskUtilTest.java
+++ 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/util/NoticeReceiverMaskUtilTest.java
@@ -23,6 +23,7 @@ import org.junit.jupiter.api.Test;
 import static 
org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil.SECRET_MASK;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 /**
  * Test case for {@link NoticeReceiverMaskUtil}
@@ -104,7 +105,6 @@ class NoticeReceiverMaskUtilTest {
         NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(existing);
         incoming.setAccessToken("new-access-token-1234");
         incoming.setGotifyToken(null);
-        incoming.setNtfyToken(SECRET_MASK);
 
         NoticeReceiverMaskUtil.resolveMask(incoming, existing);
 
@@ -122,13 +122,73 @@ class NoticeReceiverMaskUtilTest {
     }
 
     @Test
-    void resolveMaskIgnoresMaskWhenNothingIsStored() {
+    void resolveMaskRejectsMaskWhenNothingIsStored() {
         NoticeReceiver existing = new NoticeReceiver();
         NoticeReceiver incoming = new NoticeReceiver();
         incoming.setAccessToken(SECRET_MASK);
 
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> NoticeReceiverMaskUtil.resolveMask(incoming, existing));
+    }
+
+    @Test
+    void resolveMaskRejectsBareMaskAsWildcard() {
+        NoticeReceiver existing = buildReceiverWithSecrets();
+        NoticeReceiver incoming = new NoticeReceiver();
+        incoming.setAccessToken(SECRET_MASK);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> NoticeReceiverMaskUtil.resolveMask(incoming, existing));
+    }
+
+    @Test
+    void resolveMaskRestoresBareMaskForShortStoredSecret() {
+        NoticeReceiver existing = new NoticeReceiver();
+        existing.setAccessToken("short-token");
+        NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(existing);
+
         NoticeReceiverMaskUtil.resolveMask(incoming, existing);
 
-        assertEquals(SECRET_MASK, incoming.getAccessToken());
+        assertEquals("short-token", incoming.getAccessToken());
+    }
+
+    @Test
+    void resolveMaskForTestRejectsMaskedWebhookSecretForChangedUrl() {
+        NoticeReceiver existing = buildReceiverWithSecrets();
+        existing.setType((byte) 2);
+        NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(existing);
+        incoming.setHookUrl("https://attacker.example/collect";);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> NoticeReceiverMaskUtil.resolveMaskForTest(incoming, 
existing));
+        assertEquals(SECRET_MASK + "abcd", incoming.getHookAuthToken());
+    }
+
+    @Test
+    void resolveMaskForTestRejectsMaskedNtfySecretForChangedServer() {
+        NoticeReceiver existing = buildReceiverWithSecrets();
+        existing.setType((byte) 15);
+        existing.setNtfyServerUrl("https://ntfy.example";);
+        NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(existing);
+        incoming.setNtfyServerUrl("https://attacker.example";);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> NoticeReceiverMaskUtil.resolveMaskForTest(incoming, 
existing));
+        assertEquals(SECRET_MASK + "NIz2", incoming.getNtfyToken());
+    }
+
+    @Test
+    void resolveMaskForTestRestoresSecretForUnchangedDestination() {
+        NoticeReceiver existing = buildReceiverWithSecrets();
+        existing.setType((byte) 2);
+        NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(existing);
+
+        NoticeReceiverMaskUtil.resolveMaskForTest(incoming, existing);
+
+        assertEquals("hook-auth-token-abcd", incoming.getHookAuthToken());
     }
 }


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

Reply via email to