security: isolate docker socket behind proxy

This commit is contained in:
mARTin-B78 2026-09-04 07:55:00 +02:00
parent 4a0101f4a4
commit 742b43d860
7 changed files with 75 additions and 21 deletions

View File

@ -29,6 +29,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
remain observable as queued jobs and fail clearly if no slot becomes available within 15 minutes. remain observable as queued jobs and fail clearly if no slot becomes available within 15 minutes.
- **Fish.audio Cache Eviction.** Offline search responses are now evicted by configurable file-count and byte-size - **Fish.audio Cache Eviction.** Offline search responses are now evicted by configurable file-count and byte-size
limits, preventing an unbounded cache from consuming the configuration volume. limits, preventing an unbounded cache from consuming the configuration volume.
- **Docker Socket Isolation.** Compose deployments now place the host Docker socket behind a dedicated network proxy;
the web container no longer mounts the socket or joins the host Docker group. The client retains direct Unix-socket
compatibility for deployments that intentionally configure it.
- **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL - **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL
rejection and oversized JSON request rejection. rejection and oversized JSON request rejection.
- **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary. - **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary.

View File

@ -34,9 +34,9 @@ the first block before investing in architecture or polish.
malformed records at API boundaries. malformed records at API boundaries.
- [x] **L — Add database migrations and prompt/generation history.** Preserve old record - [x] **L — Add database migrations and prompt/generation history.** Preserve old record
shapes and allow bad regenerations to be reverted. shapes and allow bad regenerations to be reverted.
- [ ] **L — Harden container and Docker access.** The app now runs non-root with - [x] **L — Harden container and Docker access.** The app runs non-root with dropped capabilities,
dropped capabilities, no-new-privileges and a read-only root filesystem; isolate no-new-privileges and a read-only root filesystem; Compose Docker access is isolated behind a minimal container
the remaining Docker socket access behind a minimal privileged service. proxy with only container inspection/control enabled.
### P1 — next, highest user impact ### P1 — next, highest user impact
@ -132,7 +132,7 @@ the first block before investing in architecture or polish.
- [ ] Strictly validate custom URLs to prevent SSRF. - [ ] Strictly validate custom URLs to prevent SSRF.
- [ ] Add request-size limits, rate limits, and authentication for destructive/API endpoints. - [ ] Add request-size limits, rate limits, and authentication for destructive/API endpoints.
- [ ] Run containers as non-root with dropped capabilities and read-only filesystems where practical. - [ ] Run containers as non-root with dropped capabilities and read-only filesystems where practical.
- [ ] Isolate Docker socket access behind a minimal privileged service. - [x] Isolate Docker socket access behind a minimal privileged service.
## P0 — Data integrity and recovery ## P0 — Data integrity and recovery

View File

@ -6,6 +6,7 @@ import os
import re import re
import socket import socket
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse
def _decode_chunked_bytes(data: bytes) -> bytes: def _decode_chunked_bytes(data: bytes) -> bytes:
@ -27,17 +28,37 @@ def _decode_chunked_bytes(data: bytes) -> bytes:
return bytes(result) return bytes(result)
def _docker_available() -> bool:
endpoint = os.environ.get("DOCKER_HOST", "")
if endpoint.startswith("tcp://"):
return bool(urlparse(endpoint).hostname and urlparse(endpoint).port)
return Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists()
def _docker_connection() -> socket.socket:
endpoint = os.environ.get("DOCKER_HOST", "")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) if endpoint.startswith("tcp://") else socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
if endpoint.startswith("tcp://"):
parsed = urlparse(endpoint)
if not parsed.hostname or not parsed.port:
sock.close()
raise RuntimeError("Invalid DOCKER_HOST; expected tcp://host:port")
sock.connect((parsed.hostname, parsed.port))
else:
sock.connect(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock"))
return sock
def _docker_get_json(path: str) -> tuple[int, dict | list | None]: def _docker_get_json(path: str) -> tuple[int, dict | list | None]:
sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock") if not _docker_available():
if not Path(sock_path).exists(): raise RuntimeError("Docker control endpoint is unavailable")
raise RuntimeError(f"Docker socket not found: {sock_path}")
request = ( request = (
f"GET {path} HTTP/1.1\r\n" f"GET {path} HTTP/1.1\r\n"
"Host: docker\r\n" "Host: docker\r\n"
"Connection: close\r\n\r\n" "Connection: close\r\n\r\n"
).encode("utf-8") ).encode("utf-8")
chunks: list[bytes] = [] chunks: list[bytes] = []
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: with _docker_connection() as sock:
sock.settimeout(10) sock.settimeout(10)
sock.connect(sock_path) sock.connect(sock_path)
sock.sendall(request) sock.sendall(request)
@ -65,9 +86,8 @@ def _docker_get_json(path: str) -> tuple[int, dict | list | None]:
def _docker_post(path: str) -> tuple[int, str]: def _docker_post(path: str) -> tuple[int, str]:
sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock") if not _docker_available():
if not Path(sock_path).exists(): raise RuntimeError("Docker control endpoint is unavailable")
raise RuntimeError(f"Docker socket not found: {sock_path}")
request = ( request = (
f"POST {path} HTTP/1.1\r\n" f"POST {path} HTTP/1.1\r\n"
@ -77,7 +97,7 @@ def _docker_post(path: str) -> tuple[int, str]:
).encode("utf-8") ).encode("utf-8")
chunks = [] chunks = []
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: with _docker_connection() as sock:
sock.settimeout(20) sock.settimeout(20)
sock.connect(sock_path) sock.connect(sock_path)
sock.sendall(request) sock.sendall(request)

View File

@ -1,4 +1,30 @@
services: services:
# The web process talks to this narrowly configured proxy over the Compose
# network; the host Docker socket is never mounted into the web container.
docker-socket-proxy:
image: tecnativa/docker-socket-proxy:0.3.0
container_name: tts-voice-creator-docker-proxy
restart: unless-stopped
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
CONTAINERS: "1"
POST: "1"
IMAGES: "0"
NETWORKS: "0"
VOLUMES: "0"
SERVICES: "0"
TASKS: "0"
SWARM: "0"
SYSTEM: "0"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- dgx_net
tts-voice-creator-clone-and-design-2: tts-voice-creator-clone-and-design-2:
build: build:
context: . context: .
@ -26,16 +52,14 @@ services:
ports: ports:
- "7890:7890" - "7890:7890"
# Give the app user access to the Docker socket (GID of docker group on host) depends_on:
group_add: - docker-socket-proxy
- "${DOCKER_GID:-988}"
volumes: volumes:
- ${VOICE_HOST_DIR:-/home/sparky/Media/_Sounds/TTS_Voices}:/voices:rw - ${VOICE_HOST_DIR:-/home/sparky/Media/_Sounds/TTS_Voices}:/voices:rw
- ./config:/home/app/.config/tts-voice-creator:rw # settings, presets, routes - ./config:/home/app/.config/tts-voice-creator:rw # settings, presets, routes
- ./logs:/logs:rw # application logs - ./logs:/logs:rw # application logs
- /home/sparky/Docker/tts-dgx-spark-faster-qwen3-tts/config:/tts-config:rw - /home/sparky/Docker/tts-dgx-spark-faster-qwen3-tts/config:/tts-config:rw
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./static:/app/static:ro - ./static:/app/static:ro
- ./server.py:/app/server.py:ro - ./server.py:/app/server.py:ro
- ./core:/app/core:ro - ./core:/app/core:ro
@ -52,6 +76,7 @@ services:
- TTS_CONTAINER_NAME=tts-faster-qwen3-tts-voiceclone - TTS_CONTAINER_NAME=tts-faster-qwen3-tts-voiceclone
- TTS_CONTAINER_NAMES=tts-faster-qwen3-tts-voiceclone,tts-faster-qwen3-tts-streaming - TTS_CONTAINER_NAMES=tts-faster-qwen3-tts-voiceclone,tts-faster-qwen3-tts-streaming
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign - VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign
- DOCKER_HOST=tcp://docker-socket-proxy:2375
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"

View File

@ -9,7 +9,7 @@ from urllib.parse import quote
import requests import requests
from fastapi import APIRouter, Header, HTTPException from fastapi import APIRouter, Header, HTTPException
from core.docker_client import _docker_get_json, _docker_post from core.docker_client import _docker_available, _docker_get_json, _docker_post
from core.config import _load_settings from core.config import _load_settings
from core.validation import _normalize_service_url from core.validation import _normalize_service_url
@ -66,7 +66,7 @@ def _container_status(name: str) -> dict:
@router.get("/api/local-containers") @router.get("/api/local-containers")
async def get_local_containers(): async def get_local_containers():
sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists() sock_ok = _docker_available()
async def _check(defn: dict) -> dict: async def _check(defn: dict) -> dict:
entry = {k: v for k, v in defn.items()} entry = {k: v for k, v in defn.items()}

View File

@ -48,7 +48,7 @@ from core.tts_helpers import (
_fishspeech_request_audio, _fishspeech_request_audio,
) )
from core.audio import _duration, _export_normalized_wav from core.audio import _duration, _export_normalized_wav
from core.docker_client import _docker_post from core.docker_client import _docker_available, _docker_post
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@ -528,7 +528,7 @@ async def restart_tts_container():
else: else:
results.append(container) results.append(container)
except PermissionError: except PermissionError:
raise HTTPException(502, "No permission to access /var/run/docker.sock — is the socket mounted in docker-compose.yml?") raise HTTPException(502, "Docker control proxy is unavailable — check docker-socket-proxy in docker-compose.yml")
except Exception as e: except Exception as e:
errors.append(f"{container}: {e}") errors.append(f"{container}: {e}")
@ -552,7 +552,7 @@ async def restart_tts_container():
@router.get("/api/tts/restart-info") @router.get("/api/tts/restart-info")
async def tts_restart_info(): async def tts_restart_info():
containers = _tts_container_names() containers = _tts_container_names()
sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists() sock_ok = _docker_available()
return {"containers": containers, "socket_available": sock_ok} return {"containers": containers, "socket_available": sock_ok}

View File

@ -95,6 +95,12 @@ class SecurityValidationTests(unittest.TestCase):
finally: finally:
sources._FISH_CACHE_MAX_FILES, sources._FISH_CACHE_MAX_BYTES = old_count, old_bytes sources._FISH_CACHE_MAX_FILES, sources._FISH_CACHE_MAX_BYTES = old_count, old_bytes
def test_docker_client_accepts_network_proxy_without_socket_mount(self):
from unittest.mock import patch
from core.docker_client import _docker_available
with patch.dict(os.environ, {"DOCKER_HOST": "tcp://docker-socket-proxy:2375", "DOCKER_SOCKET": "/missing/socket"}):
self.assertTrue(_docker_available())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()