Commit 56b2b5409668952fea2a3212be4d1d2036c60b39
1 parent
39c3681d
feat(bpm): 增加可选 HTTP 请求域名和 IP 黑白名单
默认关闭名单检查,保留原有 RestTemplate 和正常 HTTP 行为;开启后按域名或 IP 精确匹配,黑名单优先。 验证:BPM 模块全部 316 条测试通过,独立多模型复评通过。
Showing
4 changed files
with
232 additions
and
0 deletions
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 | 16 | import org.flowable.spring.boot.EngineConfigurationConfigurer; |
| 17 | 17 | import org.springframework.beans.factory.ObjectProvider; |
| 18 | 18 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; |
| 19 | +import org.springframework.boot.context.properties.EnableConfigurationProperties; | |
| 19 | 20 | import org.springframework.context.ApplicationEventPublisher; |
| 20 | 21 | import org.springframework.context.annotation.Bean; |
| 21 | 22 | import org.springframework.context.annotation.Configuration; |
| ... | ... | @@ -32,6 +33,7 @@ import java.util.List; |
| 32 | 33 | * @author jason |
| 33 | 34 | */ |
| 34 | 35 | @Configuration(proxyBeanMethods = false) |
| 36 | +@EnableConfigurationProperties(BpmHttpRequestProperties.class) | |
| 35 | 37 | @Slf4j |
| 36 | 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<String> allowHosts = new HashSet<>(Set.of("erp.example.internal", "10.0.0.8")); | |
| 18 | + * private Set<String> denyHosts = new HashSet<>(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 | 10 | import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; |
| 11 | 11 | import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelNodeVO; |
| 12 | 12 | import cn.iocoder.yudao.module.bpm.enums.definition.BpmHttpRequestParamTypeEnum; |
| 13 | +import cn.iocoder.yudao.module.bpm.framework.flowable.config.BpmHttpRequestProperties; | |
| 13 | 14 | import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService; |
| 14 | 15 | import com.fasterxml.jackson.core.type.TypeReference; |
| 15 | 16 | import lombok.extern.slf4j.Slf4j; |
| ... | ... | @@ -19,10 +20,14 @@ import org.springframework.util.LinkedMultiValueMap; |
| 19 | 20 | import org.springframework.util.MultiValueMap; |
| 20 | 21 | import org.springframework.web.client.RestClientException; |
| 21 | 22 | import org.springframework.web.client.RestTemplate; |
| 23 | +import org.springframework.web.util.UriComponents; | |
| 24 | +import org.springframework.web.util.UriComponentsBuilder; | |
| 22 | 25 | |
| 23 | 26 | import java.util.HashMap; |
| 24 | 27 | import java.util.List; |
| 28 | +import java.util.Locale; | |
| 25 | 29 | import java.util.Map; |
| 30 | +import java.util.Set; | |
| 26 | 31 | |
| 27 | 32 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 28 | 33 | import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; |
| ... | ... | @@ -42,6 +47,8 @@ public class BpmHttpRequestUtils { |
| 42 | 47 | List<BpmSimpleModelNodeVO.HttpRequestParam> bodyParams, |
| 43 | 48 | Boolean handleResponse, |
| 44 | 49 | List<KeyValue<String, String>> response) { |
| 50 | + // 0. 校验请求地址的域名 / IP 黑白名单(默认关闭) | |
| 51 | + validateHttpRequestUrl(url); | |
| 45 | 52 | BpmProcessInstanceService processInstanceService = SpringUtils.getBean(BpmProcessInstanceService.class); |
| 46 | 53 | |
| 47 | 54 | // 1.1 设置请求头 |
| ... | ... | @@ -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 | 4 | import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; |
| 5 | 5 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 6 | 6 | import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; |
| 7 | +import cn.iocoder.yudao.module.bpm.framework.flowable.config.BpmHttpRequestProperties; | |
| 7 | 8 | import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService; |
| 8 | 9 | import org.flowable.engine.runtime.ProcessInstance; |
| 9 | 10 | import org.junit.jupiter.api.AfterEach; |
| ... | ... | @@ -15,7 +16,11 @@ import org.springframework.http.HttpEntity; |
| 15 | 16 | import org.springframework.http.ResponseEntity; |
| 16 | 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 | 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 | 24 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 20 | 25 | import static org.mockito.ArgumentMatchers.*; |
| 21 | 26 | import static org.mockito.Mockito.*; |
| ... | ... | @@ -62,4 +67,110 @@ public class BpmHttpRequestUtilsTest extends BaseMockitoUnitTest { |
| 62 | 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 | } | ... | ... |