Security fixes: - Block /proc /sys /dev /run /boot in /api/browse-dirs (path traversal) - Verify yt-dlp output stays inside TEMP_DIR before registration - Remove Access-Control-Allow-Origin: * from /api/proxy-audio - TTL-based temp file registry (default 2h) to prevent disk fill Performance: - Cache settings + routing rules in memory (mtime-checked); eliminates per-request disk reads on every TTS call UI: - Add container name (optional) field to Docker stack TTS/STT engine cards (Qwen3 Voice Clone, Voice Design, Custom Voice, Streaming, NVIDIA Magpie, Parakeet) — enables Stop/Start/Restart buttons on all engine cards, matching the existing Other Local TTS/STT cards Refactor — backend: - server.py: 5560 lines → 43-line entry point - core/ package: constants, registry, validation, docker_client, config, routing, audio, voice, presets, tts_helpers - routes/ package: admin, settings, library, stt, sources, docker, tts, conversation (FastAPI APIRouter modules) - Dockerfile + docker-compose.yml updated to include core/ and routes/ Refactor — frontend: - static/app.js: 8744 lines → 16 modules in static/js/ utils, voice-inspector, voice-sources, integrations, routing, settings, voice-clone, voice-library, tts-preview, benchmark, stt, init, engines, ai-backends, generation, conversation - static/loader.js updated to load modules sequentially Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
63 lines
2.2 KiB
Python
63 lines
2.2 KiB
Python
"""URL validation and safe path helpers."""
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import re
|
|
import socket
|
|
from pathlib import Path
|
|
from urllib.parse import urlsplit
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
|
def _normalize_service_url(url: str) -> str:
|
|
"""Replace 0.0.0.0 with host.docker.internal so server-side probes reach the host."""
|
|
return re.sub(r"(https?://)0\.0\.0\.0([\/:$])", r"\1host.docker.internal\2", str(url or ""))
|
|
|
|
|
|
def _validate_http_url(raw: str, *, allow_private: bool = True) -> str:
|
|
raw = _normalize_service_url(str(raw or "")).strip()
|
|
if not raw:
|
|
raise HTTPException(400, "URL is required")
|
|
parts = urlsplit(raw)
|
|
if parts.scheme not in {"http", "https"}:
|
|
raise HTTPException(400, "Only http:// and https:// URLs are allowed")
|
|
if not parts.hostname:
|
|
raise HTTPException(400, "URL must include a hostname")
|
|
if parts.username or parts.password:
|
|
raise HTTPException(400, "URLs with embedded credentials are not allowed")
|
|
if allow_private:
|
|
return raw
|
|
try:
|
|
infos = socket.getaddrinfo(parts.hostname, parts.port or (443 if parts.scheme == "https" else 80), type=socket.SOCK_STREAM)
|
|
except socket.gaierror:
|
|
raise HTTPException(400, "URL hostname could not be resolved")
|
|
for info in infos:
|
|
ip = ipaddress.ip_address(info[4][0])
|
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved:
|
|
raise HTTPException(400, "Private, local, and reserved network URLs are not allowed for downloads")
|
|
return raw
|
|
|
|
|
|
def _copy_limited(src, dest, limit: int) -> int:
|
|
total = 0
|
|
while True:
|
|
chunk = src.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
total += len(chunk)
|
|
if total > limit:
|
|
raise HTTPException(413, "Uploaded file is too large")
|
|
dest.write(chunk)
|
|
return total
|
|
|
|
|
|
def _safe_child_path(root: Path, candidate: Path) -> Path:
|
|
root_resolved = root.resolve()
|
|
candidate_resolved = candidate.resolve()
|
|
try:
|
|
candidate_resolved.relative_to(root_resolved)
|
|
except ValueError:
|
|
raise HTTPException(403, "Access denied")
|
|
return candidate_resolved
|