Commit 56b2b5409668952fea2a3212be4d1d2036c60b39

Authored by YunaiV
1 parent 39c3681d

feat(bpm): 增加可选 HTTP 请求域名和 IP 黑白名单

默认关闭名单检查,保留原有 RestTemplate 和正常 HTTP 行为;开启后按域名或 IP 精确匹配,黑名单优先。

验证:BPM 模块全部 316 条测试通过,独立多模型复评通过。
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; @@ -16,6 +16,7 @@ import org.flowable.spring.SpringProcessEngineConfiguration;
16 import org.flowable.spring.boot.EngineConfigurationConfigurer; 16 import org.flowable.spring.boot.EngineConfigurationConfigurer;
17 import org.springframework.beans.factory.ObjectProvider; 17 import org.springframework.beans.factory.ObjectProvider;
18 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 18 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
  19 +import org.springframework.boot.context.properties.EnableConfigurationProperties;
19 import org.springframework.context.ApplicationEventPublisher; 20 import org.springframework.context.ApplicationEventPublisher;
20 import org.springframework.context.annotation.Bean; 21 import org.springframework.context.annotation.Bean;
21 import org.springframework.context.annotation.Configuration; 22 import org.springframework.context.annotation.Configuration;
@@ -32,6 +33,7 @@ import java.util.List; @@ -32,6 +33,7 @@ import java.util.List;
32 * @author jason 33 * @author jason
33 */ 34 */
34 @Configuration(proxyBeanMethods = false) 35 @Configuration(proxyBeanMethods = false)
  36 +@EnableConfigurationProperties(BpmHttpRequestProperties.class)
35 @Slf4j 37 @Slf4j
36 public class BpmFlowableConfiguration { 38 public class BpmFlowableConfiguration {
37 39
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmHttpRequestProperties.java 0 → 100644
  1 +package cn.iocoder.yudao.module.bpm.framework.flowable.config;
  2 +
  3 +import lombok.Data;
  4 +import org.springframework.boot.context.properties.ConfigurationProperties;
  5 +
  6 +import java.util.HashSet;
  7 +import java.util.Set;
  8 +
  9 +/**
  10 + * BPM 流程模型发起 HTTP 请求的配置类:请求地址的域名 / IP 黑白名单
  11 + *
  12 + * 作用于流程模型中配置的 HTTP 请求:HTTP 请求触发器、HTTP 回调触发器、任务前置/后置通知、流程前置/后置通知、审批人监听器 HTTP。
  13 + * 默认关闭,不做任何校验,与原有行为一致。如需开启,直接修改本类字段的默认值即可,例如说:
  14 + *
  15 + * <pre>
  16 + * private Boolean enabled = true;
  17 + * private Set&lt;String&gt; allowHosts = new HashSet&lt;&gt;(Set.of("erp.example.internal", "10.0.0.8"));
  18 + * private Set&lt;String&gt; denyHosts = new HashSet&lt;&gt;(Set.of("169.254.169.254"));
  19 + * </pre>
  20 + *
  21 + * 注意:只校验流程模型中填写的 URL 的域名 / IP,不解析 DNS,不能防御 DNS 重绑定、代理、重定向等场景,不是完整的 SSRF 防护
  22 + *
  23 + * @author 芋道源码
  24 + */
  25 +@ConfigurationProperties(prefix = "yudao.bpm.http-request")
  26 +@Data
  27 +public class BpmHttpRequestProperties {
  28 +
  29 + /**
  30 + * 是否开启黑白名单校验,默认关闭
  31 + */
  32 + private Boolean enabled = false;
  33 +
  34 + /**
  35 + * 白名单:域名或 IP,忽略大小写后精确匹配,例如说 erp.example.internal、10.0.0.8
  36 + *
  37 + * 非空时,只允许请求列表中的域名或 IP;为空时,不额外限制
  38 + */
  39 + private Set<String> allowHosts = new HashSet<>();
  40 +
  41 + /**
  42 + * 黑名单:域名或 IP,忽略大小写后精确匹配,例如说 169.254.169.254
  43 + *
  44 + * 优先于白名单判断,命中时拒绝请求
  45 + */
  46 + private Set<String> denyHosts = new HashSet<>();
  47 +
  48 +}
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; @@ -10,6 +10,7 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
10 import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; 10 import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
11 import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelNodeVO; 11 import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelNodeVO;
12 import cn.iocoder.yudao.module.bpm.enums.definition.BpmHttpRequestParamTypeEnum; 12 import cn.iocoder.yudao.module.bpm.enums.definition.BpmHttpRequestParamTypeEnum;
  13 +import cn.iocoder.yudao.module.bpm.framework.flowable.config.BpmHttpRequestProperties;
13 import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService; 14 import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService;
14 import com.fasterxml.jackson.core.type.TypeReference; 15 import com.fasterxml.jackson.core.type.TypeReference;
15 import lombok.extern.slf4j.Slf4j; 16 import lombok.extern.slf4j.Slf4j;
@@ -19,10 +20,14 @@ import org.springframework.util.LinkedMultiValueMap; @@ -19,10 +20,14 @@ import org.springframework.util.LinkedMultiValueMap;
19 import org.springframework.util.MultiValueMap; 20 import org.springframework.util.MultiValueMap;
20 import org.springframework.web.client.RestClientException; 21 import org.springframework.web.client.RestClientException;
21 import org.springframework.web.client.RestTemplate; 22 import org.springframework.web.client.RestTemplate;
  23 +import org.springframework.web.util.UriComponents;
  24 +import org.springframework.web.util.UriComponentsBuilder;
22 25
23 import java.util.HashMap; 26 import java.util.HashMap;
24 import java.util.List; 27 import java.util.List;
  28 +import java.util.Locale;
25 import java.util.Map; 29 import java.util.Map;
  30 +import java.util.Set;
26 31
27 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 32 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
28 import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; 33 import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID;
@@ -42,6 +47,8 @@ public class BpmHttpRequestUtils { @@ -42,6 +47,8 @@ public class BpmHttpRequestUtils {
42 List<BpmSimpleModelNodeVO.HttpRequestParam> bodyParams, 47 List<BpmSimpleModelNodeVO.HttpRequestParam> bodyParams,
43 Boolean handleResponse, 48 Boolean handleResponse,
44 List<KeyValue<String, String>> response) { 49 List<KeyValue<String, String>> response) {
  50 + // 0. 校验请求地址的域名 / IP 黑白名单(默认关闭)
  51 + validateHttpRequestUrl(url);
45 BpmProcessInstanceService processInstanceService = SpringUtils.getBean(BpmProcessInstanceService.class); 52 BpmProcessInstanceService processInstanceService = SpringUtils.getBean(BpmProcessInstanceService.class);
46 53
47 // 1.1 设置请求头 54 // 1.1 设置请求头
@@ -185,4 +192,68 @@ public class BpmHttpRequestUtils { @@ -185,4 +192,68 @@ public class BpmHttpRequestUtils {
185 }); 192 });
186 } 193 }
187 194
  195 + /**
  196 + * 校验流程模型配置的请求地址,是否满足 {@link BpmHttpRequestProperties} 的域名 / IP 黑白名单
  197 + *
  198 + * 1. 未开启时,不做任何校验
  199 + * 2. 开启时,只允许 http、https;黑名单优先,命中则拒绝;白名单非空时,必须命中
  200 + * 3. 只校验 URL 中填写的域名或 IP(忽略大小写,精确匹配),不解析 DNS,不能防御 DNS 重绑定、代理、重定向等场景
  201 + *
  202 + * @param url 请求地址
  203 + */
  204 + private static void validateHttpRequestUrl(String url) {
  205 + BpmHttpRequestProperties properties = SpringUtils.getBean(BpmHttpRequestProperties.class);
  206 + if (!Boolean.TRUE.equals(properties.getEnabled())) {
  207 + return;
  208 + }
  209 + String host = getHttpRequestHost(url);
  210 + if (host == null) {
  211 + log.warn("[validateHttpRequestUrl][请求地址({}) 不合法,拒绝请求]", url);
  212 + throw exception(PROCESS_INSTANCE_HTTP_CALL_ERROR);
  213 + }
  214 + if (containsHost(properties.getDenyHosts(), host)) {
  215 + log.warn("[validateHttpRequestUrl][请求地址的主机({}) 在黑名单中,拒绝请求]", host);
  216 + throw exception(PROCESS_INSTANCE_HTTP_CALL_ERROR);
  217 + }
  218 + if (CollUtil.isNotEmpty(properties.getAllowHosts()) && !containsHost(properties.getAllowHosts(), host)) {
  219 + log.warn("[validateHttpRequestUrl][请求地址的主机({}) 不在白名单中,拒绝请求]", host);
  220 + throw exception(PROCESS_INSTANCE_HTTP_CALL_ERROR);
  221 + }
  222 + }
  223 +
  224 + /**
  225 + * 获得请求地址的主机(域名或 IP),使用与 RestTemplate 相同的 URI 解析方式
  226 + *
  227 + * @param url 请求地址
  228 + * @return 规范化后的主机;不是 http、https 的绝对地址时,返回 null
  229 + */
  230 + private static String getHttpRequestHost(String url) {
  231 + if (StrUtil.isBlank(url)) {
  232 + return null;
  233 + }
  234 + UriComponents uriComponents;
  235 + try {
  236 + uriComponents = UriComponentsBuilder.fromUriString(url).build();
  237 + } catch (IllegalArgumentException e) {
  238 + return null;
  239 + }
  240 + if (!StrUtil.equalsAnyIgnoreCase(uriComponents.getScheme(), "http", "https")
  241 + || StrUtil.isBlank(uriComponents.getHost())) {
  242 + return null;
  243 + }
  244 + return normalizeHost(uriComponents.getHost());
  245 + }
  246 +
  247 + private static boolean containsHost(Set<String> hosts, String host) {
  248 + return CollUtil.isNotEmpty(hosts) && hosts.stream().anyMatch(item -> host.equals(normalizeHost(item)));
  249 + }
  250 +
  251 + /**
  252 + * 规范化主机:去掉首尾空白、转小写,IPv6 去掉方括号
  253 + */
  254 + private static String normalizeHost(String host) {
  255 + String result = StrUtil.nullToEmpty(StrUtil.trim(host)).toLowerCase(Locale.ROOT);
  256 + return StrUtil.removeSuffix(StrUtil.removePrefix(result, "["), "]");
  257 + }
  258 +
188 } 259 }
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; @@ -4,6 +4,7 @@ import cn.hutool.extra.spring.SpringUtil;
4 import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; 4 import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
5 import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; 5 import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
6 import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; 6 import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
  7 +import cn.iocoder.yudao.module.bpm.framework.flowable.config.BpmHttpRequestProperties;
7 import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService; 8 import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService;
8 import org.flowable.engine.runtime.ProcessInstance; 9 import org.flowable.engine.runtime.ProcessInstance;
9 import org.junit.jupiter.api.AfterEach; 10 import org.junit.jupiter.api.AfterEach;
@@ -15,7 +16,11 @@ import org.springframework.http.HttpEntity; @@ -15,7 +16,11 @@ import org.springframework.http.HttpEntity;
15 import org.springframework.http.ResponseEntity; 16 import org.springframework.http.ResponseEntity;
16 import org.springframework.web.client.RestTemplate; 17 import org.springframework.web.client.RestTemplate;
17 18
  19 +import java.util.Set;
  20 +
  21 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
18 import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; 22 import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID;
  23 +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.PROCESS_INSTANCE_HTTP_CALL_ERROR;
19 import static org.junit.jupiter.api.Assertions.assertEquals; 24 import static org.junit.jupiter.api.Assertions.assertEquals;
20 import static org.mockito.ArgumentMatchers.*; 25 import static org.mockito.ArgumentMatchers.*;
21 import static org.mockito.Mockito.*; 26 import static org.mockito.Mockito.*;
@@ -62,4 +67,110 @@ public class BpmHttpRequestUtilsTest extends BaseMockitoUnitTest { @@ -62,4 +67,110 @@ public class BpmHttpRequestUtilsTest extends BaseMockitoUnitTest {
62 assertEquals("tenant-1", requestCaptor.getValue().getHeaders().getFirst(HEADER_TENANT_ID)); 67 assertEquals("tenant-1", requestCaptor.getValue().getHeaders().getFirst(HEADER_TENANT_ID));
63 } 68 }
64 69
  70 + @Test
  71 + public void testExecuteBpmHttpRequest_disabled() {
  72 + // 准备参数:默认关闭,不做任何校验,与原有行为一致
  73 + BpmHttpRequestProperties properties = new BpmHttpRequestProperties();
  74 + properties.setDenyHosts(Set.of("169.254.169.254"));
  75 +
  76 + // 调用,并断言:仍然发起请求
  77 + executeBpmHttpRequestSuccess(properties, "http://169.254.169.254/latest");
  78 + }
  79 +
  80 + @Test
  81 + public void testExecuteBpmHttpRequest_allowHostMatch() {
  82 + // 准备参数:域名、IP 精确匹配,忽略大小写
  83 + BpmHttpRequestProperties properties = buildProperties(Set.of("ERP.example.internal", "10.0.0.8", "::1"), Set.of());
  84 +
  85 + // 调用,并断言:发起请求
  86 + executeBpmHttpRequestSuccess(properties, "https://erp.EXAMPLE.internal/api",
  87 + "http://10.0.0.8:8080/callback", "http://[::1]:48080/callback");
  88 + }
  89 +
  90 + @Test
  91 + public void testExecuteBpmHttpRequest_denyHostFirst() {
  92 + // 准备参数:同时在黑名单、白名单中,黑名单优先
  93 + BpmHttpRequestProperties properties = buildProperties(Set.of("10.0.0.8"), Set.of(" 10.0.0.8 "));
  94 +
  95 + // 调用,并断言:拒绝请求
  96 + executeBpmHttpRequestFail(properties, "http://10.0.0.8/callback");
  97 + }
  98 +
  99 + @Test
  100 + public void testExecuteBpmHttpRequest_notInAllowHosts() {
  101 + // 准备参数:白名单非空时,只允许列表中的域名或 IP,不做后缀、包含匹配
  102 + BpmHttpRequestProperties properties = buildProperties(Set.of("erp.example.internal"), Set.of());
  103 +
  104 + // 调用,并断言:拒绝请求
  105 + executeBpmHttpRequestFail(properties, "http://a.erp.example.internal/callback",
  106 + "http://erp.example.internal.evil.com/callback", "http://10.0.0.9/callback");
  107 + }
  108 +
  109 + @Test
  110 + public void testExecuteBpmHttpRequest_emptyHosts() {
  111 + // 准备参数:开启,但黑白名单都为空时,不额外限制
  112 + BpmHttpRequestProperties properties = buildProperties(Set.of(), Set.of());
  113 +
  114 + // 调用,并断言:发起请求
  115 + executeBpmHttpRequestSuccess(properties, "http://169.254.169.254/latest");
  116 + }
  117 +
  118 + @Test
  119 + public void testExecuteBpmHttpRequest_denyHostsOnly() {
  120 + // 准备参数:只配置黑名单时,命中黑名单拒绝
  121 + BpmHttpRequestProperties properties = buildProperties(Set.of(), Set.of("169.254.169.254"));
  122 +
  123 + // 调用,并断言:拒绝请求
  124 + executeBpmHttpRequestFail(properties, "http://169.254.169.254/latest");
  125 + }
  126 +
  127 + @Test
  128 + public void testExecuteBpmHttpRequest_invalidUrl() {
  129 + // 准备参数:开启后,只允许带域名或 IP 的 http、https 地址
  130 + BpmHttpRequestProperties properties = buildProperties(Set.of(), Set.of());
  131 +
  132 + // 调用,并断言:拒绝请求
  133 + executeBpmHttpRequestFail(properties, "ftp://10.0.0.8/callback", "file:///etc/passwd",
  134 + "//10.0.0.8/callback", "callback", "http:///callback", "", null);
  135 + }
  136 +
  137 + private static BpmHttpRequestProperties buildProperties(Set<String> allowHosts, Set<String> denyHosts) {
  138 + BpmHttpRequestProperties properties = new BpmHttpRequestProperties();
  139 + properties.setEnabled(true);
  140 + properties.setAllowHosts(allowHosts);
  141 + properties.setDenyHosts(denyHosts);
  142 + return properties;
  143 + }
  144 +
  145 + private void executeBpmHttpRequestSuccess(BpmHttpRequestProperties properties, String... urls) {
  146 + when(restTemplate.exchange(anyString(), any(), any(), eq(String.class))).thenReturn(ResponseEntity.ok("{}"));
  147 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  148 + springUtilMockedStatic.when(() -> SpringUtil.getBean(BpmHttpRequestProperties.class)).thenReturn(properties);
  149 + springUtilMockedStatic.when(() -> SpringUtil.getBean(BpmProcessInstanceService.class))
  150 + .thenReturn(processInstanceService);
  151 + springUtilMockedStatic.when(() -> SpringUtil.getBean(RestTemplate.class)).thenReturn(restTemplate);
  152 +
  153 + for (String url : urls) {
  154 + // 调用
  155 + BpmHttpRequestUtils.executeBpmHttpRequest(processInstance, url, null, null, false, null);
  156 + // 断言:使用原有 RestTemplate 发起请求
  157 + verify(restTemplate).exchange(eq(url), any(), any(), eq(String.class));
  158 + }
  159 + }
  160 + }
  161 +
  162 + private void executeBpmHttpRequestFail(BpmHttpRequestProperties properties, String... urls) {
  163 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  164 + springUtilMockedStatic.when(() -> SpringUtil.getBean(BpmHttpRequestProperties.class)).thenReturn(properties);
  165 +
  166 + for (String url : urls) {
  167 + // 调用,并断言
  168 + assertServiceException(() -> BpmHttpRequestUtils.executeBpmHttpRequest(processInstance, url,
  169 + null, null, false, null), PROCESS_INSTANCE_HTTP_CALL_ERROR);
  170 + }
  171 + }
  172 + // 断言:未发起请求
  173 + verify(restTemplate, never()).exchange(anyString(), any(), any(), eq(String.class));
  174 + }
  175 +
65 } 176 }