diff --git a/apps/api/app/api/routes/subscription.py b/apps/api/app/api/routes/subscription.py old mode 100644 new mode 100755 index e7a51372d..ae7944a74 --- a/apps/api/app/api/routes/subscription.py +++ b/apps/api/app/api/routes/subscription.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from dataclasses import replace from datetime import datetime, timezone from typing import List @@ -20,6 +21,8 @@ from fastapi import APIRouter, Depends, HTTPException, status from packages.ports.user_repository import UserRepository +logger = logging.getLogger(__name__) + router = APIRouter() @@ -254,7 +257,9 @@ async def payment_callback( return {"success": True, "message": "支付成功", "record_id": record_id} except Exception as e: session.rollback() - raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}") from e + logger.error(f"支付回调处理失败: user_id={user_id}, plan={plan}, error={e}") + # 不返回原始异常信息,避免泄漏内部实现细节 + raise HTTPException(status_code=500, detail="支付处理失败,请稍后重试") from e finally: session.close() diff --git a/packages/application/voice_clone/workflow.py b/packages/application/voice_clone/workflow.py index 458193609..896b43ff6 100755 --- a/packages/application/voice_clone/workflow.py +++ b/packages/application/voice_clone/workflow.py @@ -24,6 +24,7 @@ from packages.application.voice_clone.use_cases import ( ) from packages.domain.voice_clone_profile import VoiceCloneProfile from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository +from packages.shared.url_security import UrlSecurityError, validate_url_safety logger = logging.getLogger(__name__) @@ -103,6 +104,15 @@ class VoiceCloneWorkflowService: # 2. 提交 CosyVoice 克隆任务(仅有音频 URL 时才标记 processing) if source_audio_url: + # SSRF 防护:校验音频 URL 安全性 + try: + source_audio_url = validate_url_safety(source_audio_url, purpose="download") + except UrlSecurityError as e: + profile.mark_failed(f"音频URL安全校验失败: {e}") + profile = self.repository.update(profile) + logger.warning(f"音色克隆音频URL安全校验失败: profile_id={profile.id}, error={e}") + return profile + # 标记为 processing profile.mark_processing() profile = self.repository.update(profile) @@ -243,6 +253,15 @@ class VoiceCloneWorkflowService: # 3. 重新提交 CosyVoice if profile.source_audio_url: + # SSRF 防护:重新校验音频 URL 安全性 + try: + validate_url_safety(profile.source_audio_url, purpose="download") + except UrlSecurityError as e: + profile.mark_failed(f"音频URL安全校验失败: {e}") + profile = self.repository.update(profile) + logger.warning(f"音色克隆重试音频URL安全校验失败: profile_id={clone_id}, error={e}") + return profile + try: submit_result = self.cosyvoice_service.submit_clone_task( audio_url=profile.source_audio_url, diff --git a/tests/unit/test_voice_clone_workflow.py b/tests/unit/test_voice_clone_workflow.py old mode 100644 new mode 100755 index cd9b67833..7820e1adc --- a/tests/unit/test_voice_clone_workflow.py +++ b/tests/unit/test_voice_clone_workflow.py @@ -169,6 +169,69 @@ class TestStartClone: assert profile.status == VoiceCloneStatus.PENDING mock_cosyvoice.submit_clone_task.assert_not_called() + def test_start_clone_ssrf_internal_url_rejected(self) -> None: + """SSRF 防护:内网 URL 应该被拒绝,profile 标记为 failed。""" + mock_repo = MagicMock() + mock_cosyvoice = MagicMock(spec=CosyVoiceService) + + mock_repo.create.side_effect = lambda p: p + mock_repo.update.side_effect = lambda p: p + + service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) + profile = service.start_clone( + user_id="user-123", + name="测试音色", + source_audio_url="http://127.0.0.1/audio.wav", + ) + + # 内网 IP 应该被拒绝,标记为 failed + assert profile.status == VoiceCloneStatus.FAILED + assert "安全校验失败" in profile.error_message + mock_cosyvoice.submit_clone_task.assert_not_called() + + def test_start_clone_ssrf_private_ip_rejected(self) -> None: + """SSRF 防护:私有网段 IP 应该被拒绝,profile 标记为 failed。""" + mock_repo = MagicMock() + mock_cosyvoice = MagicMock(spec=CosyVoiceService) + + mock_repo.create.side_effect = lambda p: p + mock_repo.update.side_effect = lambda p: p + + service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) + profile = service.start_clone( + user_id="user-123", + name="测试音色", + source_audio_url="http://192.168.1.100/audio.wav", + ) + + assert profile.status == VoiceCloneStatus.FAILED + assert "安全校验失败" in profile.error_message + mock_cosyvoice.submit_clone_task.assert_not_called() + + def test_start_clone_ssrf_public_url_passes(self) -> None: + """SSRF 防护:正常公网 URL 应该通过校验。""" + mock_repo = MagicMock() + mock_cosyvoice = MagicMock(spec=CosyVoiceService) + + mock_cosyvoice.submit_clone_task.return_value = { + "voice_id": "voice-ssrf-test", + "status": "DEPLOYING", + "request_id": "req-ssrf", + } + mock_repo.create.side_effect = lambda p: p + mock_repo.update.side_effect = lambda p: p + + service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) + profile = service.start_clone( + user_id="user-123", + name="测试音色", + source_audio_url="https://example.com/audio.wav", + ) + + # 公网 URL 应该正常通过 + assert profile.status == VoiceCloneStatus.PROCESSING + mock_cosyvoice.submit_clone_task.assert_called_once() + # ── process_clone_result ───────────────────────────────── @@ -313,6 +376,27 @@ class TestRetryClone: assert result.status == VoiceCloneStatus.FAILED assert "重试失败" in result.error_message + def test_retry_clone_ssrf_internal_url_rejected(self) -> None: + """重试时 SSRF 防护:内网 URL 应该被拒绝,profile 标记为 failed。""" + mock_repo = MagicMock() + mock_cosyvoice = MagicMock(spec=CosyVoiceService) + + profile = _make_profile( + status=VoiceCloneStatus.FAILED, + source_audio_url="http://10.0.0.1/secret.wav", + retry_count=0, + max_retries=3, + ) + mock_repo.get.return_value = profile + mock_repo.update.side_effect = lambda p: p + + service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) + result = service.retry_clone(profile.id, "user-123") + + assert result.status == VoiceCloneStatus.FAILED + assert "安全校验失败" in result.error_message + mock_cosyvoice.submit_clone_task.assert_not_called() + # ── poll_and_process_clone ───────────────────────────────