From 269d7fe5e4246e234d868db91e6579c6eee317ac Mon Sep 17 00:00:00 2001 From: PIGCLOUD Date: Tue, 11 Aug 2026 13:52:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(quartz):=20=E5=A2=9E=E5=8A=A0=20REST=20?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=20URL=20=E7=99=BD=E5=90=8D=E5=8D=95=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit ad6cd0600641ce916a09efe6affb56db97ad6890) --- .../config/QuartzProtectionProperties.java | 13 +++ .../quartz/controller/SysJobController.java | 15 +++ .../daemon/quartz/util/RestTaskInvok.java | 19 +++- .../quartz/util/RestTaskUrlValidator.java | 99 +++++++++++++++++++ .../controller/SysJobControllerTests.java | 65 ++++++++++++ .../quartz/util/RestTaskInvokTests.java | 70 +++++++++++++ .../util/RestTaskUrlValidatorTests.java | 65 ++++++++++++ 7 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskUrlValidator.java create mode 100644 pigx-visual/pigx-daemon-quartz/src/test/java/com/pig4cloud/pigx/daemon/quartz/controller/SysJobControllerTests.java create mode 100644 pigx-visual/pigx-daemon-quartz/src/test/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskInvokTests.java create mode 100644 pigx-visual/pigx-daemon-quartz/src/test/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskUrlValidatorTests.java diff --git a/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/config/QuartzProtectionProperties.java b/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/config/QuartzProtectionProperties.java index 5eea69bd2..9c821e226 100644 --- a/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/config/QuartzProtectionProperties.java +++ b/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/config/QuartzProtectionProperties.java @@ -3,6 +3,9 @@ package com.pig4cloud.pigx.daemon.quartz.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; +import java.util.HashSet; +import java.util.Set; + /** * Quartz 重复触发保护配置。 *

@@ -34,4 +37,14 @@ public class QuartzProtectionProperties { */ private boolean logSkipped = true; + /** + * REST 任务允许访问的 URL 源白名单。 + *

+ * 白名单项格式为 {@code scheme://host[:port]},例如 + * {@code https://api.example.com}。匹配时仅比较协议、主机和有效端口,任务地址可以携带具体的路径和查询参数。空白名单默认拒绝所有 + * REST 任务请求。 + *

+ */ + private Set restTaskUrlWhitelist = new HashSet<>(); + } diff --git a/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/controller/SysJobController.java b/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/controller/SysJobController.java index bf7ff6dd0..b29140257 100644 --- a/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/controller/SysJobController.java +++ b/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/controller/SysJobController.java @@ -27,11 +27,13 @@ import com.pig4cloud.pigx.common.excel.annotation.ResponseExcel; import com.pig4cloud.pigx.common.log.annotation.SysLog; import com.pig4cloud.pigx.common.security.annotation.HasPermission; import com.pig4cloud.pigx.common.security.util.SecurityUtils; +import com.pig4cloud.pigx.daemon.quartz.constants.JobTypeQuartzEnum; import com.pig4cloud.pigx.daemon.quartz.constants.PigxQuartzEnum; import com.pig4cloud.pigx.daemon.quartz.entity.SysJob; import com.pig4cloud.pigx.daemon.quartz.entity.SysJobLog; import com.pig4cloud.pigx.daemon.quartz.service.SysJobLogService; import com.pig4cloud.pigx.daemon.quartz.service.SysJobService; +import com.pig4cloud.pigx.daemon.quartz.util.RestTaskUrlValidator; import com.pig4cloud.pigx.daemon.quartz.util.TaskUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -64,6 +66,8 @@ public class SysJobController { private final Scheduler scheduler; + private final RestTaskUrlValidator restTaskUrlValidator; + /** * 定时任务分页查询 * @param page 分页对象 @@ -115,6 +119,9 @@ public class SysJobController { @HasPermission("job_sys_job_add") @Operation(description = "新增定时任务") public R save(@RequestBody SysJob sysJob) { + if (!isRestTaskUrlAllowed(sysJob)) { + return R.failed("REST任务地址未配置白名单或不在白名单中"); + } // 初始化任务 taskUtil.addOrUpateJob(sysJob, scheduler); sysJob.setJobStatus(PigxQuartzEnum.JOB_STATUS_RELEASE.getType()); @@ -132,6 +139,9 @@ public class SysJobController { @HasPermission("job_sys_job_edit") @Operation(description = "修改定时任务") public R updateById(@RequestBody SysJob sysJob) { + if (!isRestTaskUrlAllowed(sysJob)) { + return R.failed("REST任务地址未配置白名单或不在白名单中"); + } sysJob.setUpdateBy(SecurityUtils.getUser().getUsername()); SysJob querySysJob = this.sysJobService.getById(sysJob.getJobId()); if (PigxQuartzEnum.JOB_STATUS_NOT_RUNNING.getType().equals(querySysJob.getJobStatus())) { @@ -322,4 +332,9 @@ public class SysJobController { return sysJobService.list(Wrappers.query(sysJob)); } + private boolean isRestTaskUrlAllowed(SysJob sysJob) { + return !JobTypeQuartzEnum.REST.getType().equals(sysJob.getJobType()) + || restTaskUrlValidator.isAllowed(sysJob.getExecutePath()); + } + } diff --git a/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskInvok.java b/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskInvok.java index c64d738ec..7659e65aa 100644 --- a/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskInvok.java +++ b/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskInvok.java @@ -18,6 +18,7 @@ package com.pig4cloud.pigx.daemon.quartz.util; import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; import cn.hutool.http.HttpUtil; import com.pig4cloud.pigx.common.core.constant.CommonConstants; import com.pig4cloud.pigx.daemon.quartz.entity.SysJob; @@ -36,18 +37,28 @@ import org.springframework.stereotype.Component; @Component("restTaskInvok") public class RestTaskInvok implements ITaskInvok { + private final RestTaskUrlValidator restTaskUrlValidator; + @Override public void invokMethod(SysJob sysJob) throws TaskException { + String executePath = sysJob.getExecutePath(); + if (!restTaskUrlValidator.isAllowed(executePath)) { + log.warn("定时任务REST地址未通过白名单校验,任务ID:{}", sysJob.getJobId()); + throw new TaskException("定时任务REST地址未通过白名单校验"); + } + try { - HttpRequest request = HttpUtil.createGet(sysJob.getExecutePath()); + HttpRequest request = HttpUtil.createGet(executePath).setMaxRedirectCount(0); if (sysJob.getTenantId() != null) { request.header(CommonConstants.TENANT_ID, sysJob.getTenantId().toString()); } - request.execute(); + try (HttpResponse ignored = request.execute()) { + // REST 任务仅关注请求是否成功发出,响应内容无需保留 + } } catch (Exception e) { - log.error("定时任务restTaskInvok异常,执行任务:{}", sysJob.getExecutePath()); - throw new TaskException("定时任务restTaskInvok业务执行失败,任务:" + sysJob.getExecutePath()); + log.error("定时任务restTaskInvok异常,任务ID:{}", sysJob.getJobId(), e); + throw new TaskException("定时任务restTaskInvok业务执行失败,任务ID:" + sysJob.getJobId()); } } diff --git a/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskUrlValidator.java b/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskUrlValidator.java new file mode 100644 index 000000000..bdec98481 --- /dev/null +++ b/pigx-visual/pigx-daemon-quartz/src/main/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskUrlValidator.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2018-2026, lengleng All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * Neither the name of the pig4cloud.com developer nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * Author: lengleng (wangiegie@gmail.com) + */ + +package com.pig4cloud.pigx.daemon.quartz.util; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.core.util.URLUtil; +import com.pig4cloud.pigx.daemon.quartz.config.QuartzProtectionProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.util.Locale; +import java.util.Set; + +/** + * REST 定时任务 URL 白名单校验器。 + */ +@Component +@RequiredArgsConstructor +public class RestTaskUrlValidator { + + private static final Set ALLOWED_SCHEMES = Set.of("http", "https"); + + private final QuartzProtectionProperties properties; + + /** + * 校验任务地址是否命中 URL 源白名单。 + * @param executePath REST 任务地址 + * @return 是否允许请求 + */ + public boolean isAllowed(String executePath) { + URI target = parseHttpUri(executePath, false); + if (target == null || CollUtil.isEmpty(properties.getRestTaskUrlWhitelist())) { + return false; + } + + return properties.getRestTaskUrlWhitelist() + .stream() + .map(item -> parseHttpUri(item, true)) + .anyMatch(allowed -> sameOrigin(target, allowed)); + } + + private URI parseHttpUri(String value, boolean originOnly) { + if (StrUtil.isBlank(value)) { + return null; + } + + try { + URI uri = URLUtil.toURI(value).normalize(); + String scheme = uri.getScheme(); + if (StrUtil.isBlank(scheme) || !ALLOWED_SCHEMES.contains(scheme.toLowerCase(Locale.ROOT)) + || StrUtil.isBlank(uri.getHost()) || uri.getUserInfo() != null) { + return null; + } + if (originOnly && (StrUtil.isNotBlank(uri.getQuery()) || StrUtil.isNotBlank(uri.getFragment()) + || !(StrUtil.isBlank(uri.getPath()) || StrUtil.equals(uri.getPath(), "/")))) { + return null; + } + return uri; + } + catch (RuntimeException ex) { + return null; + } + } + + private boolean sameOrigin(URI target, URI allowed) { + return allowed != null && target.getScheme().equalsIgnoreCase(allowed.getScheme()) + && normalizeHost(target.getHost()).equals(normalizeHost(allowed.getHost())) + && effectivePort(target) == effectivePort(allowed); + } + + private String normalizeHost(String host) { + return host.toLowerCase(Locale.ROOT); + } + + private int effectivePort(URI uri) { + if (uri.getPort() >= 0) { + return uri.getPort(); + } + return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; + } + +} diff --git a/pigx-visual/pigx-daemon-quartz/src/test/java/com/pig4cloud/pigx/daemon/quartz/controller/SysJobControllerTests.java b/pigx-visual/pigx-daemon-quartz/src/test/java/com/pig4cloud/pigx/daemon/quartz/controller/SysJobControllerTests.java new file mode 100644 index 000000000..14ccba9f9 --- /dev/null +++ b/pigx-visual/pigx-daemon-quartz/src/test/java/com/pig4cloud/pigx/daemon/quartz/controller/SysJobControllerTests.java @@ -0,0 +1,65 @@ +package com.pig4cloud.pigx.daemon.quartz.controller; + +import com.pig4cloud.pigx.common.core.util.R; +import com.pig4cloud.pigx.daemon.quartz.constants.JobTypeQuartzEnum; +import com.pig4cloud.pigx.daemon.quartz.entity.SysJob; +import com.pig4cloud.pigx.daemon.quartz.service.SysJobLogService; +import com.pig4cloud.pigx.daemon.quartz.service.SysJobService; +import com.pig4cloud.pigx.daemon.quartz.util.RestTaskUrlValidator; +import com.pig4cloud.pigx.daemon.quartz.util.TaskUtil; +import org.junit.jupiter.api.Test; +import org.quartz.Scheduler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class SysJobControllerTests { + + private final SysJobService sysJobService = mock(SysJobService.class); + + private final SysJobLogService sysJobLogService = mock(SysJobLogService.class); + + private final TaskUtil taskUtil = mock(TaskUtil.class); + + private final Scheduler scheduler = mock(Scheduler.class); + + private final RestTaskUrlValidator validator = mock(RestTaskUrlValidator.class); + + private final SysJobController controller = new SysJobController(sysJobService, sysJobLogService, taskUtil, + scheduler, validator); + + @Test + void rejectsRestTaskBeforeSaving() { + SysJob sysJob = restJob(); + when(validator.isAllowed(sysJob.getExecutePath())).thenReturn(false); + + R result = controller.save(sysJob); + + assertThat(result.isOk()).isFalse(); + assertThat(result.getMsg()).isEqualTo("REST任务地址未配置白名单或不在白名单中"); + verifyNoInteractions(taskUtil, sysJobService, scheduler); + } + + @Test + void rejectsRestTaskBeforeUpdating() { + SysJob sysJob = restJob(); + when(validator.isAllowed(sysJob.getExecutePath())).thenReturn(false); + + R result = controller.updateById(sysJob); + + assertThat(result.isOk()).isFalse(); + assertThat(result.getMsg()).isEqualTo("REST任务地址未配置白名单或不在白名单中"); + verifyNoInteractions(taskUtil, sysJobService, scheduler); + } + + private SysJob restJob() { + return SysJob.builder() + .jobId(1L) + .jobType(JobTypeQuartzEnum.REST.getType()) + .executePath("http://127.0.0.1/internal") + .build(); + } + +} diff --git a/pigx-visual/pigx-daemon-quartz/src/test/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskInvokTests.java b/pigx-visual/pigx-daemon-quartz/src/test/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskInvokTests.java new file mode 100644 index 000000000..a55d8cc35 --- /dev/null +++ b/pigx-visual/pigx-daemon-quartz/src/test/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskInvokTests.java @@ -0,0 +1,70 @@ +package com.pig4cloud.pigx.daemon.quartz.util; + +import com.pig4cloud.pigx.daemon.quartz.entity.SysJob; +import com.pig4cloud.pigx.daemon.quartz.exception.TaskException; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RestTaskInvokTests { + + private final RestTaskUrlValidator validator = mock(RestTaskUrlValidator.class); + + private final RestTaskInvok invoker = new RestTaskInvok(validator); + + @Test + void rejectsUrlBeforeSendingRequest() { + SysJob sysJob = SysJob.builder().jobId(1L).executePath("http://127.0.0.1/internal").build(); + when(validator.isAllowed(sysJob.getExecutePath())).thenReturn(false); + + assertThatThrownBy(() -> invoker.invokMethod(sysJob)).isInstanceOf(TaskException.class) + .hasMessage("定时任务REST地址未通过白名单校验"); + verify(validator).isAllowed(sysJob.getExecutePath()); + } + + @Test + void doesNotFollowRedirects() throws Exception { + AtomicInteger redirectHits = new AtomicInteger(); + AtomicInteger targetHits = new AtomicInteger(); + HttpServer targetServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + targetServer.createContext("/target", exchange -> { + targetHits.incrementAndGet(); + exchange.sendResponseHeaders(200, -1); + exchange.close(); + }); + HttpServer redirectServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + redirectServer.createContext("/redirect", exchange -> { + redirectHits.incrementAndGet(); + exchange.getResponseHeaders() + .add("Location", "http://127.0.0.1:" + targetServer.getAddress().getPort() + "/target"); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + }); + + targetServer.start(); + redirectServer.start(); + try { + String executePath = "http://127.0.0.1:" + redirectServer.getAddress().getPort() + "/redirect"; + SysJob sysJob = SysJob.builder().jobId(2L).executePath(executePath).build(); + when(validator.isAllowed(executePath)).thenReturn(true); + + invoker.invokMethod(sysJob); + + assertThat(redirectHits).hasValue(1); + assertThat(targetHits).hasValue(0); + } + finally { + redirectServer.stop(0); + targetServer.stop(0); + } + } + +} diff --git a/pigx-visual/pigx-daemon-quartz/src/test/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskUrlValidatorTests.java b/pigx-visual/pigx-daemon-quartz/src/test/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskUrlValidatorTests.java new file mode 100644 index 000000000..0af6dbca0 --- /dev/null +++ b/pigx-visual/pigx-daemon-quartz/src/test/java/com/pig4cloud/pigx/daemon/quartz/util/RestTaskUrlValidatorTests.java @@ -0,0 +1,65 @@ +package com.pig4cloud.pigx.daemon.quartz.util; + +import com.pig4cloud.pigx.daemon.quartz.config.QuartzProtectionProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class RestTaskUrlValidatorTests { + + private QuartzProtectionProperties properties; + + private RestTaskUrlValidator validator; + + @BeforeEach + void setUp() { + properties = new QuartzProtectionProperties(); + validator = new RestTaskUrlValidator(properties); + } + + @Test + void rejectsAllUrlsWhenWhitelistIsEmpty() { + assertThat(validator.isAllowed("https://api.example.com/jobs")).isFalse(); + } + + @Test + void acceptsPathAndQueryForWhitelistedOrigin() { + properties.setRestTaskUrlWhitelist(Set.of("https://api.example.com")); + + assertThat(validator.isAllowed("https://api.example.com/jobs/run?id=1")).isTrue(); + } + + @Test + void normalizesHostCaseAndDefaultPort() { + properties.setRestTaskUrlWhitelist(Set.of("https://API.EXAMPLE.COM:443")); + + assertThat(validator.isAllowed("https://api.example.com/jobs")).isTrue(); + } + + @Test + void rejectsPrefixAndUserInfoBypasses() { + properties.setRestTaskUrlWhitelist(Set.of("http://www.baidu.com")); + + assertThat(validator.isAllowed("http://www.baidu.com.evil.example/internal")).isFalse(); + assertThat(validator.isAllowed("http://www.baidu.com@127.0.0.1/internal")).isFalse(); + } + + @Test + void rejectsDifferentSchemeOrPort() { + properties.setRestTaskUrlWhitelist(Set.of("https://api.example.com")); + + assertThat(validator.isAllowed("http://api.example.com/jobs")).isFalse(); + assertThat(validator.isAllowed("https://api.example.com:8443/jobs")).isFalse(); + } + + @Test + void ignoresInvalidWhitelistEntries() { + properties.setRestTaskUrlWhitelist(Set.of("https://api.example.com/private", "file:///tmp/task")); + + assertThat(validator.isAllowed("https://api.example.com/private")).isFalse(); + } + +}