[improve] mask secret fields of notice receiver in rest api response (#4220)

This commit is contained in:
Duansg
2026-07-21 23:11:30 +08:00
committed by GitHub
parent 107f7d02ff
commit c9e3338c9f
7 changed files with 322 additions and 4 deletions
@@ -30,6 +30,7 @@ import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
import org.apache.hertzbeat.alert.service.NoticeConfigService;
import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
@@ -87,14 +88,16 @@ public class NoticeConfigController {
@Parameter(description = "en: Recipient name,support fuzzy query", example = "tom") @RequestParam(required = false) final String name,
@Parameter(description = "en: List current page", example = "0") @RequestParam(defaultValue = "0") final int pageIndex,
@Parameter(description = "en: Number of list pages", example = "8") @RequestParam(defaultValue = "8") final int pageSize) {
return ResponseEntity.ok(Message.success(noticeConfigService.getNoticeReceivers(name, pageIndex, pageSize)));
return ResponseEntity.ok(Message.success(noticeConfigService.getNoticeReceivers(name, pageIndex, pageSize)
.map(NoticeReceiverMaskUtil::mask)));
}
@GetMapping(path = "/receivers/all")
@Operation(summary = "Get a list of all message notification recipients",
description = "Get a list of all message notification recipients")
public ResponseEntity<Message<List<NoticeReceiver>>> getAllReceivers() {
return ResponseEntity.ok(Message.success(noticeConfigService.getAllNoticeReceivers()));
return ResponseEntity.ok(Message.success(noticeConfigService.getAllNoticeReceivers().stream()
.map(NoticeReceiverMaskUtil::mask).toList()));
}
@GetMapping(path = "/receiver/{id}")
@@ -106,7 +109,7 @@ public class NoticeConfigController {
if (noticeReceiver == null) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, "The relevant information of the recipient could not be found, please check whether the parameters are correct or refresh the page"));
}
return ResponseEntity.ok(Message.success(noticeReceiver));
return ResponseEntity.ok(Message.success(NoticeReceiverMaskUtil.mask(noticeReceiver)));
}
@PostMapping(path = "/rule")
@@ -31,6 +31,7 @@ import org.apache.hertzbeat.alert.dao.NoticeReceiverDao;
import org.apache.hertzbeat.alert.dao.NoticeRuleDao;
import org.apache.hertzbeat.alert.dao.NoticeTemplateDao;
import org.apache.hertzbeat.alert.service.NoticeConfigService;
import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
@@ -176,9 +177,23 @@ public class NoticeConfigServiceImpl implements NoticeConfigService, CommandLine
@Override
public void editReceiver(NoticeReceiver noticeReceiver) {
resolveMaskedSecrets(noticeReceiver);
noticeReceiverDao.save(noticeReceiver);
}
/**
* The rest api returns receivers with masked secret fields, so a receiver submitted
* from the ui may carry the mask placeholder instead of the real secret.
* Restore such fields from the stored entity before using the receiver.
*/
private void resolveMaskedSecrets(NoticeReceiver noticeReceiver) {
if (noticeReceiver == null || noticeReceiver.getId() == null) {
return;
}
noticeReceiverDao.findById(noticeReceiver.getId())
.ifPresent(existing -> NoticeReceiverMaskUtil.resolveMask(noticeReceiver, existing));
}
@Override
public void deleteReceiver(Long receiverId) {
noticeReceiverDao.deleteById(receiverId);
@@ -319,6 +334,7 @@ public class NoticeConfigServiceImpl implements NoticeConfigService, CommandLine
@Override
public boolean sendTestMsg(NoticeReceiver noticeReceiver) {
resolveMaskedSecrets(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");
@@ -0,0 +1,125 @@
/*
* 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.alert.util;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Function;
import org.apache.commons.lang3.StringUtils;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
/**
* Masks the secret fields of {@link NoticeReceiver} before it is exposed through the rest api,
* and resolves masked values back to the stored secrets when an edited receiver is submitted.
* A secret long enough keeps its last characters visible so different tokens stay
* distinguishable in the ui, while an unchanged secret round-trips through the ui
* without ever leaving the server.
*/
public final class NoticeReceiverMaskUtil {
/**
* Fixed-length placeholder replacing the hidden part of a secret. Its length is
* constant on purpose so the mask never reveals how long the real secret is.
*/
public static final String SECRET_MASK = "******";
/**
* A secret shorter than this is masked entirely: revealing a suffix of a short
* secret would give away too large a fraction of it.
*/
private static final int MIN_LENGTH_TO_SHOW_SUFFIX = 12;
/**
* Number of trailing characters kept visible for a long secret,
* enough to tell configured tokens apart.
*/
private static final int VISIBLE_SUFFIX_LENGTH = 4;
private record SecretField(Function<NoticeReceiver, String> getter, BiConsumer<NoticeReceiver, String> setter) {
}
private static final List<SecretField> SECRET_FIELDS = List.of(
new SecretField(NoticeReceiver::getHookAuthToken, NoticeReceiver::setHookAuthToken),
new SecretField(NoticeReceiver::getAccessToken, NoticeReceiver::setAccessToken),
new SecretField(NoticeReceiver::getTgBotToken, NoticeReceiver::setTgBotToken),
new SecretField(NoticeReceiver::getSlackWebHookUrl, NoticeReceiver::setSlackWebHookUrl),
new SecretField(NoticeReceiver::getAppSecret, NoticeReceiver::setAppSecret),
new SecretField(NoticeReceiver::getDiscordBotToken, NoticeReceiver::setDiscordBotToken),
new SecretField(NoticeReceiver::getSmnAk, NoticeReceiver::setSmnAk),
new SecretField(NoticeReceiver::getSmnSk, NoticeReceiver::setSmnSk),
new SecretField(NoticeReceiver::getServerChanToken, NoticeReceiver::setServerChanToken),
new SecretField(NoticeReceiver::getGotifyToken, NoticeReceiver::setGotifyToken),
new SecretField(NoticeReceiver::getNtfyToken, NoticeReceiver::setNtfyToken));
private NoticeReceiverMaskUtil() {
}
/**
* Return a copy of the receiver with all secret fields masked.
* The given entity is not modified as it may still be attached to the persistence context.
* @param receiver receiver to mask, may be null
* @return masked copy, or null if receiver is null
*/
public static NoticeReceiver mask(NoticeReceiver receiver) {
if (receiver == null) {
return null;
}
NoticeReceiver masked = receiver.toBuilder().build();
for (SecretField field : SECRET_FIELDS) {
field.setter().accept(masked, maskValue(field.getter().apply(masked)));
}
return masked;
}
/**
* Replace every secret field of the incoming receiver that still holds the masked form
* of the stored secret with the stored value, so an edit that did not touch a secret keeps it.
* 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
*/
public static void resolveMask(NoticeReceiver incoming, NoticeReceiver existing) {
if (incoming == null || existing == null) {
return;
}
for (SecretField field : SECRET_FIELDS) {
String submitted = field.getter().apply(incoming);
String stored = field.getter().apply(existing);
if (isMaskOf(submitted, stored)) {
field.setter().accept(incoming, stored);
}
}
}
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));
}
private static String maskValue(String value) {
if (StringUtils.isBlank(value)) {
return value;
}
if (value.length() < MIN_LENGTH_TO_SHOW_SUFFIX) {
return SECRET_MASK;
}
return SECRET_MASK + value.substring(value.length() - VISIBLE_SUFFIX_LENGTH);
}
}
@@ -31,6 +31,7 @@ import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import org.apache.hertzbeat.alert.service.impl.NoticeConfigServiceImpl;
import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
@@ -174,6 +175,7 @@ class NoticeConfigControllerTest {
NoticeReceiver receiver1 = new NoticeReceiver();
receiver1.setId(1L);
receiver1.setName("Receiver1");
receiver1.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM");
NoticeReceiver receiver2 = new NoticeReceiver();
receiver2.setId(2L);
@@ -197,6 +199,7 @@ class NoticeConfigControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.content[0].id").value(1))
.andExpect(jsonPath("$.data.content[0].name").value("Receiver1"))
.andExpect(jsonPath("$.data.content[0].tgBotToken").value(NoticeReceiverMaskUtil.SECRET_MASK + "voJM"))
.andExpect(jsonPath("$.data.content[1].id").value(2))
.andExpect(jsonPath("$.data.content[1].name").value("Receiver2"))
.andExpect(jsonPath("$.data.totalElements").value(2))
@@ -222,6 +225,8 @@ class NoticeConfigControllerTest {
this.mockMvc.perform(MockMvcRequestBuilders.get("/api/notice/receiver/{id}", 7565463543L))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.accessToken").value(NoticeReceiverMaskUtil.SECRET_MASK + "739d"))
.andExpect(jsonPath("$.data.email").value("2762242004@qq.com"))
.andReturn();
}
@@ -22,6 +22,7 @@ import org.apache.hertzbeat.alert.dao.NoticeRuleDao;
import org.apache.hertzbeat.alert.dao.NoticeTemplateDao;
import org.apache.hertzbeat.alert.notice.AlertNoticeDispatch;
import org.apache.hertzbeat.alert.service.impl.NoticeConfigServiceImpl;
import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil;
import org.apache.hertzbeat.common.cache.CacheFactory;
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
@@ -48,6 +49,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -216,6 +218,39 @@ class NoticeConfigServiceTest {
verify(noticeReceiverDao, times(1)).save(noticeReceiver);
}
@Test
void editReceiverKeepsStoredSecretWhenMasked() {
final NoticeReceiver stored = new NoticeReceiver();
stored.setId(5L);
stored.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM");
when(noticeReceiverDao.findById(5L)).thenReturn(Optional.of(stored));
final NoticeReceiver incoming = new NoticeReceiver();
incoming.setId(5L);
incoming.setTgBotToken(NoticeReceiverMaskUtil.SECRET_MASK + "voJM");
noticeConfigService.editReceiver(incoming);
assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", incoming.getTgBotToken());
verify(noticeReceiverDao, times(1)).save(incoming);
}
@Test
void sendTestMsgResolvesMaskedSecret() {
final NoticeReceiver stored = new NoticeReceiver();
stored.setId(5L);
stored.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM");
when(noticeReceiverDao.findById(5L)).thenReturn(Optional.of(stored));
final NoticeReceiver incoming = new NoticeReceiver();
incoming.setId(5L);
incoming.setTgBotToken(NoticeReceiverMaskUtil.SECRET_MASK + "voJM");
noticeConfigService.sendTestMsg(incoming);
assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", incoming.getTgBotToken());
}
@Test
void deleteReceiver() {
final Long receiverId = 23342525L;
@@ -0,0 +1,134 @@
/*
* 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.alert.util;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
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;
/**
* Test case for {@link NoticeReceiverMaskUtil}
*/
class NoticeReceiverMaskUtilTest {
private NoticeReceiver buildReceiverWithSecrets() {
NoticeReceiver receiver = new NoticeReceiver();
receiver.setId(1L);
receiver.setName("tom");
receiver.setEmail("tom@usthe.com");
receiver.setHookUrl("https://example.com/hook");
receiver.setHookAuthToken("hook-auth-token-abcd");
receiver.setAccessToken("c03a568a306f8fd84dab51ff03cf6af6ba676a3be940c904e1df2de34853739d");
receiver.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM");
receiver.setSlackWebHookUrl("https://hooks.slack.com/services/X/Y/Zt0k3n");
receiver.setAppSecret("oUydwn92ey0lnuY02MixNa57eNK-20dJn5NEOG-u2uE");
receiver.setDiscordBotToken("MTA2NTMwMzU0ODY4Mzg4MjUzNw.discord.t0kn");
receiver.setSmnAk("NCVBODJOEYHSW3VNSMAK");
receiver.setSmnSk("nmSNhUJN9MlpPl8lfCsgdA0KvHCL9JSMSK");
receiver.setServerChanToken("SCT193569TSNm6xIabdjqeZPtOGOWcvU1e");
receiver.setGotifyToken("A845h__ZMqDxZlO");
receiver.setNtfyToken("tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2");
return receiver;
}
@Test
void maskKeepsSuffixOfLongSecrets() {
NoticeReceiver receiver = buildReceiverWithSecrets();
NoticeReceiver masked = NoticeReceiverMaskUtil.mask(receiver);
assertEquals(SECRET_MASK + "abcd", masked.getHookAuthToken());
assertEquals(SECRET_MASK + "739d", masked.getAccessToken());
assertEquals(SECRET_MASK + "voJM", masked.getTgBotToken());
assertEquals(SECRET_MASK + "0k3n", masked.getSlackWebHookUrl());
assertEquals(SECRET_MASK + "u2uE", masked.getAppSecret());
assertEquals(SECRET_MASK + "t0kn", masked.getDiscordBotToken());
assertEquals(SECRET_MASK + "SMAK", masked.getSmnAk());
assertEquals(SECRET_MASK + "SMSK", masked.getSmnSk());
assertEquals(SECRET_MASK + "vU1e", masked.getServerChanToken());
assertEquals(SECRET_MASK + "xZlO", masked.getGotifyToken());
assertEquals(SECRET_MASK + "NIz2", masked.getNtfyToken());
assertEquals(receiver.getId(), masked.getId());
assertEquals(receiver.getName(), masked.getName());
assertEquals(receiver.getEmail(), masked.getEmail());
assertEquals(receiver.getHookUrl(), masked.getHookUrl());
}
@Test
void maskHidesShortSecretsEntirely() {
NoticeReceiver receiver = new NoticeReceiver();
receiver.setAccessToken("short-token");
receiver.setGotifyToken("tiny");
NoticeReceiver masked = NoticeReceiverMaskUtil.mask(receiver);
assertEquals(SECRET_MASK, masked.getAccessToken());
assertEquals(SECRET_MASK, masked.getGotifyToken());
}
@Test
void maskDoesNotModifyOriginalAndKeepsEmptySecrets() {
NoticeReceiver receiver = buildReceiverWithSecrets();
NoticeReceiverMaskUtil.mask(receiver);
assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", receiver.getTgBotToken());
assertEquals("nmSNhUJN9MlpPl8lfCsgdA0KvHCL9JSMSK", receiver.getSmnSk());
NoticeReceiver empty = new NoticeReceiver();
NoticeReceiver maskedEmpty = NoticeReceiverMaskUtil.mask(empty);
assertNull(maskedEmpty.getAccessToken());
assertNull(maskedEmpty.getNtfyToken());
assertNull(NoticeReceiverMaskUtil.mask(null));
}
@Test
void resolveMaskRestoresOnlyMaskedFields() {
NoticeReceiver existing = buildReceiverWithSecrets();
NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(existing);
incoming.setAccessToken("new-access-token-1234");
incoming.setGotifyToken(null);
incoming.setNtfyToken(SECRET_MASK);
NoticeReceiverMaskUtil.resolveMask(incoming, existing);
assertEquals("new-access-token-1234", incoming.getAccessToken());
assertNull(incoming.getGotifyToken());
assertEquals("tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", incoming.getNtfyToken());
assertEquals("hook-auth-token-abcd", incoming.getHookAuthToken());
assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", incoming.getTgBotToken());
assertEquals("https://hooks.slack.com/services/X/Y/Zt0k3n", incoming.getSlackWebHookUrl());
assertEquals("oUydwn92ey0lnuY02MixNa57eNK-20dJn5NEOG-u2uE", incoming.getAppSecret());
assertEquals("MTA2NTMwMzU0ODY4Mzg4MjUzNw.discord.t0kn", incoming.getDiscordBotToken());
assertEquals("NCVBODJOEYHSW3VNSMAK", incoming.getSmnAk());
assertEquals("nmSNhUJN9MlpPl8lfCsgdA0KvHCL9JSMSK", incoming.getSmnSk());
assertEquals("SCT193569TSNm6xIabdjqeZPtOGOWcvU1e", incoming.getServerChanToken());
}
@Test
void resolveMaskIgnoresMaskWhenNothingIsStored() {
NoticeReceiver existing = new NoticeReceiver();
NoticeReceiver incoming = new NoticeReceiver();
incoming.setAccessToken(SECRET_MASK);
NoticeReceiverMaskUtil.resolveMask(incoming, existing);
assertEquals(SECRET_MASK, incoming.getAccessToken());
}
}
@@ -48,7 +48,7 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Entity
@Table(name = "hzb_notice_receiver")
@Data
@Builder
@Builder(toBuilder = true)
@AllArgsConstructor
@NoArgsConstructor
@Schema(description = "Message notification recipient entity")