fix(quartz): 增加 REST 任务 URL 白名单校验

(cherry picked from commit ad6cd0600641ce916a09efe6affb56db97ad6890)
This commit is contained in:
PIGCLOUD committed 2026-09-22 21:43:37 +08:00
1 parent 2edc478f73
commit 269d7fe5e4
7 files changed
+342 -4

No files matched your search

@@ -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 重复触发保护配置。
* <p>
@@ -34,4 +37,14 @@ public class QuartzProtectionProperties {
*/
private boolean logSkipped = true;
/**
* REST 任务允许访问的 URL 源白名单。
* <p>
* 白名单项格式为 {@code scheme://host[:port]},例如
* {@code https://api.example.com}。匹配时仅比较协议、主机和有效端口,任务地址可以携带具体的路径和查询参数。空白名单默认拒绝所有
* REST 任务请求。
* </p>
*/
private Set<String> restTaskUrlWhitelist = new HashSet<>();
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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<String> 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;
}
}
@@ -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();
}
}
@@ -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);
}
}
}
@@ -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();
}
}