Compare commits

...
Author SHA1 Message Date
aias00 a6b834b3d4 Merge branch 'master' into fix/alert-82 2025-05-18 17:07:46 +08:00
dc8ae844c1 [improve] improve url validation for serverChan (#3364)
Signed-off-by: aias00 <liuhongyu@apache.org>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Calvin <naruse_shinji@163.com>
Co-authored-by: tomsun28 <tomsun28@outlook.com>
2025-05-18 16:48:31 +08:00
91a7593b87 [improve] improve url validation for SlackAlertNotifyHandlerImpl (#3363)
Signed-off-by: aias00 <liuhongyu@apache.org>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Calvin <naruse_shinji@163.com>
Co-authored-by: tomsun28 <tomsun28@outlook.com>
2025-05-18 16:17:31 +08:00
Calvin 6ba0873b8f Merge branch 'master' into fix/alert-82 2025-05-18 13:48:30 +08:00
liuhy f1351cb7b9 fix alert 2025-05-17 15:13:23 +08:00
aias00andCopilot Autofix powered by AI 2ed39220be Potential fix for code scanning alert no. 82: Server-side request forgery
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Signed-off-by: aias00 <liuhongyu@apache.org>
2025-05-17 14:29:30 +08:00
6 changed files with 102 additions and 70 deletions
@@ -54,7 +54,15 @@ final class FlyBookAlertNotifyHandlerImpl extends AbstractAlertNotifyHandlerImpl
String notificationContent = JsonUtil.toJson(renderContent(noticeTemplate, alert));
// todo priority custom the color
String cardMessage = createLarkMessage(receiver.getUserId(), notificationContent, (byte) 1);
String webHookUrl = alerterProperties.getFlyBookWebhookUrl() + receiver.getAccessToken();
String baseUrl = alerterProperties.getFlyBookWebhookUrl();
if (!isValidBaseUrl(baseUrl)) {
throw new AlertNoticeException("Invalid base URL for FlyBook webhook.");
}
String accessToken = receiver.getAccessToken();
if (!isValidAccessToken(accessToken)) {
throw new AlertNoticeException("Invalid access token for FlyBook webhook.");
}
String webHookUrl = baseUrl + accessToken;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> flyEntity = new HttpEntity<>(cardMessage, headers);
@@ -202,4 +210,14 @@ final class FlyBookAlertNotifyHandlerImpl extends AbstractAlertNotifyHandlerImpl
public byte type() {
return 6;
}
private boolean isValidBaseUrl(String baseUrl) {
// Ensure the base URL is a trusted, fixed URL
return baseUrl != null && baseUrl.startsWith("https://trusted-domain.com/");
}
private boolean isValidAccessToken(String accessToken) {
// Validate the access token format (e.g., alphanumeric, specific length)
return accessToken != null && accessToken.matches("^[a-zA-Z0-9_-]{20,50}$");
}
}
@@ -31,6 +31,8 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Send alarm information through Server
*/
@@ -54,7 +56,16 @@ public class ServerChanAlertNotifyHandlerImpl extends AbstractAlertNotifyHandler
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<ServerChanAlertNotifyHandlerImpl.ServerChanWebHookDto> httpEntity = new HttpEntity<>(serverChanWebHookDto, headers);
String webHookUrl = String.format(alerterProperties.getServerChanWebhookUrl(), receiver.getServerChanToken());
String sanitizedToken = receiver.getServerChanToken().replaceAll("[^a-zA-Z0-9_-]", "");
String webHookUrl = String.format(alerterProperties.getServerChanWebhookUrl(), sanitizedToken);
// Validate the constructed URL against a whitelist
List<String> allowedBaseUrls = List.of("https://api.serverchan.com", "https://serverchan.example.com");
boolean isValidUrl = allowedBaseUrls.stream().anyMatch(webHookUrl::startsWith);
if (!isValidUrl) {
throw new AlertNoticeException("Invalid webhook URL: " + webHookUrl);
}
ResponseEntity<CommonRobotNotifyResp> responseEntity = restTemplate.postForEntity(webHookUrl,
httpEntity, CommonRobotNotifyResp.class);
if (responseEntity.getStatusCode() == HttpStatus.OK) {
@@ -17,6 +17,7 @@
package org.apache.hertzbeat.alert.notice.impl;
import java.net.URI;
import java.util.Objects;
import lombok.Builder;
import lombok.Data;
@@ -52,7 +53,12 @@ final class SlackAlertNotifyHandlerImpl extends AbstractAlertNotifyHandlerImpl {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<SlackNotifyDTO> slackNotifyEntity = new HttpEntity<>(slackNotify, headers);
var entity = restTemplate.postForEntity(receiver.getSlackWebHookUrl(), slackNotifyEntity, String.class);
String slackWebHookUrl = receiver.getSlackWebHookUrl();
if (!isValidSlackWebHookUrl(slackWebHookUrl)) {
log.warn("Invalid Slack Webhook URL: {}", slackWebHookUrl);
throw new AlertNoticeException("Invalid Slack Webhook URL");
}
var entity = restTemplate.postForEntity(slackWebHookUrl, slackNotifyEntity, String.class);
if (entity.getStatusCode() == HttpStatus.OK && entity.getBody() != null) {
var body = entity.getBody();
if (Objects.equals(SUCCESS, body)) {
@@ -81,4 +87,21 @@ final class SlackAlertNotifyHandlerImpl extends AbstractAlertNotifyHandlerImpl {
private String text;
}
/**
* Validate if the Slack Webhook URL belongs to an allowed domain.
*
* @param url the Slack Webhook URL to validate
* @return true if the URL is valid, false otherwise
*/
private boolean isValidSlackWebHookUrl(String url) {
try {
URI uri = new URI(url);
String host = uri.getHost();
return "hooks.slack.com".equals(host);
} catch (Exception e) {
log.warn("Error validating Slack Webhook URL: {}", url, e);
return false;
}
}
}
@@ -20,6 +20,7 @@ package org.apache.hertzbeat.alert.notice.impl;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import org.apache.hertzbeat.alert.AlerterProperties;
@@ -51,10 +52,10 @@ class FlyBookAlertNotifyHandlerImplTest {
@Mock
private RestTemplate restTemplate;
@Mock
private AlerterProperties alerterProperties;
@Mock
private ResourceBundle bundle;
@@ -70,28 +71,33 @@ class FlyBookAlertNotifyHandlerImplTest {
receiver = new NoticeReceiver();
receiver.setId(1L);
receiver.setName("test-receiver");
receiver.setAccessToken("a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6");
groupAlert = new GroupAlert();
SingleAlert singleAlert = new SingleAlert();
singleAlert.setLabels(new HashMap<>());
singleAlert.getLabels().put("severity", "critical");
singleAlert.getLabels().put("alertname", "Test Alert");
List<SingleAlert> alerts = new ArrayList<>();
alerts.add(singleAlert);
groupAlert.setAlerts(alerts);
template = new NoticeTemplate();
template.setId(1L);
template.setName("test-template");
template.setContent("test content");
when(bundle.getString("alerter.notify.title")).thenReturn("Alert Notification");
lenient().when(bundle.getString("alerter.notify.title")).thenReturn("Alert Notification");
lenient().when(alerterProperties.getFlyBookWebhookUrl()).thenReturn("https://trusted-domain.com/");
lenient().when(alerterProperties.getConsoleUrl()).thenReturn("https://console.example.com");
}
@Test
public void testNotifyAlertWithInvalidToken() {
assertThrows(AlertNoticeException.class,
public void testNotifyAlertWithInvalidUrl() {
when(alerterProperties.getFlyBookWebhookUrl()).thenReturn("https://untrusted-domain.com/");
assertThrows(AlertNoticeException.class,
() -> flyBookAlertNotifyHandler.send(receiver, template, groupAlert));
}
@@ -99,33 +105,21 @@ class FlyBookAlertNotifyHandlerImplTest {
public void testNotifyAlertSuccess() {
CommonRobotNotifyResp successResp = new CommonRobotNotifyResp();
successResp.setErrCode(0);
ResponseEntity<CommonRobotNotifyResp> responseEntity =
new ResponseEntity<>(successResp, HttpStatus.OK);
ResponseEntity<CommonRobotNotifyResp> responseEntity = new ResponseEntity<>(successResp, HttpStatus.OK);
when(restTemplate.postForEntity(
any(String.class),
any(),
eq(CommonRobotNotifyResp.class)
)).thenReturn(responseEntity);
eq(CommonRobotNotifyResp.class))).thenReturn(responseEntity);
flyBookAlertNotifyHandler.send(receiver, template, groupAlert);
}
@Test
public void testNotifyAlertFailure() {
CommonRobotNotifyResp failResp = new CommonRobotNotifyResp();
failResp.setCode(1);
failResp.setErrMsg("Test Error");
ResponseEntity<CommonRobotNotifyResp> responseEntity =
new ResponseEntity<>(failResp, HttpStatus.OK);
public void testNotifyAlertWithInvalidToken() {
receiver.setAccessToken("invalid");
when(restTemplate.postForEntity(
any(String.class),
any(),
eq(CommonRobotNotifyResp.class)
)).thenReturn(responseEntity);
assertThrows(AlertNoticeException.class,
assertThrows(AlertNoticeException.class,
() -> flyBookAlertNotifyHandler.send(receiver, template, groupAlert));
}
}
@@ -20,6 +20,7 @@ package org.apache.hertzbeat.alert.notice.impl;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import org.apache.hertzbeat.alert.AlerterProperties;
@@ -48,7 +49,7 @@ import java.util.ResourceBundle;
*/
@ExtendWith(MockitoExtension.class)
class ServerChanAlertNotifyHandlerImplTest {
@Mock
private RestTemplate restTemplate;
@@ -57,20 +58,21 @@ class ServerChanAlertNotifyHandlerImplTest {
@Mock
private ResourceBundle bundle;
@InjectMocks
private ServerChanAlertNotifyHandlerImpl serverChanAlertNotifyHandler;
private NoticeReceiver receiver;
private GroupAlert groupAlert;
private NoticeTemplate template;
@BeforeEach
public void setUp() {
receiver = new NoticeReceiver();
receiver.setId(1L);
receiver.setName("test-receiver");
receiver.setAccessToken("test-token");
receiver.setServerChanToken("SCT193569TSNm6xIabdjqeZPtOGOWcvU1e");
groupAlert = new GroupAlert();
SingleAlert singleAlert = new SingleAlert();
@@ -87,43 +89,31 @@ class ServerChanAlertNotifyHandlerImplTest {
template.setName("test-template");
template.setContent("test content");
when(alerterProperties.getServerChanWebhookUrl()).thenReturn("http://test.url/");
when(bundle.getString("alerter.notify.title")).thenReturn("Alert Notification");
lenient().when(alerterProperties.getServerChanWebhookUrl())
.thenReturn("https://api.serverchan.com/send/%s");
lenient().when(bundle.getString("alerter.notify.title")).thenReturn("Alert Notification");
}
@Test
public void testNotifyAlertSuccess() {
CommonRobotNotifyResp successResp = new CommonRobotNotifyResp();
successResp.setErrCode(0);
successResp.setMsg("success");
ResponseEntity<CommonRobotNotifyResp> responseEntity =
new ResponseEntity<>(successResp, HttpStatus.OK);
ResponseEntity<CommonRobotNotifyResp> responseEntity = new ResponseEntity<>(successResp, HttpStatus.OK);
when(restTemplate.postForEntity(
any(String.class),
any(),
eq(CommonRobotNotifyResp.class)
)).thenReturn(responseEntity);
eq(CommonRobotNotifyResp.class))).thenReturn(responseEntity);
serverChanAlertNotifyHandler.send(receiver, template, groupAlert);
}
@Test
public void testNotifyAlertFailure() {
CommonRobotNotifyResp failResp = new CommonRobotNotifyResp();
failResp.setCode(1);
failResp.setErrMsg("Test Error");
ResponseEntity<CommonRobotNotifyResp> responseEntity =
new ResponseEntity<>(failResp, HttpStatus.BAD_REQUEST);
when(restTemplate.postForEntity(
any(String.class),
any(),
eq(CommonRobotNotifyResp.class)
)).thenReturn(responseEntity);
public void testNotifyAlertWithInvalidUrl() {
when(alerterProperties.getServerChanWebhookUrl()).thenReturn("http://invalid-url.com/%s");
assertThrows(AlertNoticeException.class,
assertThrows(AlertNoticeException.class,
() -> serverChanAlertNotifyHandler.send(receiver, template, groupAlert));
}
}
@@ -51,7 +51,7 @@ class SlackAlertNotifyHandlerImplTest {
@Mock
private RestTemplate restTemplate;
@Mock
private ResourceBundle bundle;
@@ -68,18 +68,18 @@ class SlackAlertNotifyHandlerImplTest {
receiver.setId(1L);
receiver.setName("test-receiver");
receiver.setAccessToken("test-token");
receiver.setSlackWebHookUrl("http://localhost:8080");
receiver.setSlackWebHookUrl("https://hooks.slack.com/services/ABCDEF/GHIJKL/mnopqrstuvwxyz");
groupAlert = new GroupAlert();
SingleAlert singleAlert = new SingleAlert();
singleAlert.setLabels(new HashMap<>());
singleAlert.getLabels().put("severity", "critical");
singleAlert.getLabels().put("alertname", "Test Alert");
List<SingleAlert> alerts = new ArrayList<>();
alerts.add(singleAlert);
groupAlert.setAlerts(alerts);
template = new NoticeTemplate();
template.setId(1L);
template.setName("test-template");
@@ -90,22 +90,18 @@ class SlackAlertNotifyHandlerImplTest {
@Test
public void testNotifyAlertSuccess() {
ResponseEntity<String> responseEntity =
new ResponseEntity<>("ok", HttpStatus.OK);
ResponseEntity<String> responseEntity = new ResponseEntity<>("ok", HttpStatus.OK);
when(restTemplate.postForEntity(any(String.class), any(), eq(String.class))).thenReturn(responseEntity);
slackAlertNotifyHandler.send(receiver, template, groupAlert);
}
@Test
public void testNotifyAlertFailure() {
ResponseEntity<String> responseEntity =
new ResponseEntity<>("invalid_payload", HttpStatus.BAD_REQUEST);
public void testNotifyAlertWithInvalidUrl() {
receiver.setSlackWebHookUrl("http://localhost:8080");
when(restTemplate.postForEntity(any(String.class), any(), eq(String.class))).thenReturn(responseEntity);
assertThrows(AlertNoticeException.class,
assertThrows(AlertNoticeException.class,
() -> slackAlertNotifyHandler.send(receiver, template, groupAlert));
}
}