签入版本

This commit is contained in:
2026-09-12 18:24:41 +08:00
commit fb2b7d9538
852 changed files with 115466 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
"""调度器安全相关测试:SSRF 防护 + 单实例防护 + 统一线程池。"""
from modules.job import scheduler as sch
def test_build_full_url():
assert sch.build_full_url('/api/x') == f"http://{sch.FASTAPI_HOST}:{sch.FASTAPI_PORT}/api/x"
assert sch.build_full_url('http://a.com/x') == 'http://a.com/x'
assert sch.build_full_url('ftp://a.com/x') == 'ftp://a.com/x'
def test_validate_http_url_rejects_internal_ips():
for url in (
'http://127.0.0.1:6379/', # 回环
'http://10.0.0.1/x', # 私有A类
'http://169.254.169.254/latest', # 云元数据
'http://192.168.1.1/x', # 私有C类
'http://172.16.5.5/x', # 私有B类
):
ok, reason = sch._validate_http_url(url)
assert ok is False, url
assert '禁止' in reason or '内网' in reason
def test_validate_http_url_rejects_non_http_scheme():
for url in ('ftp://a.com/x', 'file:///etc/passwd', 'javascript:alert(1)'):
ok, reason = sch._validate_http_url(url)
assert ok is False, url
assert '协议' in reason
def test_validate_http_url_accepts_public_ip():
# 公网 IP 字面量,无需真实 DNS
ok, reason = sch._validate_http_url('http://93.184.216.34/')
assert ok is True, reason
def test_execute_by_url_blocks_internal_url(monkeypatch):
sent = []
monkeypatch.setattr(
sch, '_send_sync_request',
lambda url, data, jid, pinned_ip=None: sent.append(url) or {'success': True})
result = sch._execute_by_url(1, 'http://127.0.0.1:6379', 1)
assert result['success'] is False
assert '禁止' in result['error'] or '内网' in result['error']
assert sent == [] # 未实际发起请求
def test_execute_by_url_relative_self_call_allowed(monkeypatch):
sent = []
monkeypatch.setattr(
sch, '_send_sync_request',
lambda url, data, jid, pinned_ip=None: sent.append(url) or {'success': True})
result = sch._execute_by_url(1, '/api/ping', 1)
assert result['success'] is True
assert sent == [f"http://{sch.FASTAPI_HOST}:{sch.FASTAPI_PORT}/api/ping"]
# ============================================================
# SSRF DNS 重绑定防护:预解析后固定 IP 直连,杜绝二次解析域名
# ============================================================
def test_resolve_and_pin_returns_pinned_ip():
# 公网 IP 字面量,无需真实 DNS
ok, reason, ip = sch._resolve_and_pin('http://93.184.216.34/x')
assert ok is True, reason
assert ip == '93.184.216.34'
def test_resolve_and_pin_rejects_custom_port():
ok, reason, ip = sch._resolve_and_pin('http://93.184.216.34:8080/x')
assert ok is False
assert '端口' in reason
assert ip is None
def test_resolve_and_pin_allows_80_443():
for url in ('http://93.184.216.34:80/x', 'https://93.184.216.34:443/x'):
ok, reason, ip = sch._resolve_and_pin(url)
assert ok is True, reason
assert ip == '93.184.216.34'
def test_pinned_connection_pins_dns_host_keeps_real_host():
# HTTP 与 HTTPS 连接:_dns_host 固定为预校验 IP(实际建连目标),
# host 保持真实主机名(Host 头 / TLS SNI)——同域名只解析一次,杜绝重绑定。
for cls, port in ((sch._PinnedHTTPConnection, 80), (sch._PinnedHTTPSConnection, 443)):
conn = cls(host='example.com', port=port, pinned_ip='93.184.216.34')
assert conn._dns_host == '93.184.216.34'
assert conn.host == 'example.com'
def test_send_sync_request_uses_pinned_ip_when_given(monkeypatch):
calls = []
class _FakeResp:
status_code = 200
def raise_for_status(self):
pass
def json(self):
return {'ok': 1}
monkeypatch.setattr(
sch, '_post_pinned',
lambda url, data, headers, timeout, pinned_ip: calls.append((url, pinned_ip)) or _FakeResp())
result = sch._send_sync_request('http://example.com/x', {'a': 1}, 1, '93.184.216.34')
assert result['success'] is True
assert calls == [('http://example.com/x', '93.184.216.34')]
def test_send_sync_request_without_pinned_ip_uses_plain_post(monkeypatch):
calls = []
class _FakeResp:
status_code = 200
def raise_for_status(self):
pass
def json(self):
return {'ok': 1}
monkeypatch.setattr(
sch, '_post_pinned',
lambda *a, **k: calls.append('pinned') or _FakeResp())
monkeypatch.setattr(
sch.requests, 'post',
lambda url, **kw: calls.append('plain') or _FakeResp())
# 相对路径自调用等可信场景无固定 IP,应走常规 requests.post 而非 pinned 路径
result = sch._send_sync_request('http://127.0.0.1:8031/api/ping', {'a': 1}, 1)
assert result['success'] is True
assert calls == ['plain']
def test_job_single_instance_claim(monkeypatch):
# 禁用 Redis 锁(退化进程内互斥),使进程内 set 行为可独立验证
monkeypatch.setattr(sch, '_get_lock_redis', lambda: None)
assert sch._try_claim_job(42) is True
assert sch._try_claim_job(42) is False # 已占用,拒绝重入
sch._release_job(42)
assert sch._try_claim_job(42) is True
sch._release_job(42)
# ============================================================
# Redis 分布式锁:跨 worker 进程单实例互斥
# ============================================================
def test_try_claim_job_respects_cross_process_redis_lock(monkeypatch):
import fakeredis
fake = fakeredis.FakeRedis()
monkeypatch.setattr(sch, '_get_lock_redis', lambda: fake)
# 进程内重复触发仍被本地 set 拦截
assert sch._try_claim_job(7) is True
assert sch._try_claim_job(7) is False
sch._release_job(7)
assert sch._try_claim_job(7) is True
sch._release_job(7)
# 模拟他进程已持锁:Redis 锁被占用 → 本进程拒绝,且本地占用已回滚
fake.set(f'{sch._JOB_LOCK_PREFIX}9', '1', nx=True, ex=300)
assert sch._try_claim_job(9) is False
assert 9 not in sch._running_jobs
# 释放后(他进程删除锁)可再次占用
fake.delete(f'{sch._JOB_LOCK_PREFIX}9')
assert sch._try_claim_job(9) is True
sch._release_job(9)
def test_try_claim_job_degrades_when_redis_unavailable(monkeypatch):
# Redis 不可用 → 退化进程内互斥,不中断任务执行
monkeypatch.setattr(sch, '_get_lock_redis', lambda: None)
assert sch._try_claim_job(10) is True
assert sch._try_claim_job(10) is False # 本地 set 仍生效
sch._release_job(10)
# ============================================================
# _handle_scheduler_update:运行中任务编辑后必须重加调度器刷新执行参数
# ============================================================
def _patch_scheduler_calls(monkeypatch):
"""打桩 job.service 中的调度器函数,记录 add/remove 调用。"""
from modules.job import service as job_svc
calls = {'add': [], 'remove': []}
monkeypatch.setattr(
job_svc, 'add_job_to_scheduler',
lambda data: calls['add'].append(data) or True)
monkeypatch.setattr(
job_svc, 'remove_job_from_scheduler',
lambda jid: calls['remove'].append(jid) or True)
return job_svc, calls
def test_handle_scheduler_update_running_edit_readds(monkeypatch):
"""运行中任务编辑 url/params:cron 未变也需重加,否则调度器仍按旧参数执行。"""
job_svc, calls = _patch_scheduler_calls(monkeypatch)
ok = job_svc._handle_scheduler_update(
{'id': 5, 'status': 1, 'url': '/api/new'}, old_status=1, old_cron='0 * * * *')
assert ok is True
assert calls['remove'] == []
assert len(calls['add']) == 1
# 重加时携带完整任务状态与有效 cron(部分更新缺 cron 时沿用旧值)
added = calls['add'][0]
assert added['id'] == 5
assert added['url'] == '/api/new'
assert added['cron_expression'] == '0 * * * *'
def test_handle_scheduler_update_running_explicit_new_cron(monkeypatch):
"""运行中任务显式改 cron:以新 cron 重加。"""
job_svc, calls = _patch_scheduler_calls(monkeypatch)
ok = job_svc._handle_scheduler_update(
{'id': 5, 'status': 1, 'cron_expression': '*/5 * * * *'}, old_status=1, old_cron='0 * * * *')
assert ok is True
assert calls['add'][0]['cron_expression'] == '*/5 * * * *'
def test_handle_scheduler_update_running_to_disabled_removes(monkeypatch):
"""运行中 → 禁用:从调度器移除。"""
job_svc, calls = _patch_scheduler_calls(monkeypatch)
ok = job_svc._handle_scheduler_update(
{'id': 5, 'status': 2}, old_status=1, old_cron='0 * * * *')
assert ok is True
assert calls['add'] == []
assert calls['remove'] == [5]
def test_handle_scheduler_update_disabled_to_running_readds(monkeypatch):
"""暂停 → 启用且带 cron:重加。"""
job_svc, calls = _patch_scheduler_calls(monkeypatch)
ok = job_svc._handle_scheduler_update(
{'id': 5, 'status': 1, 'cron_expression': '0 * * * *'}, old_status=2, old_cron='0 * * * *')
assert ok is True
assert len(calls['add']) == 1
assert calls['remove'] == []
def test_handle_scheduler_update_disabled_to_running_without_cron(monkeypatch):
"""暂停 → 启用但无 cron:无法调度,移除(保持调度器一致性)。"""
job_svc, calls = _patch_scheduler_calls(monkeypatch)
ok = job_svc._handle_scheduler_update(
{'id': 5, 'status': 1}, old_status=2, old_cron=None)
assert ok is True
assert calls['add'] == []
assert calls['remove'] == [5]
def test_handle_scheduler_update_no_status_change_noop(monkeypatch):
"""保持暂停:不触发任何调度器操作。"""
job_svc, calls = _patch_scheduler_calls(monkeypatch)
ok = job_svc._handle_scheduler_update(
{'id': 5, 'status': 2}, old_status=2, old_cron=None)
assert ok is True
assert calls['add'] == []
assert calls['remove'] == []
# ============================================================
# _build_cron_trigger:5/6 字段 cron,6 字段首字段为秒(秒级触发)
# ============================================================
def _fire_seconds_away(cron_expr):
"""返回 (next_fire, 距 now 的秒数)。now 取本地 aware 时间,与触发器默认时区对齐。"""
from datetime import datetime
now = datetime.now().astimezone()
nf = sch._build_cron_trigger(cron_expr).get_next_fire_time(None, now)
return nf, (nf - now).total_seconds() if nf else None
def test_build_cron_trigger_5_fields_minute_granularity():
nf, delta = _fire_seconds_away('0 * * * *') # 每小时整点
assert nf is not None
assert 0 < delta <= 3600
def test_build_cron_trigger_6_fields_second_granularity():
nf, delta = _fire_seconds_away('*/15 * * * * *') # 每 15 秒
assert nf is not None
assert 0 < delta <= 15, f"秒级 cron 应 15s 内触发,实际 {delta}s"
def test_build_cron_trigger_5_field_matches_from_crontab():
"""5 字段行为与 CronTrigger.from_crontab 完全一致(向后兼容)。"""
from apscheduler.triggers.cron import CronTrigger
from datetime import datetime
now = datetime.now().astimezone()
t1 = sch._build_cron_trigger('0 * * * *').get_next_fire_time(None, now)
t2 = CronTrigger.from_crontab('0 * * * *').get_next_fire_time(None, now)
assert t1 == t2
def test_build_cron_trigger_invalid_field_count_raises():
import pytest
for bad in ('', '* * * *', '* * * * * * *'): # 0/4/7 字段均非法
with pytest.raises(ValueError):
sch._build_cron_trigger(bad)