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();
+ }
+
+}