diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmFlowableConfiguration.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmFlowableConfiguration.java
index 3325c87..34090a2 100644
--- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmFlowableConfiguration.java
+++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmFlowableConfiguration.java
@@ -16,6 +16,7 @@ import org.flowable.spring.SpringProcessEngineConfiguration;
import org.flowable.spring.boot.EngineConfigurationConfigurer;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -32,6 +33,7 @@ import java.util.List;
* @author jason
*/
@Configuration(proxyBeanMethods = false)
+@EnableConfigurationProperties(BpmHttpRequestProperties.class)
@Slf4j
public class BpmFlowableConfiguration {
diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmHttpRequestProperties.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmHttpRequestProperties.java
new file mode 100644
index 0000000..3b7ea4d
--- /dev/null
+++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmHttpRequestProperties.java
@@ -0,0 +1,48 @@
+package cn.iocoder.yudao.module.bpm.framework.flowable.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * BPM 流程模型发起 HTTP 请求的配置类:请求地址的域名 / IP 黑白名单
+ *
+ * 作用于流程模型中配置的 HTTP 请求:HTTP 请求触发器、HTTP 回调触发器、任务前置/后置通知、流程前置/后置通知、审批人监听器 HTTP。
+ * 默认关闭,不做任何校验,与原有行为一致。如需开启,直接修改本类字段的默认值即可,例如说:
+ *
+ *
+ * private Boolean enabled = true;
+ * private Set<String> allowHosts = new HashSet<>(Set.of("erp.example.internal", "10.0.0.8"));
+ * private Set<String> denyHosts = new HashSet<>(Set.of("169.254.169.254"));
+ *
+ *
+ * 注意:只校验流程模型中填写的 URL 的域名 / IP,不解析 DNS,不能防御 DNS 重绑定、代理、重定向等场景,不是完整的 SSRF 防护
+ *
+ * @author 芋道源码
+ */
+@ConfigurationProperties(prefix = "yudao.bpm.http-request")
+@Data
+public class BpmHttpRequestProperties {
+
+ /**
+ * 是否开启黑白名单校验,默认关闭
+ */
+ private Boolean enabled = false;
+
+ /**
+ * 白名单:域名或 IP,忽略大小写后精确匹配,例如说 erp.example.internal、10.0.0.8
+ *
+ * 非空时,只允许请求列表中的域名或 IP;为空时,不额外限制
+ */
+ private Set allowHosts = new HashSet<>();
+
+ /**
+ * 黑名单:域名或 IP,忽略大小写后精确匹配,例如说 169.254.169.254
+ *
+ * 优先于白名单判断,命中时拒绝请求
+ */
+ private Set denyHosts = new HashSet<>();
+
+}
diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtils.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtils.java
index 024bb1b..6ddeea8 100644
--- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtils.java
+++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtils.java
@@ -10,6 +10,7 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelNodeVO;
import cn.iocoder.yudao.module.bpm.enums.definition.BpmHttpRequestParamTypeEnum;
+import cn.iocoder.yudao.module.bpm.framework.flowable.config.BpmHttpRequestProperties;
import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService;
import com.fasterxml.jackson.core.type.TypeReference;
import lombok.extern.slf4j.Slf4j;
@@ -19,10 +20,14 @@ import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponentsBuilder;
import java.util.HashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
+import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID;
@@ -42,6 +47,8 @@ public class BpmHttpRequestUtils {
List bodyParams,
Boolean handleResponse,
List> response) {
+ // 0. 校验请求地址的域名 / IP 黑白名单(默认关闭)
+ validateHttpRequestUrl(url);
BpmProcessInstanceService processInstanceService = SpringUtils.getBean(BpmProcessInstanceService.class);
// 1.1 设置请求头
@@ -185,4 +192,68 @@ public class BpmHttpRequestUtils {
});
}
+ /**
+ * 校验流程模型配置的请求地址,是否满足 {@link BpmHttpRequestProperties} 的域名 / IP 黑白名单
+ *
+ * 1. 未开启时,不做任何校验
+ * 2. 开启时,只允许 http、https;黑名单优先,命中则拒绝;白名单非空时,必须命中
+ * 3. 只校验 URL 中填写的域名或 IP(忽略大小写,精确匹配),不解析 DNS,不能防御 DNS 重绑定、代理、重定向等场景
+ *
+ * @param url 请求地址
+ */
+ private static void validateHttpRequestUrl(String url) {
+ BpmHttpRequestProperties properties = SpringUtils.getBean(BpmHttpRequestProperties.class);
+ if (!Boolean.TRUE.equals(properties.getEnabled())) {
+ return;
+ }
+ String host = getHttpRequestHost(url);
+ if (host == null) {
+ log.warn("[validateHttpRequestUrl][请求地址({}) 不合法,拒绝请求]", url);
+ throw exception(PROCESS_INSTANCE_HTTP_CALL_ERROR);
+ }
+ if (containsHost(properties.getDenyHosts(), host)) {
+ log.warn("[validateHttpRequestUrl][请求地址的主机({}) 在黑名单中,拒绝请求]", host);
+ throw exception(PROCESS_INSTANCE_HTTP_CALL_ERROR);
+ }
+ if (CollUtil.isNotEmpty(properties.getAllowHosts()) && !containsHost(properties.getAllowHosts(), host)) {
+ log.warn("[validateHttpRequestUrl][请求地址的主机({}) 不在白名单中,拒绝请求]", host);
+ throw exception(PROCESS_INSTANCE_HTTP_CALL_ERROR);
+ }
+ }
+
+ /**
+ * 获得请求地址的主机(域名或 IP),使用与 RestTemplate 相同的 URI 解析方式
+ *
+ * @param url 请求地址
+ * @return 规范化后的主机;不是 http、https 的绝对地址时,返回 null
+ */
+ private static String getHttpRequestHost(String url) {
+ if (StrUtil.isBlank(url)) {
+ return null;
+ }
+ UriComponents uriComponents;
+ try {
+ uriComponents = UriComponentsBuilder.fromUriString(url).build();
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ if (!StrUtil.equalsAnyIgnoreCase(uriComponents.getScheme(), "http", "https")
+ || StrUtil.isBlank(uriComponents.getHost())) {
+ return null;
+ }
+ return normalizeHost(uriComponents.getHost());
+ }
+
+ private static boolean containsHost(Set hosts, String host) {
+ return CollUtil.isNotEmpty(hosts) && hosts.stream().anyMatch(item -> host.equals(normalizeHost(item)));
+ }
+
+ /**
+ * 规范化主机:去掉首尾空白、转小写,IPv6 去掉方括号
+ */
+ private static String normalizeHost(String host) {
+ String result = StrUtil.nullToEmpty(StrUtil.trim(host)).toLowerCase(Locale.ROOT);
+ return StrUtil.removeSuffix(StrUtil.removePrefix(result, "["), "]");
+ }
+
}
diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtilsTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtilsTest.java
index 0a450e8..69d6bd4 100644
--- a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtilsTest.java
+++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtilsTest.java
@@ -4,6 +4,7 @@ import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
+import cn.iocoder.yudao.module.bpm.framework.flowable.config.BpmHttpRequestProperties;
import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService;
import org.flowable.engine.runtime.ProcessInstance;
import org.junit.jupiter.api.AfterEach;
@@ -15,7 +16,11 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
+import java.util.Set;
+
+import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID;
+import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.PROCESS_INSTANCE_HTTP_CALL_ERROR;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@@ -62,4 +67,110 @@ public class BpmHttpRequestUtilsTest extends BaseMockitoUnitTest {
assertEquals("tenant-1", requestCaptor.getValue().getHeaders().getFirst(HEADER_TENANT_ID));
}
+ @Test
+ public void testExecuteBpmHttpRequest_disabled() {
+ // 准备参数:默认关闭,不做任何校验,与原有行为一致
+ BpmHttpRequestProperties properties = new BpmHttpRequestProperties();
+ properties.setDenyHosts(Set.of("169.254.169.254"));
+
+ // 调用,并断言:仍然发起请求
+ executeBpmHttpRequestSuccess(properties, "http://169.254.169.254/latest");
+ }
+
+ @Test
+ public void testExecuteBpmHttpRequest_allowHostMatch() {
+ // 准备参数:域名、IP 精确匹配,忽略大小写
+ BpmHttpRequestProperties properties = buildProperties(Set.of("ERP.example.internal", "10.0.0.8", "::1"), Set.of());
+
+ // 调用,并断言:发起请求
+ executeBpmHttpRequestSuccess(properties, "https://erp.EXAMPLE.internal/api",
+ "http://10.0.0.8:8080/callback", "http://[::1]:48080/callback");
+ }
+
+ @Test
+ public void testExecuteBpmHttpRequest_denyHostFirst() {
+ // 准备参数:同时在黑名单、白名单中,黑名单优先
+ BpmHttpRequestProperties properties = buildProperties(Set.of("10.0.0.8"), Set.of(" 10.0.0.8 "));
+
+ // 调用,并断言:拒绝请求
+ executeBpmHttpRequestFail(properties, "http://10.0.0.8/callback");
+ }
+
+ @Test
+ public void testExecuteBpmHttpRequest_notInAllowHosts() {
+ // 准备参数:白名单非空时,只允许列表中的域名或 IP,不做后缀、包含匹配
+ BpmHttpRequestProperties properties = buildProperties(Set.of("erp.example.internal"), Set.of());
+
+ // 调用,并断言:拒绝请求
+ executeBpmHttpRequestFail(properties, "http://a.erp.example.internal/callback",
+ "http://erp.example.internal.evil.com/callback", "http://10.0.0.9/callback");
+ }
+
+ @Test
+ public void testExecuteBpmHttpRequest_emptyHosts() {
+ // 准备参数:开启,但黑白名单都为空时,不额外限制
+ BpmHttpRequestProperties properties = buildProperties(Set.of(), Set.of());
+
+ // 调用,并断言:发起请求
+ executeBpmHttpRequestSuccess(properties, "http://169.254.169.254/latest");
+ }
+
+ @Test
+ public void testExecuteBpmHttpRequest_denyHostsOnly() {
+ // 准备参数:只配置黑名单时,命中黑名单拒绝
+ BpmHttpRequestProperties properties = buildProperties(Set.of(), Set.of("169.254.169.254"));
+
+ // 调用,并断言:拒绝请求
+ executeBpmHttpRequestFail(properties, "http://169.254.169.254/latest");
+ }
+
+ @Test
+ public void testExecuteBpmHttpRequest_invalidUrl() {
+ // 准备参数:开启后,只允许带域名或 IP 的 http、https 地址
+ BpmHttpRequestProperties properties = buildProperties(Set.of(), Set.of());
+
+ // 调用,并断言:拒绝请求
+ executeBpmHttpRequestFail(properties, "ftp://10.0.0.8/callback", "file:///etc/passwd",
+ "//10.0.0.8/callback", "callback", "http:///callback", "", null);
+ }
+
+ private static BpmHttpRequestProperties buildProperties(Set allowHosts, Set denyHosts) {
+ BpmHttpRequestProperties properties = new BpmHttpRequestProperties();
+ properties.setEnabled(true);
+ properties.setAllowHosts(allowHosts);
+ properties.setDenyHosts(denyHosts);
+ return properties;
+ }
+
+ private void executeBpmHttpRequestSuccess(BpmHttpRequestProperties properties, String... urls) {
+ when(restTemplate.exchange(anyString(), any(), any(), eq(String.class))).thenReturn(ResponseEntity.ok("{}"));
+ try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) {
+ springUtilMockedStatic.when(() -> SpringUtil.getBean(BpmHttpRequestProperties.class)).thenReturn(properties);
+ springUtilMockedStatic.when(() -> SpringUtil.getBean(BpmProcessInstanceService.class))
+ .thenReturn(processInstanceService);
+ springUtilMockedStatic.when(() -> SpringUtil.getBean(RestTemplate.class)).thenReturn(restTemplate);
+
+ for (String url : urls) {
+ // 调用
+ BpmHttpRequestUtils.executeBpmHttpRequest(processInstance, url, null, null, false, null);
+ // 断言:使用原有 RestTemplate 发起请求
+ verify(restTemplate).exchange(eq(url), any(), any(), eq(String.class));
+ }
+ }
+ }
+
+ private void executeBpmHttpRequestFail(BpmHttpRequestProperties properties, String... urls) {
+ try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) {
+ springUtilMockedStatic.when(() -> SpringUtil.getBean(BpmHttpRequestProperties.class)).thenReturn(properties);
+
+ for (String url : urls) {
+ // 调用,并断言
+ assertServiceException(() -> BpmHttpRequestUtils.executeBpmHttpRequest(processInstance, url,
+ null, null, false, null), PROCESS_INSTANCE_HTTP_CALL_ERROR);
+ }
+ }
+ // 断言:未发起请求
+ verify(restTemplate, never()).exchange(anyString(), any(), any(), eq(String.class));
+ }
+
}