mirror of
http://git.pig4cloud.com/pig/pigx.git
synced 2026-11-08 04:39:00 +08:00
fix(quartz): 增加 REST 任务 URL 白名单校验
(cherry picked from commit ad6cd0600641ce916a09efe6affb56db97ad6890)
This commit is contained in:
1 parent
2edc478f73
commit
269d7fe5e4
7 files changed
+342
-4
No files matched your search
+13
@@ -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<>();
|
||||
|
||||
}
|
||||
+15
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
+15
-4
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+99
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+65
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+65
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user