签入版本
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""utils/captcha.py 验证码校验:原子消费(get→del→compare)杜绝重放与无限试错。
|
||||
|
||||
fakeredis 不支持 Lua eval,这里用 Python 复刻 Lua 语义(原子 get→del→compare,
|
||||
无论对错都消费验证码)作为 fake,验证业务层行为;另断言生产代码确实调用
|
||||
_CAPTCHA_VERIFY_LUA 脚本。
|
||||
"""
|
||||
import pytest
|
||||
import fakeredis.aioredis
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _captcha_redis(monkeypatch):
|
||||
from core import redis_client as rc
|
||||
|
||||
redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
|
||||
async def fake_eval(script, numkeys, key, user_input):
|
||||
# 复刻 _CAPTCHA_VERIFY_LUA 语义:原子 get→del→compare,无论对错都消费
|
||||
stored = await redis.get(key)
|
||||
if not stored:
|
||||
return 0
|
||||
await redis.delete(key)
|
||||
if str(stored).upper().strip() == str(user_input).upper().strip():
|
||||
return 1
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(redis, 'eval', fake_eval)
|
||||
monkeypatch.setattr(rc, 'get_redis', lambda: redis)
|
||||
return redis
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_captcha_correct_then_consumed(_captcha_redis):
|
||||
from utils.captcha import verify_captcha
|
||||
|
||||
await _captcha_redis.set('k1', 'Ab3X')
|
||||
# 大小写不敏感比较通过
|
||||
assert await verify_captcha('k1', 'ab3x') is True
|
||||
# 已消费:同一验证码无法再次使用(防重放)
|
||||
assert await verify_captcha('k1', 'Ab3X') is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_captcha_wrong_input_burns_captcha(_captcha_redis):
|
||||
from utils.captcha import verify_captcha
|
||||
|
||||
await _captcha_redis.set('k2', 'Ab3X')
|
||||
# 错误输入 → False,且验证码被消费(失败即失效,防无限试错)
|
||||
assert await verify_captcha('k2', 'wrong') is False
|
||||
# 随后即使用正确输入也无法通过
|
||||
assert await verify_captcha('k2', 'Ab3X') is False
|
||||
assert await _captcha_redis.get('k2') is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_captcha_missing_key(_captcha_redis):
|
||||
from utils.captcha import verify_captcha
|
||||
|
||||
assert await verify_captcha('missing', 'any') is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_captcha_empty_input(_captcha_redis):
|
||||
from utils.captcha import verify_captcha
|
||||
|
||||
assert await verify_captcha('k', '') is False
|
||||
assert await verify_captcha('', 'x') is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_captcha_uses_atomic_lua_script(_captcha_redis, monkeypatch):
|
||||
from utils.captcha import verify_captcha, _CAPTCHA_VERIFY_LUA
|
||||
|
||||
await _captcha_redis.set('k1', 'Ab3X')
|
||||
calls = []
|
||||
original = _captcha_redis.eval
|
||||
|
||||
async def spy_eval(script, numkeys, key, user_input):
|
||||
calls.append((script, numkeys))
|
||||
return await original(script, numkeys, key, user_input)
|
||||
|
||||
monkeypatch.setattr(_captcha_redis, 'eval', spy_eval)
|
||||
|
||||
assert await verify_captcha('k1', 'Ab3X') is True
|
||||
# 生产代码调用原子 Lua 脚本(numkeys=1)
|
||||
assert calls and calls[0] == (_CAPTCHA_VERIFY_LUA, 1)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""utils/file.py 魔数校验:读取异常必须 fail-closed 拒绝上传,不得放行伪装文件。"""
|
||||
import io
|
||||
|
||||
from utils.file import verify_file_magic
|
||||
|
||||
|
||||
class _BrokenStream:
|
||||
"""模拟文件流读取失败(seek/read 抛异常)。"""
|
||||
|
||||
def read(self, n):
|
||||
raise OSError('模拟读取失败')
|
||||
|
||||
def seek(self, n):
|
||||
raise OSError('模拟 seek 失败')
|
||||
|
||||
|
||||
def test_verify_file_magic_fail_closed_on_read_error():
|
||||
is_valid, msg = verify_file_magic(_BrokenStream(), '.jpg')
|
||||
assert is_valid is False
|
||||
assert '校验异常' in msg
|
||||
|
||||
|
||||
def test_verify_file_magic_accepts_valid_magic():
|
||||
png_header = b'\x89PNG\r\n\x1a\n' + b'filler' * 10
|
||||
is_valid, msg = verify_file_magic(io.BytesIO(png_header), '.png')
|
||||
assert is_valid is True
|
||||
assert msg == ''
|
||||
|
||||
|
||||
def test_verify_file_magic_rejects_mismatched_content():
|
||||
# 内容与扩展名不符(JPEG 头冒充 .png)→ 拒绝
|
||||
jpeg_bytes = b'\xff\xd8\xff\xe0' + b'filler' * 10
|
||||
is_valid, msg = verify_file_magic(io.BytesIO(jpeg_bytes), '.png')
|
||||
assert is_valid is False
|
||||
assert '不匹配' in msg
|
||||
|
||||
|
||||
def test_verify_file_magic_txt_accepts_text_content():
|
||||
# 文本无固定二进制签名,可读文本(含空文件)放行
|
||||
for content in (b'hello world', b'', '你好,世界'.encode('utf-8')):
|
||||
is_valid, msg = verify_file_magic(io.BytesIO(content), '.txt')
|
||||
assert is_valid is True, content
|
||||
assert msg == ''
|
||||
|
||||
|
||||
def test_verify_file_magic_txt_rejects_binary_content():
|
||||
# 二进制内容伪装成 .txt(含 NUL 字节)→ 拒绝,防伪装文件绕过
|
||||
binary = b'\x00\x00\x00\xff' + b'filler' * 10
|
||||
is_valid, msg = verify_file_magic(io.BytesIO(binary), '.txt')
|
||||
assert is_valid is False
|
||||
assert '不匹配' in msg
|
||||
@@ -0,0 +1,39 @@
|
||||
"""utils/ip2region.py:畸形 IP 不得抛异常中断日志(内部降级返回"未知")。
|
||||
|
||||
回归场景:IP 形如 172.abc 时,int(parts[1]) 曾抛 ValueError 冒泡到
|
||||
record_login_log / record_operation_log,使日志整段丢失。
|
||||
"""
|
||||
|
||||
from utils.ip2region import _is_private_ip, get_ip_location, get_ip_region
|
||||
|
||||
|
||||
def test_is_private_ip_handles_malformed_ip():
|
||||
# 畸形 IP 不再抛 ValueError,按非内网处理(交查询侧兜底为"未知")
|
||||
assert _is_private_ip('172.abc') is False
|
||||
assert _is_private_ip('172.') is False
|
||||
assert _is_private_ip('172.a.b') is False
|
||||
|
||||
|
||||
def test_is_private_ip_private_ranges():
|
||||
assert _is_private_ip('172.16.5.5') is True
|
||||
assert _is_private_ip('172.31.5.5') is True
|
||||
assert _is_private_ip('172.32.5.5') is False # 不在 172.16/12 私有段
|
||||
assert _is_private_ip('10.1.2.3') is True
|
||||
assert _is_private_ip('192.168.1.1') is True
|
||||
assert _is_private_ip('8.8.8.8') is False
|
||||
|
||||
|
||||
def test_get_ip_location_malformed_ip_degrades_to_unknown():
|
||||
# 畸形 IP:不抛异常,降级返回"未知"(xdb 未加载时非内网 IP 也返回"未知")
|
||||
assert get_ip_location('172.abc') == '未知'
|
||||
|
||||
|
||||
def test_get_ip_region_malformed_ip_returns_unknown_dict():
|
||||
region = get_ip_region('172.abc')
|
||||
assert region['country'] == '未知'
|
||||
assert set(region) == {'country', 'province', 'city', 'isp', 'country_code'}
|
||||
|
||||
|
||||
def test_get_ip_location_private_ip_still_works():
|
||||
# 内网 IP 无需 xdb,仍正常返回"内网IP"
|
||||
assert get_ip_location('10.0.0.1') == '内网IP'
|
||||
@@ -0,0 +1,70 @@
|
||||
"""utils/ip.py 可信代理白名单:仅直连对端命中白名单才信任 X-Forwarded-For。"""
|
||||
import types
|
||||
|
||||
from utils import ip as ip_mod
|
||||
from utils.ip import get_client_ip
|
||||
|
||||
|
||||
def _make_request(client_host, xff=None):
|
||||
req = types.SimpleNamespace()
|
||||
req.client = types.SimpleNamespace(host=client_host)
|
||||
req.headers = {'X-Forwarded-For': xff} if xff else {}
|
||||
return req
|
||||
|
||||
|
||||
def test_proxy_count_zero_ignores_xff(monkeypatch):
|
||||
# TRUSTED_PROXY_COUNT=0:即使带 XFF 头也不信任,直接返回 TCP 层 IP
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 0)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'127.0.0.1'}))
|
||||
assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9'
|
||||
|
||||
|
||||
def test_proxy_count_positive_but_no_whitelist_is_fail_closed(monkeypatch):
|
||||
# 配了 TRUSTED_PROXY_COUNT 但未配 TRUSTED_PROXY_IPS → 不信任 XFF(fail-closed)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset())
|
||||
assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9'
|
||||
|
||||
|
||||
def test_peer_not_in_whitelist_rejects_xff(monkeypatch):
|
||||
# 直连对端不是可信代理(应用被直接暴露,如 Docker bridge 映射端口)
|
||||
# → 客户端伪造的 XFF 被忽略,返回真实对端 IP
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
|
||||
assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9'
|
||||
|
||||
|
||||
def test_peer_in_whitelist_trusts_xff(monkeypatch):
|
||||
# 单层代理:XFF 仅含客户端 IP(由可信代理追加),跳过最右侧 1 个代理条目即客户端
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
|
||||
assert get_client_ip(_make_request('10.0.0.1', xff='1.2.3.4')) == '1.2.3.4'
|
||||
|
||||
|
||||
def test_peer_in_whitelist_spoofed_left_entry_ignored(monkeypatch):
|
||||
# 客户端伪造条目只会出现在 XFF 左侧(由可信代理追加的在其右侧),伪造 IP 被忽略
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
|
||||
assert get_client_ip(_make_request('10.0.0.1', xff='6.6.6.6, 1.2.3.4')) == '1.2.3.4'
|
||||
|
||||
|
||||
def test_peer_in_whitelist_fewer_entries_than_proxies(monkeypatch):
|
||||
# 代理条目少于层数(部分代理未追加 XFF)→ 回退到最右侧条目,避免拿到代理 IP
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 2)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.2'}))
|
||||
assert get_client_ip(_make_request('10.0.0.2', xff='1.2.3.4')) == '1.2.3.4'
|
||||
|
||||
|
||||
def test_peer_in_whitelist_multiple_proxy_layers(monkeypatch):
|
||||
# 两层代理:XFF=[客户端, 外层代理],最内层可信代理是直连对端(不在 XFF 内),
|
||||
# 跳过最右侧 2 个代理条目取真实客户端
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 2)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.2'}))
|
||||
assert get_client_ip(_make_request('10.0.0.2', xff='1.2.3.4, 10.0.0.1')) == '1.2.3.4'
|
||||
|
||||
|
||||
def test_no_client_falls_back_to_localhost(monkeypatch):
|
||||
req = types.SimpleNamespace(client=None, headers={})
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
|
||||
assert get_client_ip(req) == '127.0.0.1'
|
||||
Reference in New Issue
Block a user