Files
xiaoxia-saas/tests/unit/test_pr312_security_debt.py
xiaoxia 7e172c0907
CI/CD Pipeline / Unit Tests (push) Successful in 1m33s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m25s
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
fix: PR#312安全债务 4个P1修复(路径安全+数量上限) (#322)
2026-07-14 17:43:57 +08:00

485 lines
18 KiB
Python

"""PR #312 安全债务修复 单元测试.
测试4个P1安全修复:
1. 多轨道混音:audio_path 路径安全 + 轨道数量上限
2. 视频拼接:video_path 路径安全 + 段数上限
3. 字幕渲染:字幕文件路径白名单校验
"""
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import pytest
from video_processing.path_security import PathSecurityError
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def work_dir(tmp_path):
return tmp_path
@pytest.fixture
def sample_audio(work_dir):
"""生成一个测试音频文件."""
import subprocess
path = work_dir / "test.aac"
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=1:sample_rate=44100",
"-c:a",
"aac",
"-b:a",
"128k",
str(path),
],
capture_output=True,
check=True,
timeout=30,
)
return path
@pytest.fixture
def sample_video(work_dir):
"""生成一个测试视频文件."""
import subprocess
path = work_dir / "test.mp4"
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"testsrc=duration=1:size=320x240:rate=30",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=1:sample_rate=44100",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
str(path),
],
capture_output=True,
check=True,
timeout=60,
)
return path
# ══════════════════════════════════════════════════════════════════════════════
# 1. 多轨道混音安全测试
# ══════════════════════════════════════════════════════════════════════════════
class TestMultiTrackSecurity:
"""多轨道混音安全测试."""
def test_track_count_limit_exceeded(self, work_dir, sample_audio):
"""超过最大轨道数时应截断到上限."""
from unittest.mock import MagicMock, patch
from video_processing.multi_track_mixer import (
MAX_AUDIO_TRACKS,
AudioTrack,
MultiTrackMixConfig,
mix_multi_track,
)
# 创建超过上限的轨道数
tracks = []
for i in range(MAX_AUDIO_TRACKS + 5):
tracks.append(
AudioTrack(
track_id=f"track_{i}",
track_type="sfx",
audio_path=str(sample_audio),
volume=0.5,
)
)
config = MultiTrackMixConfig(tracks=tracks)
ctx = MagicMock()
ctx.work_dir = work_dir
ctx.plan_id = "test_plan"
# mock _prepare_single_track 避免实际跑ffmpeg
with patch("video_processing.multi_track_mixer._prepare_single_track", return_value=True):
with patch("video_processing.multi_track_mixer.run_ffmpeg"):
import shutil
with patch("shutil.copy2"):
result = mix_multi_track(ctx, sample_audio, config, 10.0)
# 验证轨道被截断到上限
assert len(config.tracks) == MAX_AUDIO_TRACKS
assert result is not None
def test_track_count_within_limit(self, work_dir, sample_audio):
"""轨道数在限制内时正常处理."""
from unittest.mock import MagicMock, patch
from video_processing.multi_track_mixer import (
MAX_AUDIO_TRACKS,
AudioTrack,
MultiTrackMixConfig,
mix_multi_track,
)
tracks = []
for i in range(3):
tracks.append(
AudioTrack(
track_id=f"track_{i}",
track_type="sfx",
audio_path=str(sample_audio),
volume=0.5,
)
)
config = MultiTrackMixConfig(tracks=tracks)
ctx = MagicMock()
ctx.work_dir = work_dir
ctx.plan_id = "test_plan"
with patch("video_processing.multi_track_mixer._prepare_single_track", return_value=True):
with patch("video_processing.multi_track_mixer.run_ffmpeg"):
result = mix_multi_track(ctx, sample_audio, config, 10.0)
assert len(config.tracks) == 3
assert result is not None
def test_audio_path_traversal_attack(self, work_dir, sample_audio):
"""路径遍历攻击应被拦截."""
from video_processing.multi_track_mixer import _validate_audio_path
# 路径遍历
with pytest.raises(PathSecurityError):
_validate_audio_path("../../../etc/passwd", work_dir)
# local:// 路径遍历
with pytest.raises(PathSecurityError):
_validate_audio_path("local://../../../etc/passwd", work_dir)
def test_audio_path_allowed_extension(self, work_dir, sample_audio):
"""允许的音频扩展名应通过校验."""
from video_processing.multi_track_mixer import _validate_audio_path
# 在work_dir内的音频文件
test_file = work_dir / "test.mp3"
test_file.touch()
_validate_audio_path(str(test_file), work_dir) # 不应抛异常
test_file2 = work_dir / "test.wav"
test_file2.touch()
_validate_audio_path(str(test_file2), work_dir) # 不应抛异常
def test_audio_path_disallowed_extension(self, work_dir):
"""不允许的文件扩展名应被拦截."""
from video_processing.multi_track_mixer import _validate_audio_path
test_file = work_dir / "test.exe"
test_file.touch()
with pytest.raises(PathSecurityError):
_validate_audio_path(str(test_file), work_dir)
test_file2 = work_dir / "test.php"
test_file2.touch()
with pytest.raises(PathSecurityError):
_validate_audio_path(str(test_file2), work_dir)
def test_audio_path_empty(self, work_dir):
"""空路径应被拦截."""
from video_processing.multi_track_mixer import _validate_audio_path
with pytest.raises(PathSecurityError):
_validate_audio_path("", work_dir)
with pytest.raises(PathSecurityError):
_validate_audio_path(None, work_dir)
def test_audio_path_traversal_bypass_startswith(self, work_dir):
"""【P1绕过】用../构造伪work_dir前缀路径,真实路径逃逸,必须被拦截.
漏洞:旧代码用 startswith(str(work_dir)) 比原始字符串,
/tmp/work/../../opt/secret.aac 会通过 startswith 检查,跳过白名单校验。
修复:用 realpath 规范化后再比较。
"""
from video_processing.multi_track_mixer import _validate_audio_path
evil_path = str(work_dir / "../../../../opt/secret.aac")
with pytest.raises(PathSecurityError, match="不在允许目录"):
_validate_audio_path(evil_path, work_dir)
# ══════════════════════════════════════════════════════════════════════════════
# 2. 视频拼接安全测试
# ══════════════════════════════════════════════════════════════════════════════
class TestConcatSecurity:
"""视频拼接安全测试."""
def test_segment_count_limit_exceeded(self, work_dir, sample_video):
"""超过最大段数时应报错."""
from video_processing.concat_engine import (
MAX_CONCAT_SEGMENTS,
ConcatConfig,
ConcatEngine,
ConcatSegment,
)
# 创建超过上限的段数
segments = []
for i in range(MAX_CONCAT_SEGMENTS + 5):
segments.append(ConcatSegment(video_path=str(sample_video)))
config = ConcatConfig(segments=segments)
engine = ConcatEngine(work_dir)
output_path = work_dir / "output.mp4"
with pytest.raises(ValueError, match="Too many concat segments"):
engine.concat_videos(config, output_path)
def test_segment_count_within_limit(self, work_dir, sample_video):
"""段数在限制内时正常处理."""
from unittest.mock import patch
from video_processing.concat_engine import (
MAX_CONCAT_SEGMENTS,
ConcatConfig,
ConcatEngine,
ConcatSegment,
)
segments = [
ConcatSegment(video_path=str(sample_video)),
ConcatSegment(video_path=str(sample_video)),
ConcatSegment(video_path=str(sample_video)),
]
config = ConcatConfig(segments=segments)
engine = ConcatEngine(work_dir)
output_path = work_dir / "output.mp4"
# mock ffmpeg执行
with patch.object(engine, "_concat_filter", return_value=output_path):
with patch.object(engine, "_can_use_stream_copy", return_value=False):
result = engine.concat_videos(config, output_path)
assert result == output_path
def test_video_path_traversal_attack(self, work_dir):
"""路径遍历攻击应被拦截."""
from video_processing.concat_engine import _validate_video_path
with pytest.raises(PathSecurityError):
_validate_video_path("../../../etc/passwd", work_dir)
with pytest.raises(PathSecurityError):
_validate_video_path("local://../../../etc/passwd", work_dir)
def test_video_path_allowed_extension(self, work_dir):
"""允许的视频扩展名应通过校验."""
from video_processing.concat_engine import _validate_video_path
for ext in [".mp4", ".mov", ".avi", ".mkv", ".webm"]:
test_file = work_dir / f"test{ext}"
test_file.touch()
_validate_video_path(str(test_file), work_dir) # 不应抛异常
def test_video_path_disallowed_extension(self, work_dir):
"""不允许的文件扩展名应被拦截."""
from video_processing.concat_engine import _validate_video_path
test_file = work_dir / "test.exe"
test_file.touch()
with pytest.raises(PathSecurityError):
_validate_video_path(str(test_file), work_dir)
test_file2 = work_dir / "test.js"
test_file2.touch()
with pytest.raises(PathSecurityError):
_validate_video_path(str(test_file2), work_dir)
def test_video_path_empty(self, work_dir):
"""空路径应被拦截."""
from video_processing.concat_engine import _validate_video_path
with pytest.raises(PathSecurityError):
_validate_video_path("", work_dir)
with pytest.raises(PathSecurityError):
_validate_video_path(None, work_dir)
def test_video_path_traversal_bypass_startswith(self, work_dir):
"""【P1绕过】视频路径../遍历绕过startswith检查,必须被拦截.
漏洞:旧代码用 startswith(str(work_dir)) 比原始字符串,
/tmp/work/../../opt/secret.mp4 会通过 startswith 检查,跳过白名单校验。
修复:用 realpath 规范化后再比较。
"""
from video_processing.concat_engine import _validate_video_path
evil_path = str(work_dir / "../../../../opt/secret.mp4")
with pytest.raises(PathSecurityError, match="不在允许目录"):
_validate_video_path(evil_path, work_dir)
def test_invalid_segments_skipped(self, work_dir, sample_video):
"""路径不安全的片段应被跳过."""
from unittest.mock import patch
from video_processing.concat_engine import (
ConcatConfig,
ConcatEngine,
ConcatSegment,
)
segments = [
ConcatSegment(video_path=str(sample_video)),
ConcatSegment(video_path="../../../etc/passwd"), # 不安全路径
ConcatSegment(video_path=str(sample_video)),
]
config = ConcatConfig(segments=segments)
engine = ConcatEngine(work_dir)
output_path = work_dir / "output.mp4"
with patch.object(engine, "_concat_filter", return_value=output_path):
with patch.object(engine, "_can_use_stream_copy", return_value=False):
result = engine.concat_videos(config, output_path)
# 验证只有2个安全片段保留
assert len(config.segments) == 2
assert result == output_path
# ══════════════════════════════════════════════════════════════════════════════
# 3. 字幕渲染安全测试
# ══════════════════════════════════════════════════════════════════════════════
class TestSubtitleSecurity:
"""字幕渲染安全测试."""
def test_subtitle_path_traversal_attack(self, work_dir):
"""路径遍历攻击应被拦截."""
from video_processing.subtitle_render_engine import _validate_subtitle_path
with pytest.raises(PathSecurityError):
_validate_subtitle_path("../../../etc/passwd", work_dir)
with pytest.raises(PathSecurityError):
_validate_subtitle_path("local://../../../etc/shadow", work_dir)
def test_subtitle_path_allowed_extension(self, work_dir):
"""允许的字幕扩展名应通过校验."""
from video_processing.subtitle_render_engine import _validate_subtitle_path
for ext in [".srt", ".ass", ".vtt", ".sub"]:
test_file = work_dir / f"test{ext}"
test_file.touch()
_validate_subtitle_path(str(test_file), work_dir) # 不应抛异常
def test_subtitle_path_disallowed_extension(self, work_dir):
"""不允许的文件扩展名应被拦截."""
from video_processing.subtitle_render_engine import _validate_subtitle_path
test_file = work_dir / "test.exe"
test_file.touch()
with pytest.raises(PathSecurityError):
_validate_subtitle_path(str(test_file), work_dir)
test_file2 = work_dir / "test.mp4"
test_file2.touch()
with pytest.raises(PathSecurityError):
_validate_subtitle_path(str(test_file2), work_dir)
def test_subtitle_remote_url_blocked(self, work_dir):
"""远程URL字幕应被拦截."""
from video_processing.subtitle_render_engine import _validate_subtitle_path
with pytest.raises(PathSecurityError, match="远程URL"):
_validate_subtitle_path("http://evil.com/evil.ass", work_dir)
with pytest.raises(PathSecurityError, match="远程URL"):
_validate_subtitle_path("https://evil.com/evil.srt", work_dir)
def test_subtitle_path_empty(self, work_dir):
"""空路径应被拦截."""
from video_processing.subtitle_render_engine import _validate_subtitle_path
with pytest.raises(PathSecurityError):
_validate_subtitle_path("", work_dir)
with pytest.raises(PathSecurityError):
_validate_subtitle_path(None, work_dir)
def test_build_filter_with_safe_path(self, work_dir):
"""安全路径应正常生成滤镜字符串."""
from video_processing.subtitle_render_engine import build_subtitle_filter
ass_file = work_dir / "subtitle.ass"
ass_file.write_text("test", encoding="utf-8")
result = build_subtitle_filter(ass_file, work_dir=work_dir)
assert "subtitles=" in result
assert "subtitle.ass" in result
assert "[subtitled]" in result
def test_build_filter_with_unsafe_path_raises(self, work_dir):
"""不安全路径应抛出异常."""
from video_processing.subtitle_render_engine import build_subtitle_filter
with pytest.raises(PathSecurityError):
build_subtitle_filter("../../../etc/passwd", work_dir=work_dir)
def test_build_filter_work_dir_required(self, work_dir):
"""不传work_dir时必须报错(防止自证清白绕过)."""
from video_processing.subtitle_render_engine import build_subtitle_filter
ass_file = work_dir / "sub.ass"
ass_file.write_text("test", encoding="utf-8")
# 不传 work_dir 必须报错
with pytest.raises(PathSecurityError, match="work_dir"):
build_subtitle_filter(ass_file) # type: ignore[call-arg]
# 传 None 也必须报错
with pytest.raises(PathSecurityError, match="work_dir"):
build_subtitle_filter(ass_file, work_dir=None) # type: ignore[arg-type]
# 传空字符串也必须报错
with pytest.raises(PathSecurityError, match="work_dir"):
build_subtitle_filter(ass_file, work_dir="")
def test_subtitle_path_traversal_bypass_startswith(self, work_dir):
"""【P1绕过】字幕路径../遍历绕过startswith检查,必须被拦截."""
from video_processing.subtitle_render_engine import _validate_subtitle_path
evil_path = str(work_dir / "../../../../opt/secret.srt")
with pytest.raises(PathSecurityError, match="不在允许目录"):
_validate_subtitle_path(evil_path, work_dir)