From 31804b9a08ace16bed801c68e75b963eb228c81c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:52:49 +0000 Subject: [PATCH 1/9] fix(security): sanitize all HTTP 500 responses to prevent info disclosure (supersedes #801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple FastAPI handlers returned internal exception text to clients via `HTTPException(status_code=500, detail=str(e))` (or f-strings embedding `{e}`), leaking stack-adjacent messages, backend API errors, and database/Looker errors. This is an information-disclosure vector (CWE-209). #801 sanitized ~24 handlers but left three live endpoints leaking: `generate_video_pack` and `generate_blueprint` (v1/router.py) and the mounted `reporting_routes.py` dashboard endpoint — the exact gaps Copilot flagged on that PR. A tree-wide scan surfaced 13 further leaks in cloud_ai_routes.py, cloud_api_endpoints.py, and real_api_endpoints.py that #801 never touched. Changes: - Replace the dynamic `detail` in every 500 response across the backend with a static "Internal server error"; the full exception is now logged server-side (`logger.error(..., exc_info=True)`) so diagnostics are preserved. - Add reporting_routes.py a module logger (previously none). - Add tests/unit/test_500_info_disclosure.py: a hermetic source-scan guard that fails if any backend 500 response uses a dynamic `detail`, closing the test-coverage gap (existing exception-path tests asserted only status code, so they passed while the body leaked). Includes a self-check that the scanner detects a synthetic leak and ignores 4xx responses. 4xx responses (which echo client-supplied validation input) are intentionally left unchanged. Verified: all touched files compile; ruff findings are identical to the base branch (lint-neutral); guard test passes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018MF2xHBKyQBQtpdXt6bVRx --- .../backend/api/reporting_routes.py | 7 +- .../backend/api/v1/router.py | 44 ++++----- .../backend/cloud_ai_routes.py | 10 +- .../backend/cloud_api_endpoints.py | 9 +- src/youtube_extension/backend/main.py | 8 +- .../backend/real_api_endpoints.py | 15 ++- tests/unit/test_500_info_disclosure.py | 94 +++++++++++++++++++ 7 files changed, 147 insertions(+), 40 deletions(-) create mode 100644 tests/unit/test_500_info_disclosure.py diff --git a/src/youtube_extension/backend/api/reporting_routes.py b/src/youtube_extension/backend/api/reporting_routes.py index 041b1be06..dba800a07 100644 --- a/src/youtube_extension/backend/api/reporting_routes.py +++ b/src/youtube_extension/backend/api/reporting_routes.py @@ -1,9 +1,13 @@ +import logging + from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from typing import Optional from src.integration.looker_embedded import LookerEmbeddedService +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/v1/reporting", tags=["Reporting & Dashboards"]) class DashboardEmbedRequest(BaseModel): @@ -36,7 +40,8 @@ async def generate_dashboard_url( ) return DashboardEmbedResponse(embed_url=url) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to generate dashboard embed URL: {str(e)}") + logger.error(f"Unhandled error in generate_dashboard_url: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") @router.get("/dashboards") async def list_available_dashboards(tenant_id: str = Query(...)): diff --git a/src/youtube_extension/backend/api/v1/router.py b/src/youtube_extension/backend/api/v1/router.py index 7d33ffb63..275eadc63 100644 --- a/src/youtube_extension/backend/api/v1/router.py +++ b/src/youtube_extension/backend/api/v1/router.py @@ -249,7 +249,7 @@ async def health_check_v1( return HealthResponse(**health_status) except Exception as e: logger.error(f"Health check failed: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") @router.get( @@ -278,7 +278,7 @@ async def detailed_health_check_v1( } except Exception as e: logger.error(f"Detailed health check failed: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") # Capabilities / Model availability @@ -657,7 +657,7 @@ async def chat_v1( except Exception as e: logger.error(f"Error in chat endpoint: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) from e + raise HTTPException(status_code=500, detail="Internal server error") from e # Video Processing Endpoints @@ -716,7 +716,7 @@ async def process_video_v1( {"url": request.video_url, "error": str(e)}, request.video_url, ) - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") @router.post( @@ -762,7 +762,7 @@ async def process_video_markdown_v1( except Exception as e: logger.error(f"Error in markdown processing: {e}") health_service.increment_metric("error_total") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") @router.post( @@ -799,7 +799,7 @@ async def video_to_software_v1( except Exception as e: logger.error(f"Video-to-software processing failed: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") # Cache Management Endpoints @@ -823,7 +823,7 @@ async def get_cache_stats_v1(cache_service: CacheService = Depends(get_cache_ser return CacheStats(**stats) except Exception as e: logger.error(f"Error getting cache stats: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") @router.get( @@ -852,7 +852,7 @@ async def get_cached_video_v1( raise except Exception as e: logger.error(f"Error retrieving cached video: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") @router.delete( @@ -875,7 +875,7 @@ async def clear_video_cache_v1( } except Exception as e: logger.error(f"Error clearing video cache: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") @router.delete( @@ -895,7 +895,7 @@ async def clear_all_cache_v1(cache_service: CacheService = Depends(get_cache_ser } except Exception as e: logger.error(f"Error clearing all cache: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") # Data Endpoints @@ -933,7 +933,7 @@ async def list_videos_v1( except Exception as e: logger.error(f"Error listing videos: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") @router.get( @@ -957,7 +957,7 @@ async def get_video_detail_v1( raise except Exception as e: logger.error(f"Error getting video detail: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") @router.get( @@ -973,7 +973,7 @@ async def get_learning_log_v1(data_service: DataService = Depends(get_data_servi return learning_log except Exception as e: logger.error(f"Error getting learning log: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") @router.post( @@ -1024,7 +1024,7 @@ async def get_actions_by_video_v1(video_id: str): return actions except Exception as e: logger.error(f"Error retrieving actions for {video_id}: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") @router.put( @@ -1072,7 +1072,7 @@ async def update_action_v1(action_id: str, payload: dict[str, Any]): return {"success": bool(success)} except Exception as e: logger.error(f"Error updating action {action_id}: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") # Feedback Endpoints @@ -1119,7 +1119,7 @@ async def submit_feedback_v1( except Exception as e: logger.error(f"Error saving feedback: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") # Metrics Endpoints @@ -1139,7 +1139,7 @@ async def get_metrics_v1( return JSONResponse(content="\n".join(metrics_lines), media_type="text/plain") except Exception as e: logger.error(f"Metrics endpoint failed: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") # Frontend performance ingestion endpoints @@ -1159,7 +1159,7 @@ async def ingest_performance_alert_v1(payload: dict[str, Any]): return {"status": "ok", "recorded": metric_name} except Exception as e: logger.error(f"Failed to ingest performance alert: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") @router.post("/performance/report", summary="Ingest frontend performance report") @@ -1177,7 +1177,7 @@ async def ingest_performance_report_v1(report: dict[str, Any]): return {"status": "ok", "metrics_recorded": len(metrics)} except Exception as e: logger.error(f"Failed to ingest performance report: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") # ============================================================ @@ -1710,7 +1710,7 @@ async def get_or_create_videopack(request: VideoPackRequest): return ApiResponse.success(pack.model_dump()) except Exception as e: logger.error(f"Failed to create VideoPack: {e}") - raise HTTPException(status_code=500, detail=f"VideoPack generation failed: {e}") + raise HTTPException(status_code=500, detail="Internal server error") @router.post( @@ -1759,7 +1759,7 @@ async def generate_blueprint(request: BlueprintRequest): return ApiResponse.success(blueprint) except Exception as e: logger.error(f"Failed to generate blueprint: {e}") - raise HTTPException(status_code=500, detail=f"Blueprint generation failed: {e}") + raise HTTPException(status_code=500, detail="Internal server error") @router.post( @@ -1785,7 +1785,7 @@ async def generate_project_code(request: GenerateCodeRequest): return ApiResponse.success(result) except Exception as e: logger.error(f"Code generation failed: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") @router.get( diff --git a/src/youtube_extension/backend/cloud_ai_routes.py b/src/youtube_extension/backend/cloud_ai_routes.py index a1242a418..0172e1617 100644 --- a/src/youtube_extension/backend/cloud_ai_routes.py +++ b/src/youtube_extension/backend/cloud_ai_routes.py @@ -229,7 +229,7 @@ async def get_provider_status(): except Exception as e: logger.error(f"Failed to get provider status: {e}") - raise HTTPException(status_code=500, detail=f"Failed to get provider status: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error") @router.post("/analyze/video", response_model=VideoAnalysisResponse) @@ -263,12 +263,12 @@ async def analyze_video(request: VideoAnalysisRequest): raise HTTPException(status_code=429, detail=f"Rate limit exceeded: {str(e)}") except ConfigurationError as e: logger.error(f"Configuration error: {e}") - raise HTTPException(status_code=500, detail=f"Configuration error: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error") except HTTPException: raise except Exception as e: logger.error(f"Unexpected error during video analysis: {e}") - raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error") @router.post("/analyze/batch") @@ -301,7 +301,7 @@ async def analyze_batch_videos(request: BatchAnalysisRequest, background_tasks: raise except Exception as e: logger.error(f"Failed to start batch analysis: {e}") - raise HTTPException(status_code=500, detail=f"Batch analysis failed: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error") @router.post("/analyze/multi-provider", response_model=list[VideoAnalysisResponse]) @@ -325,7 +325,7 @@ async def analyze_video_multi_provider(request: VideoAnalysisRequest): except Exception as e: logger.error(f"Multi-provider analysis failed: {e}") - raise HTTPException(status_code=500, detail=f"Multi-provider analysis failed: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error") @router.get("/analysis-types") diff --git a/src/youtube_extension/backend/cloud_api_endpoints.py b/src/youtube_extension/backend/cloud_api_endpoints.py index e38655f99..165bc76c9 100644 --- a/src/youtube_extension/backend/cloud_api_endpoints.py +++ b/src/youtube_extension/backend/cloud_api_endpoints.py @@ -260,9 +260,10 @@ async def batch_process_videos_cloud(request: BatchCloudProcessingRequest): except HTTPException: raise except Exception as e: + logger.error(f"Unhandled error in batch_process_videos_cloud: {e}", exc_info=True) raise HTTPException( status_code=500, - detail=f"Batch processing failed: {str(e)}" + detail="Internal server error" ) @router.get("/api/v3/videos/{video_id}/status", response_model=VideoStatusResponse) @@ -293,9 +294,10 @@ async def get_video_status(video_id: str): except HTTPException: raise except Exception as e: + logger.error(f"Unhandled error in get_video_status: {e}", exc_info=True) raise HTTPException( status_code=500, - detail=f"Error retrieving status: {str(e)}" + detail="Internal server error" ) @router.get("/api/v3/videos/{video_id}/result") @@ -330,9 +332,10 @@ async def get_video_result(video_id: str): except HTTPException: raise except Exception as e: + logger.error(f"Unhandled error in get_video_result: {e}", exc_info=True) raise HTTPException( status_code=500, - detail=f"Error retrieving result: {str(e)}" + detail="Internal server error" ) @router.get("/api/v3/queue/stats") diff --git a/src/youtube_extension/backend/main.py b/src/youtube_extension/backend/main.py index 0e52a9341..e0ffa5c3e 100644 --- a/src/youtube_extension/backend/main.py +++ b/src/youtube_extension/backend/main.py @@ -206,7 +206,7 @@ async def legacy_chat(request: dict): except Exception as e: logger.error(f"Legacy chat endpoint error: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") @app.post("/api/process-video-markdown") @@ -240,7 +240,7 @@ async def legacy_process_video_markdown(request: dict): raise except Exception as e: logger.error(f"Legacy markdown processing error: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") @app.post("/api/process-video") @@ -297,7 +297,7 @@ async def legacy_process_video(request: dict): except Exception as e: logger.error(f"Legacy video processing error: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") # Other legacy endpoints with redirects @@ -368,7 +368,7 @@ async def system_info(): except Exception as e: logger.error(f"System info error: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Internal server error") # Enhanced OpenAPI schema generation diff --git a/src/youtube_extension/backend/real_api_endpoints.py b/src/youtube_extension/backend/real_api_endpoints.py index 03fd9e4e6..1b0433cfc 100644 --- a/src/youtube_extension/backend/real_api_endpoints.py +++ b/src/youtube_extension/backend/real_api_endpoints.py @@ -150,9 +150,10 @@ async def validate_video_url(request: VideoValidationRequest): } except Exception as e: + logger.error(f"Unhandled error in validate_video_url: {e}", exc_info=True) raise HTTPException( status_code=500, - detail=f"Validation failed: {str(e)}" + detail="Internal server error" ) @app.post("/api/v2/batch-process") @@ -177,9 +178,10 @@ async def batch_process_videos(request: BatchProcessingRequest): return result except Exception as e: + logger.error(f"Unhandled error in batch_process_videos: {e}", exc_info=True) raise HTTPException( status_code=500, - detail=f"Batch processing failed: {str(e)}" + detail="Internal server error" ) @app.get("/api/v2/videos/list") @@ -252,9 +254,10 @@ async def get_video_analysis(video_id: str): except HTTPException: raise except Exception as e: + logger.error(f"Unhandled error in get_video_analysis: {e}", exc_info=True) raise HTTPException( status_code=500, - detail=f"Error retrieving video analysis: {str(e)}" + detail="Internal server error" ) @app.get("/api/v2/cost-dashboard") @@ -384,9 +387,10 @@ async def clear_processing_cache(): } except Exception as e: + logger.error(f"Unhandled error in clear_processing_cache: {e}", exc_info=True) raise HTTPException( status_code=500, - detail=f"Failed to clear cache: {str(e)}" + detail="Internal server error" ) @app.post("/api/v2/search-videos") @@ -432,9 +436,10 @@ async def search_youtube_videos( } except Exception as e: + logger.error(f"Unhandled error in search_youtube_videos: {e}", exc_info=True) raise HTTPException( status_code=500, - detail=f"Search failed: {str(e)}" + detail="Internal server error" ) logger.info("🚀 Real API endpoints setup complete") diff --git a/tests/unit/test_500_info_disclosure.py b/tests/unit/test_500_info_disclosure.py new file mode 100644 index 000000000..d561c422e --- /dev/null +++ b/tests/unit/test_500_info_disclosure.py @@ -0,0 +1,94 @@ +"""Regression guard against information disclosure in HTTP 500 responses. + +Context: several FastAPI handlers historically raised +``HTTPException(status_code=500, detail=str(e))`` (or an f-string embedding the +exception), leaking internal exception text — stack-adjacent messages, backend +API errors, database errors — to clients. See PR #801, which sanitized most but +not all handlers. + +This test encodes the invariant directly on the source: a 500 response must use +a *static* ``detail`` string, never one derived from the caught exception. It is +hermetic (pure source scan, no app import / no pydantic) so it runs anywhere and +catches new leaks in any backend route, not just the ones fixed today. + +It deliberately does not constrain 4xx responses: those echo client-supplied +validation errors, which are not internal-disclosure vectors. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "youtube_extension" / "backend" + +# Match a single `raise HTTPException(...)` call, capturing its argument list, +# tolerant of the call spanning multiple lines. +_HTTP_EXC = re.compile(r"HTTPException\((?P.*?)\)", re.DOTALL) + +# A `detail=` argument whose value is derived from a variable/exception: +# detail=str(e) detail=str(exc) +# detail=f"... {e} ..." detail=f"... {str(e)} ..." +_DYNAMIC_DETAIL = re.compile( + r"""detail\s*=\s*(?: + str\( # detail=str(...) + | f["'][^"']*\{ # detail=f"...{...}..." + )""", + re.VERBOSE, +) + + +def _backend_python_files() -> list[Path]: + return sorted(_BACKEND.rglob("*.py")) + + +def _iter_500_dynamic_detail(text: str): + """Yield (line_no, snippet) for each 500 HTTPException with a dynamic detail.""" + for m in _HTTP_EXC.finditer(text): + args = m.group("args") + if "status_code=500" not in args.replace(" ", "").replace( + "status_code =", "status_code=" + ): + # normalize minor spacing; only care about 500 responses + if "status_code=500" not in re.sub(r"\s+", "", args): + continue + if _DYNAMIC_DETAIL.search(args): + line_no = text.count("\n", 0, m.start()) + 1 + yield line_no, " ".join(args.split())[:120] + + +def test_no_dynamic_detail_in_500_responses() -> None: + offenders: list[str] = [] + for path in _backend_python_files(): + text = path.read_text(encoding="utf-8") + for line_no, snippet in _iter_500_dynamic_detail(text): + rel = path.relative_to(_BACKEND.parents[2]) + offenders.append(f"{rel}:{line_no}: HTTPException({snippet})") + + assert not offenders, ( + "HTTP 500 responses must use a static `detail` string (e.g. " + '"Internal server error") and never leak the caught exception. ' + "Log the full error server-side instead. Offending sites:\n " + + "\n ".join(offenders) + ) + + +def test_guard_detects_a_synthetic_leak() -> None: + """Sanity check: the scanner actually flags a dynamic 500 detail.""" + leaky = "raise HTTPException(status_code=500, detail=str(e))" + safe = 'raise HTTPException(status_code=500, detail="Internal server error")' + client_err = "raise HTTPException(status_code=400, detail=str(exc))" + + assert list(_iter_500_dynamic_detail(leaky)), "scanner missed a real 500 leak" + assert not list( + _iter_500_dynamic_detail(safe) + ), "scanner false-positived a static detail" + assert not list( + _iter_500_dynamic_detail(client_err) + ), "scanner must ignore 4xx responses" + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(pytest.main([__file__, "-v"])) From f25030c8715ae1a8e1c4da114a82ea009e7b04d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 02:10:00 +0000 Subject: [PATCH 2/9] fix(security): close remaining dict/variable 500 info-disclosure leaks in cloud/real routers Follow-up on the merge resolution (4c9b205): three 500 handlers still returned internal exception text, flagged in the Copilot review but not yet fixed: - cloud_api_endpoints.py: process_video_cloud returned a dict detail whose "message" embedded f"Cloud processing failed: {str(e)}"; process_video_task_handler returned detail=error_msg (same interpolation). - real_api_endpoints.py: process_video_real_api returned a dict detail embedding f"Real API processing failed: {str(e)}". All three now return a static detail="Internal server error". error_msg is still built and logged server-side via logger.error, so diagnostics are preserved; only the client-facing body is sanitized. No test asserted the leaked dict/message. Remaining, still-open items (already noted by the Copilot review, deferred as separate semantics-touching changes): the JSONResponse global_exception_handler in backend/main.py, the 503/429 handlers in cloud_ai_routes.py (exception ordering), positional HTTPException(500, str(e)) sites in api/advanced_video_routes.py, and upgrading the regression guard to AST so it covers those forms. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018MF2xHBKyQBQtpdXt6bVRx --- .../backend/cloud_api_endpoints.py | 14 ++++---------- .../backend/real_api_endpoints.py | 11 ++--------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/src/youtube_extension/backend/cloud_api_endpoints.py b/src/youtube_extension/backend/cloud_api_endpoints.py index 165bc76c9..3963ccb21 100644 --- a/src/youtube_extension/backend/cloud_api_endpoints.py +++ b/src/youtube_extension/backend/cloud_api_endpoints.py @@ -145,15 +145,8 @@ async def process_video_cloud( error_msg = f"Cloud processing failed: {str(e)}" logger.error(error_msg) - raise HTTPException( - status_code=500, - detail={ - "error": "cloud_processing_failed", - "message": error_msg, - "video_url": request.video_url, - "timestamp": datetime.now(timezone.utc).isoformat() - } - ) + # detail is a static string; error_msg (with the exception) is logged above only + raise HTTPException(status_code=500, detail="Internal server error") @router.post("/api/v3/process-video-task") async def process_video_task_handler( @@ -229,7 +222,8 @@ async def process_video_task_handler( except Exception as state_error: logger.error(f"Failed to update error state: {state_error}") - raise HTTPException(status_code=500, detail=error_msg) + # detail is a static string; error_msg (with the exception) is logged above only + raise HTTPException(status_code=500, detail="Internal server error") @router.post("/api/v3/batch-process") async def batch_process_videos_cloud(request: BatchCloudProcessingRequest): diff --git a/src/youtube_extension/backend/real_api_endpoints.py b/src/youtube_extension/backend/real_api_endpoints.py index 1b0433cfc..8567e9712 100644 --- a/src/youtube_extension/backend/real_api_endpoints.py +++ b/src/youtube_extension/backend/real_api_endpoints.py @@ -121,15 +121,8 @@ async def process_video_real_api(request: VideoProcessingRequest, background_tas error_msg = f"Real API processing failed: {str(e)}" logger.error(error_msg) - raise HTTPException( - status_code=500, - detail={ - "error": "video_processing_failed", - "message": error_msg, - "video_url": request.video_url, - "timestamp": datetime.now(timezone.utc).isoformat() - } - ) + # detail is a static string; error_msg (with the exception) is logged above only + raise HTTPException(status_code=500, detail="Internal server error") @app.post("/api/v2/validate-video") async def validate_video_url(request: VideoValidationRequest): From 321d9632aec2a976b8a7d660fe6b7f15eaf8b386 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 02:19:04 +0000 Subject: [PATCH 3/9] test: assert 500 responses do not leak internal state (align with sanitization) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing test_error_response_includes_video_url asserted that the /api/v2/process-video 500 body echoed the request video_url — the exact CWE-209 information-disclosure behaviour the sanitization removes. It failed in CI (assert 'auJzb1D-fag' in 'Internal server error') because the handler now returns a static detail. Invert it into test_error_response_does_not_leak_internal_state: assert the detail is exactly 'Internal server error' and contains neither the video_url nor the exception text. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JNphZoHcSj1jnc3ofnw5oc --- tests/unit/test_real_api_endpoints.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_real_api_endpoints.py b/tests/unit/test_real_api_endpoints.py index 4eb7109c6..eea820c54 100644 --- a/tests/unit/test_real_api_endpoints.py +++ b/tests/unit/test_real_api_endpoints.py @@ -344,14 +344,19 @@ def test_returns_500_when_processor_raises(self, client, mock_processor): ) assert response.status_code == 500 - def test_error_response_includes_video_url(self, client, mock_processor): + def test_error_response_does_not_leak_internal_state(self, client, mock_processor): mock_processor.process_video = AsyncMock(side_effect=RuntimeError("crash")) response = client.post( "/api/v2/process-video", json={"video_url": "https://youtube.com/watch?v=auJzb1D-fag"}, ) + assert response.status_code == 500 + # 500 responses must not disclose internal state (CWE-209): + # neither the request-supplied video_url nor the exception text. detail = response.json()["detail"] - assert "auJzb1D-fag" in str(detail) + assert detail == "Internal server error" + assert "auJzb1D-fag" not in str(detail) + assert "crash" not in str(detail) def test_missing_video_url_returns_422(self, client): response = client.post("/api/v2/process-video", json={}) From 4d14e963d4a04f04df3fc7a60d7dc2351b4f2191 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 02:24:54 +0000 Subject: [PATCH 4/9] fix(security): preserve tracebacks, keep 4xx, and stop persisted-state leak Addresses CodeRabbit's changes-requested review on the 500-sanitization: - Add exc_info=True to every sanitized 500 handler in cloud_ai_routes, cloud_api_endpoints and real_api_endpoints so the full traceback is actually preserved server-side (the PR claimed this but several handlers logged only str(e)). - real_api_endpoints: re-raise HTTPException before the broad except in batch_process_videos and search_youtube_videos, so the deliberate 400 validation errors (>20 videos / >50 results) are no longer swallowed and rewrapped as 500. Tighten the batch test to assert a clean 400. - cloud_api_endpoints task handler: stop persisting str(e) as the task's error_message. get_video_status / get_video_result echo error_message to clients, so a raw exception there re-exposed internal detail (CWE-209) even though the immediate 500 body was already generic. Persist a static 'Internal server error' and log the real exception with exc_info=True. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JNphZoHcSj1jnc3ofnw5oc --- .../backend/cloud_ai_routes.py | 10 +++++----- .../backend/cloud_api_endpoints.py | 19 +++++++++---------- .../backend/real_api_endpoints.py | 12 ++++++++---- tests/unit/test_real_api_endpoints.py | 11 ++++++----- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/youtube_extension/backend/cloud_ai_routes.py b/src/youtube_extension/backend/cloud_ai_routes.py index 0172e1617..a470cabe6 100644 --- a/src/youtube_extension/backend/cloud_ai_routes.py +++ b/src/youtube_extension/backend/cloud_ai_routes.py @@ -228,7 +228,7 @@ async def get_provider_status(): ) except Exception as e: - logger.error(f"Failed to get provider status: {e}") + logger.error(f"Failed to get provider status: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @@ -262,12 +262,12 @@ async def analyze_video(request: VideoAnalysisRequest): logger.warning(f"Rate limit exceeded: {e}") raise HTTPException(status_code=429, detail=f"Rate limit exceeded: {str(e)}") except ConfigurationError as e: - logger.error(f"Configuration error: {e}") + logger.error(f"Configuration error: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") except HTTPException: raise except Exception as e: - logger.error(f"Unexpected error during video analysis: {e}") + logger.error(f"Unexpected error during video analysis: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @@ -300,7 +300,7 @@ async def analyze_batch_videos(request: BatchAnalysisRequest, background_tasks: except HTTPException: raise except Exception as e: - logger.error(f"Failed to start batch analysis: {e}") + logger.error(f"Failed to start batch analysis: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @@ -324,7 +324,7 @@ async def analyze_video_multi_provider(request: VideoAnalysisRequest): return formatted_results except Exception as e: - logger.error(f"Multi-provider analysis failed: {e}") + logger.error(f"Multi-provider analysis failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") diff --git a/src/youtube_extension/backend/cloud_api_endpoints.py b/src/youtube_extension/backend/cloud_api_endpoints.py index 3963ccb21..af3088dd2 100644 --- a/src/youtube_extension/backend/cloud_api_endpoints.py +++ b/src/youtube_extension/backend/cloud_api_endpoints.py @@ -142,10 +142,8 @@ async def process_video_cloud( ) except Exception as e: - error_msg = f"Cloud processing failed: {str(e)}" - logger.error(error_msg) - - # detail is a static string; error_msg (with the exception) is logged above only + # Exception text is logged server-side only; never returned to the client. + logger.error(f"Cloud processing failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @router.post("/api/v3/process-video-task") @@ -208,21 +206,22 @@ async def process_video_task_handler( } except Exception as e: - error_msg = f"Task processing failed: {str(e)}" - logger.error(error_msg) + # Exception text is logged server-side only. It must NOT be persisted to + # task state: get_video_status / get_video_result echo error_message back + # to clients, so a raw str(e) there would re-expose internal detail (CWE-209). + logger.error(f"Task processing failed: {e}", exc_info=True) - # Update state with error + # Update state with a user-safe message try: firestore_service = await get_firestore_service() await firestore_service.update_state( payload.video_id, status='failed', - error_message=error_msg + error_message="Internal server error" ) except Exception as state_error: - logger.error(f"Failed to update error state: {state_error}") + logger.error(f"Failed to update error state: {state_error}", exc_info=True) - # detail is a static string; error_msg (with the exception) is logged above only raise HTTPException(status_code=500, detail="Internal server error") @router.post("/api/v3/batch-process") diff --git a/src/youtube_extension/backend/real_api_endpoints.py b/src/youtube_extension/backend/real_api_endpoints.py index 8567e9712..ab7dd1e86 100644 --- a/src/youtube_extension/backend/real_api_endpoints.py +++ b/src/youtube_extension/backend/real_api_endpoints.py @@ -117,11 +117,11 @@ async def process_video_real_api(request: VideoProcessingRequest, background_tas return response + except HTTPException: + raise except Exception as e: - error_msg = f"Real API processing failed: {str(e)}" - logger.error(error_msg) - - # detail is a static string; error_msg (with the exception) is logged above only + # Exception text is logged server-side only; never returned to the client. + logger.error(f"Real API processing failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @app.post("/api/v2/validate-video") @@ -170,6 +170,8 @@ async def batch_process_videos(request: BatchProcessingRequest): return result + except HTTPException: + raise except Exception as e: logger.error(f"Unhandled error in batch_process_videos: {e}", exc_info=True) raise HTTPException( @@ -428,6 +430,8 @@ async def search_youtube_videos( "timestamp": datetime.now(timezone.utc).isoformat() } + except HTTPException: + raise except Exception as e: logger.error(f"Unhandled error in search_youtube_videos: {e}", exc_info=True) raise HTTPException( diff --git a/tests/unit/test_real_api_endpoints.py b/tests/unit/test_real_api_endpoints.py index eea820c54..67a2f49f6 100644 --- a/tests/unit/test_real_api_endpoints.py +++ b/tests/unit/test_real_api_endpoints.py @@ -441,16 +441,17 @@ def test_valid_batch_returns_200(self, client): ) assert response.status_code == 200 - def test_batch_with_more_than_20_videos_returns_error(self, client): - """Source code raises HTTPException(400) inside a try block that - re-wraps it as 500. Test matches actual behaviour.""" + def test_batch_with_more_than_20_videos_returns_400(self, client): + """A >20-video batch must surface the explicit 400 validation error and + not be swallowed into a generic 500 by the broad exception handler + (the handler re-raises HTTPException before the catch-all).""" urls = [f"https://youtube.com/watch?v=vid{i:05d}" for i in range(21)] response = client.post( "/api/v2/batch-process", json={"video_urls": urls, "max_concurrent": 3}, ) - # The HTTPException(400) is caught by the outer except -> HTTP 500 - assert response.status_code in (400, 500) + assert response.status_code == 400 + assert "Maximum 20 videos" in response.json()["detail"] def test_batch_response_contains_results(self, client): response = client.post( From 9c90eb220b9cbb2893aa2ba16567400bdde9b181 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 02:27:59 +0000 Subject: [PATCH 5/9] test: AST-based 500-leak guard + tighten search-limit assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two regression-test gaps CodeRabbit/Copilot flagged: - Rewrite test_500_info_disclosure guard from a keyword-only regex to an AST scan. It now flags every 500 HTTPException whose detail is not a static string literal — positional HTTPException(500, str(e)), keyword detail=str(e)/f-strings, dicts, and bare variables (detail=error_msg) — and is explicitly scoped to the routers this PR hardens (cloud_ai_routes, cloud_api_endpoints, real_api_endpoints), with a docstring that no longer over-claims tree-wide coverage. Legacy leaks elsewhere (e.g. api/advanced_video_routes.py) are out of scope and tracked separately. Synthetic self-checks cover each supported form plus static/4xx negatives. - Tighten test_max_results_above_50 to assert a clean 400 + limit message (was accepting 400 or 500), matching the batch-limit test now that the search handler re-raises HTTPException before its broad except. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JNphZoHcSj1jnc3ofnw5oc --- tests/unit/test_500_info_disclosure.py | 157 ++++++++++++++++--------- tests/unit/test_real_api_endpoints.py | 10 +- 2 files changed, 106 insertions(+), 61 deletions(-) diff --git a/tests/unit/test_500_info_disclosure.py b/tests/unit/test_500_info_disclosure.py index d561c422e..c87bcc464 100644 --- a/tests/unit/test_500_info_disclosure.py +++ b/tests/unit/test_500_info_disclosure.py @@ -1,15 +1,24 @@ """Regression guard against information disclosure in HTTP 500 responses. Context: several FastAPI handlers historically raised -``HTTPException(status_code=500, detail=str(e))`` (or an f-string embedding the -exception), leaking internal exception text — stack-adjacent messages, backend -API errors, database errors — to clients. See PR #801, which sanitized most but -not all handlers. - -This test encodes the invariant directly on the source: a 500 response must use -a *static* ``detail`` string, never one derived from the caught exception. It is -hermetic (pure source scan, no app import / no pydantic) so it runs anywhere and -catches new leaks in any backend route, not just the ones fixed today. +``HTTPException(status_code=500, detail=str(e))`` (or an f-string / dict +embedding the exception), leaking internal exception text — stack-adjacent +messages, backend API errors, database errors — to clients (CWE-209). See PR +#801, which sanitized most but not all handlers. + +This test encodes the invariant directly on the source of the routers this PR +hardens: a 500 response must use a *static* string ``detail``, never one derived +from the caught exception or any runtime value. The scan is AST-based, so it +catches every call form — positional ``HTTPException(500, str(e))`` and keyword +``HTTPException(status_code=500, detail=...)`` alike, and flags ``str(...)``, +f-strings, dicts, and bare variables such as ``detail=error_msg``. It is hermetic +(no app import / no pydantic) so it runs anywhere. + +Scope note: ``_GUARDED_FILES`` is deliberately limited to the routers sanitized +here. Other backend modules still carry legacy dynamic 500 details (e.g. +``api/advanced_video_routes.py``); hardening those is tracked separately. Add a +router to ``_GUARDED_FILES`` once it has been sanitized to bring it under guard — +that is the intended way to widen coverage. It deliberately does not constrain 4xx responses: those echo client-supplied validation errors, which are not internal-disclosure vectors. @@ -17,77 +26,111 @@ from __future__ import annotations -import re +import ast from pathlib import Path import pytest _BACKEND = Path(__file__).resolve().parents[2] / "src" / "youtube_extension" / "backend" -# Match a single `raise HTTPException(...)` call, capturing its argument list, -# tolerant of the call spanning multiple lines. -_HTTP_EXC = re.compile(r"HTTPException\((?P.*?)\)", re.DOTALL) - -# A `detail=` argument whose value is derived from a variable/exception: -# detail=str(e) detail=str(exc) -# detail=f"... {e} ..." detail=f"... {str(e)} ..." -_DYNAMIC_DETAIL = re.compile( - r"""detail\s*=\s*(?: - str\( # detail=str(...) - | f["'][^"']*\{ # detail=f"...{...}..." - )""", - re.VERBOSE, +# Routers sanitized by this PR — the invariant is enforced exactly here. +_GUARDED_FILES = ( + "cloud_ai_routes.py", + "cloud_api_endpoints.py", + "real_api_endpoints.py", ) -def _backend_python_files() -> list[Path]: - return sorted(_BACKEND.rglob("*.py")) +def _is_httpexception(call: ast.Call) -> bool: + func = call.func + return (isinstance(func, ast.Name) and func.id == "HTTPException") or ( + isinstance(func, ast.Attribute) and func.attr == "HTTPException" + ) -def _iter_500_dynamic_detail(text: str): - """Yield (line_no, snippet) for each 500 HTTPException with a dynamic detail.""" - for m in _HTTP_EXC.finditer(text): - args = m.group("args") - if "status_code=500" not in args.replace(" ", "").replace( - "status_code =", "status_code=" +def _is_500(call: ast.Call) -> bool: + # Positional form: HTTPException(500, ...) + if call.args and isinstance(call.args[0], ast.Constant) and call.args[0].value == 500: + return True + # Keyword form: HTTPException(status_code=500, ...) + for kw in call.keywords: + if ( + kw.arg == "status_code" + and isinstance(kw.value, ast.Constant) + and kw.value.value == 500 ): - # normalize minor spacing; only care about 500 responses - if "status_code=500" not in re.sub(r"\s+", "", args): - continue - if _DYNAMIC_DETAIL.search(args): - line_no = text.count("\n", 0, m.start()) + 1 - yield line_no, " ".join(args.split())[:120] + return True + return False + + +def _detail_node(call: ast.Call) -> ast.expr | None: + # Keyword form: detail=... + for kw in call.keywords: + if kw.arg == "detail": + return kw.value + # Positional form: HTTPException(, ) + if len(call.args) >= 2: + return call.args[1] + return None + + +def _iter_500_dynamic_detail(text: str): + """Yield (line_no, snippet) for each 500 ``HTTPException`` whose ``detail`` is + anything other than a static string literal.""" + tree = ast.parse(text) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not _is_httpexception(node): + continue + if not _is_500(node): + continue + detail = _detail_node(node) + if detail is None: + continue # no detail argument at all — nothing to leak + # A static string literal is the only safe form. + if isinstance(detail, ast.Constant) and isinstance(detail.value, str): + continue + yield node.lineno, ast.unparse(node)[:160] def test_no_dynamic_detail_in_500_responses() -> None: offenders: list[str] = [] - for path in _backend_python_files(): - text = path.read_text(encoding="utf-8") - for line_no, snippet in _iter_500_dynamic_detail(text): - rel = path.relative_to(_BACKEND.parents[2]) - offenders.append(f"{rel}:{line_no}: HTTPException({snippet})") + for name in _GUARDED_FILES: + path = _BACKEND / name + assert path.exists(), f"guarded file no longer exists: {name}" + for line_no, snippet in _iter_500_dynamic_detail(path.read_text(encoding="utf-8")): + offenders.append(f"{name}:{line_no}: {snippet}") assert not offenders, ( "HTTP 500 responses must use a static `detail` string (e.g. " - '"Internal server error") and never leak the caught exception. ' - "Log the full error server-side instead. Offending sites:\n " + '"Internal server error") and never leak the caught exception or any ' + "runtime value. Log the full error server-side instead. Offending sites:\n " + "\n ".join(offenders) ) -def test_guard_detects_a_synthetic_leak() -> None: - """Sanity check: the scanner actually flags a dynamic 500 detail.""" - leaky = "raise HTTPException(status_code=500, detail=str(e))" - safe = 'raise HTTPException(status_code=500, detail="Internal server error")' - client_err = "raise HTTPException(status_code=400, detail=str(exc))" - - assert list(_iter_500_dynamic_detail(leaky)), "scanner missed a real 500 leak" - assert not list( - _iter_500_dynamic_detail(safe) - ), "scanner false-positived a static detail" - assert not list( - _iter_500_dynamic_detail(client_err) - ), "scanner must ignore 4xx responses" +def test_guard_detects_synthetic_leaks() -> None: + """Sanity check: the scanner flags every dynamic 500 detail form and ignores + static details and 4xx responses.""" + leaky = [ + "raise HTTPException(status_code=500, detail=str(e))", + "raise HTTPException(500, str(e))", # positional status + detail + 'raise HTTPException(status_code=500, detail=f"failed: {e}")', + "raise HTTPException(status_code=500, detail=error_msg)", # bare variable + 'raise HTTPException(status_code=500, detail={"message": str(e)})', # dict + ] + for src in leaky: + assert list(_iter_500_dynamic_detail(src)), f"scanner missed a real leak: {src}" + + safe = [ + 'raise HTTPException(status_code=500, detail="Internal server error")', + 'raise HTTPException(500, "Internal server error")', + "raise HTTPException(status_code=400, detail=str(exc))", # 4xx echoes client input + "raise HTTPException(status_code=429, detail=f'Rate limit: {e}')", + ] + for src in safe: + assert not list( + _iter_500_dynamic_detail(src) + ), f"scanner false-positived: {src}" if __name__ == "__main__": # pragma: no cover diff --git a/tests/unit/test_real_api_endpoints.py b/tests/unit/test_real_api_endpoints.py index 67a2f49f6..c320bde37 100644 --- a/tests/unit/test_real_api_endpoints.py +++ b/tests/unit/test_real_api_endpoints.py @@ -802,11 +802,13 @@ def test_result_video_url_format(self, client): result = response.json()["results"][0] assert "youtube.com/watch?v=" in result["video_url"] - def test_max_results_above_50_returns_error(self, client): - """Source code raises HTTPException(400) inside a try block that - catches Exception -> results in HTTP 500.""" + def test_max_results_above_50_returns_400(self, client): + """A >50-result search must surface the explicit 400 validation error and + not be swallowed into a generic 500 by the broad exception handler + (the handler re-raises HTTPException before the catch-all).""" response = client.post("/api/v2/search-videos?query=python&max_results=51") - assert response.status_code in (400, 500) + assert response.status_code == 400 + assert "Maximum 50 results" in response.json()["detail"] def test_default_order_is_relevance(self, client, mock_youtube): client.post("/api/v2/search-videos?query=test") From 9dd7107aa7d7a34bc96a689493ce8aa81f1cf1c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 02:35:25 +0000 Subject: [PATCH 6/9] test: 500-leak guard also recognizes symbolic status constants Extend the AST guard to treat status.HTTP_500_INTERNAL_SERVER_ERROR (and a direct HTTP_500_INTERNAL_SERVER_ERROR import) as a 500 in both positional and status_code= positions, so a style change from the literal 500 cannot silently reintroduce a CWE-209 leak. Adds synthetic leak/safe cases for the symbolic form. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JNphZoHcSj1jnc3ofnw5oc --- tests/unit/test_500_info_disclosure.py | 28 ++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_500_info_disclosure.py b/tests/unit/test_500_info_disclosure.py index c87bcc464..95c452e93 100644 --- a/tests/unit/test_500_info_disclosure.py +++ b/tests/unit/test_500_info_disclosure.py @@ -48,17 +48,25 @@ def _is_httpexception(call: ast.Call) -> bool: ) +def _is_500_status(node: ast.expr) -> bool: + # Literal 500 + if isinstance(node, ast.Constant) and node.value == 500: + return True + # Symbolic constant: status.HTTP_500_INTERNAL_SERVER_ERROR or a direct import + if isinstance(node, ast.Attribute) and node.attr == "HTTP_500_INTERNAL_SERVER_ERROR": + return True + if isinstance(node, ast.Name) and node.id == "HTTP_500_INTERNAL_SERVER_ERROR": + return True + return False + + def _is_500(call: ast.Call) -> bool: - # Positional form: HTTPException(500, ...) - if call.args and isinstance(call.args[0], ast.Constant) and call.args[0].value == 500: + # Positional form: HTTPException(, ...) + if call.args and _is_500_status(call.args[0]): return True - # Keyword form: HTTPException(status_code=500, ...) + # Keyword form: HTTPException(status_code=, ...) for kw in call.keywords: - if ( - kw.arg == "status_code" - and isinstance(kw.value, ast.Constant) - and kw.value.value == 500 - ): + if kw.arg == "status_code" and _is_500_status(kw.value): return True return False @@ -117,6 +125,9 @@ def test_guard_detects_synthetic_leaks() -> None: 'raise HTTPException(status_code=500, detail=f"failed: {e}")', "raise HTTPException(status_code=500, detail=error_msg)", # bare variable 'raise HTTPException(status_code=500, detail={"message": str(e)})', # dict + # Symbolic status constant (status.HTTP_500_* and direct import forms) + "raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))", + "raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, str(e))", ] for src in leaky: assert list(_iter_500_dynamic_detail(src)), f"scanner missed a real leak: {src}" @@ -124,6 +135,7 @@ def test_guard_detects_synthetic_leaks() -> None: safe = [ 'raise HTTPException(status_code=500, detail="Internal server error")', 'raise HTTPException(500, "Internal server error")', + 'raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error")', "raise HTTPException(status_code=400, detail=str(exc))", # 4xx echoes client input "raise HTTPException(status_code=429, detail=f'Rate limit: {e}')", ] From 6c37560f4063ee9dba913b535eb0b91f1e52e611 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 02:42:27 +0000 Subject: [PATCH 7/9] fix(security): make analyze_video subclass handlers reachable + sanitize 503/429 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flagged independently by CodeRabbit, Copilot, and Vercel's agent (VADE, security): RateLimitError and ConfigurationError both subclass CloudAIError, so the base-class 'except CloudAIError' caught them first and the specific handlers were dead code. A ConfigurationError therefore returned a 503 whose body embedded the raw config message (e.g. 'missing key') — a CWE-209 leak — instead of the intended sanitized 500. Reorder so the subclasses are caught before the base class: - RateLimitError -> 429 (was mis-reported as 503) - ConfigurationError -> 500 'Internal server error' (leak closed; full error logged) - CloudAIError -> 503 'AI service temporarily unavailable' (was leaking str(e)) Tighten the two tests to assert the corrected status codes and that the config message never reaches the client. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JNphZoHcSj1jnc3ofnw5oc --- src/youtube_extension/backend/cloud_ai_routes.py | 13 +++++++++---- tests/unit/test_cloud_routes.py | 15 +++++++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/youtube_extension/backend/cloud_ai_routes.py b/src/youtube_extension/backend/cloud_ai_routes.py index a470cabe6..c55d90042 100644 --- a/src/youtube_extension/backend/cloud_ai_routes.py +++ b/src/youtube_extension/backend/cloud_ai_routes.py @@ -255,15 +255,20 @@ async def analyze_video(request: VideoAnalysisRequest): # Format and return response return format_analysis_result(result) - except CloudAIError as e: - logger.error(f"Cloud AI analysis failed: {e}") - raise HTTPException(status_code=503, detail=f"AI analysis failed: {str(e)}") except RateLimitError as e: + # RateLimitError subclasses CloudAIError, so it MUST be caught before the + # base class or it would be swallowed and mis-reported as a 503. logger.warning(f"Rate limit exceeded: {e}") - raise HTTPException(status_code=429, detail=f"Rate limit exceeded: {str(e)}") + raise HTTPException(status_code=429, detail="Rate limit exceeded") except ConfigurationError as e: + # ConfigurationError subclasses CloudAIError, so it MUST be caught before + # the base class. Its message can embed internal configuration detail, so + # the client body stays generic (CWE-209); the full error is logged below. logger.error(f"Configuration error: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") + except CloudAIError as e: + logger.error(f"Cloud AI analysis failed: {e}", exc_info=True) + raise HTTPException(status_code=503, detail="AI service temporarily unavailable") except HTTPException: raise except Exception as e: diff --git a/tests/unit/test_cloud_routes.py b/tests/unit/test_cloud_routes.py index 4c415a3ed..e4763a577 100644 --- a/tests/unit/test_cloud_routes.py +++ b/tests/unit/test_cloud_routes.py @@ -360,8 +360,8 @@ def test_analyze_video_cloud_ai_error(self): assert response.status_code == 503 def test_analyze_video_rate_limit_error(self): - # RateLimitError is a subclass of CloudAIError, so it's caught by the - # CloudAIError except clause first and returns 503 (not 429). + # RateLimitError subclasses CloudAIError but is now caught before the base + # class, so it correctly returns 429 (not 503). mock_ai = AsyncMock() mock_ai.__aenter__ = AsyncMock(return_value=mock_ai) mock_ai.__aexit__ = AsyncMock(return_value=False) @@ -373,11 +373,12 @@ def test_analyze_video_rate_limit_error(self): "video_url": "https://www.youtube.com/watch?v=auJzb1D-fag", "analysis_types": ["label_detection"], }) - assert response.status_code in (429, 503) + assert response.status_code == 429 def test_analyze_video_configuration_error(self): - # ConfigurationError is a subclass of CloudAIError, so it's caught by - # the CloudAIError clause -> 503. + # ConfigurationError subclasses CloudAIError but is now caught before the + # base class, so it returns a sanitized 500 (not a 503 leaking the config + # message). The internal detail must never reach the client. mock_ai = AsyncMock() mock_ai.__aenter__ = AsyncMock(return_value=mock_ai) mock_ai.__aexit__ = AsyncMock(return_value=False) @@ -389,7 +390,9 @@ def test_analyze_video_configuration_error(self): "video_url": "https://www.youtube.com/watch?v=auJzb1D-fag", "analysis_types": ["label_detection"], }) - assert response.status_code in (500, 503) + assert response.status_code == 500 + assert response.json()["detail"] == "Internal server error" + assert "missing key" not in response.text def test_analyze_video_generic_error(self): mock_ai = AsyncMock() From 52f0a264e4566a20314d52bda8b508a64a0dafd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 02:47:26 +0000 Subject: [PATCH 8/9] fix(security): sanitize persisted error_message at status/result response boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flagged by Copilot and Vercel's agent (VADE, security): the video processor's normal failure path persists raw exception text (str(e)) into processing state, which /status, /result, and the sync process-video response echoed back to clients via error_message/error (CWE-209) — the endpoint-level 500 handler only covered the raise path, not this persisted path. Add _client_safe_error() and apply it at all three client boundaries in cloud_api_endpoints.py: clients now get a generic 'Internal server error' when a failure occurred, while the full message stays in server-side state and logs. Fixing at the boundary covers every persistence path without changing the processor module or its internal-facing result.error_message (which its own tests assert). Update test_process_video_sync_failed to assert the sanitized body and that the raw message never appears in the response. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JNphZoHcSj1jnc3ofnw5oc --- .../backend/cloud_api_endpoints.py | 18 +++++++++++++++--- tests/unit/test_cloud_routes.py | 5 ++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/youtube_extension/backend/cloud_api_endpoints.py b/src/youtube_extension/backend/cloud_api_endpoints.py index af3088dd2..16378abdd 100644 --- a/src/youtube_extension/backend/cloud_api_endpoints.py +++ b/src/youtube_extension/backend/cloud_api_endpoints.py @@ -35,6 +35,18 @@ router = APIRouter() +def _client_safe_error(error_message: Optional[str]) -> Optional[str]: + """Return a client-safe error string. + + Processing state persisted by the video processor can embed raw exception + text (e.g. ``Error processing video ...: ``). That text must never + be echoed to clients through the status/result/process responses (CWE-209). + The full message stays in server-side state and logs; the client only learns + that an error occurred. + """ + return "Internal server error" if error_message else None + + # Pydantic models for API requests/responses class CloudVideoProcessingRequest(BaseModel): @@ -138,7 +150,7 @@ async def process_video_cloud( ai_analysis=result.ai_analysis, processing_time=result.processing_time, from_cache=result.from_cache, - error=result.error_message, + error=_client_safe_error(result.error_message), ) except Exception as e: @@ -281,7 +293,7 @@ async def get_video_status(video_id: str): created_at=state.created_at, updated_at=state.updated_at, processing_time=state.processing_time, - error_message=state.error_message, + error_message=_client_safe_error(state.error_message), ) except HTTPException: @@ -319,7 +331,7 @@ async def get_video_result(video_id: str): "processing_time": state.processing_time, "created_at": state.created_at, "updated_at": state.updated_at, - "error_message": state.error_message, + "error_message": _client_safe_error(state.error_message), } except HTTPException: diff --git a/tests/unit/test_cloud_routes.py b/tests/unit/test_cloud_routes.py index e4763a577..3b8f5f155 100644 --- a/tests/unit/test_cloud_routes.py +++ b/tests/unit/test_cloud_routes.py @@ -577,7 +577,10 @@ def test_process_video_sync_failed(self): assert response.status_code == 200 data = response.json() assert data["status"] == "failed" - assert data["error"] == "something went wrong" + # The failure is reported, but the raw persisted exception text must be + # sanitized at the response boundary (CWE-209), not echoed to the client. + assert data["error"] == "Internal server error" + assert "something went wrong" not in response.text def test_process_video_exception(self): mock_processor = AsyncMock() From 9767f2dd417a73b9cb8570c3ba55cc30834f1613 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 03:57:03 +0000 Subject: [PATCH 9/9] test: regression-cover the sanitized 429/503 branches of analyze_video Copilot: the guard scans 500s only and the endpoint tests asserted status codes alone, so a regression re-leaking str(e) in the newly sanitized 429 (RateLimitError) or 503 (CloudAIError) branch would pass unnoticed. Assert the exact static bodies and the absence of the exception text in test_analyze_video_rate_limit_error (429 -> 'Rate limit exceeded') and test_analyze_video_cloud_ai_error (503 -> 'AI service temporarily unavailable'). Also replace the guard's misleading dynamic-429 'safe' control with a genuine client-input 4xx example and note where 429/503 coverage lives. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JNphZoHcSj1jnc3ofnw5oc --- tests/unit/test_500_info_disclosure.py | 6 +++++- tests/unit/test_cloud_routes.py | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_500_info_disclosure.py b/tests/unit/test_500_info_disclosure.py index 95c452e93..69f7f6c82 100644 --- a/tests/unit/test_500_info_disclosure.py +++ b/tests/unit/test_500_info_disclosure.py @@ -137,8 +137,12 @@ def test_guard_detects_synthetic_leaks() -> None: 'raise HTTPException(500, "Internal server error")', 'raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error")', "raise HTTPException(status_code=400, detail=str(exc))", # 4xx echoes client input - "raise HTTPException(status_code=429, detail=f'Rate limit: {e}')", + 'raise HTTPException(status_code=404, detail=f"No such video: {video_id}")', # 4xx echoes client input ] + # NOTE: this guard covers 500 responses only. The 429/503 server-error + # branches in analyze_video are sanitized too, but their regression coverage + # lives in the endpoint tests (test_cloud_routes.py), which assert the exact + # static bodies and the absence of the exception text. for src in safe: assert not list( _iter_500_dynamic_detail(src) diff --git a/tests/unit/test_cloud_routes.py b/tests/unit/test_cloud_routes.py index 3b8f5f155..12dde6699 100644 --- a/tests/unit/test_cloud_routes.py +++ b/tests/unit/test_cloud_routes.py @@ -358,6 +358,9 @@ def test_analyze_video_cloud_ai_error(self): "analysis_types": ["label_detection"], }) assert response.status_code == 503 + # The 503 body must be static; the exception text must not leak (CWE-209). + assert response.json()["detail"] == "AI service temporarily unavailable" + assert "service down" not in response.text def test_analyze_video_rate_limit_error(self): # RateLimitError subclasses CloudAIError but is now caught before the base @@ -374,6 +377,9 @@ def test_analyze_video_rate_limit_error(self): "analysis_types": ["label_detection"], }) assert response.status_code == 429 + # The 429 body must be static; the exception text must not leak (CWE-209). + assert response.json()["detail"] == "Rate limit exceeded" + assert "too many requests" not in response.text def test_analyze_video_configuration_error(self): # ConfigurationError subclasses CloudAIError but is now caught before the