From 56b2b5409668952fea2a3212be4d1d2036c60b39 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Mon, 26 Oct 2026 19:03:56 +0800 Subject: [PATCH] feat(bpm): 增加可选 HTTP 请求域名和 IP 黑白名单 --- yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmFlowableConfiguration.java | 2 ++ yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmHttpRequestProperties.java | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtils.java | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtilsTest.java | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 232 insertions(+), 0 deletions(-) create mode 100644 yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmHttpRequestProperties.java 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)); + } + } -- libgit2 0.21.4