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.
- **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.
- **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
rejection and oversized JSON request rejection.
- **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.
- [x] **L — Add database migrations and prompt/generation history.** Preserve old record
shapes and allow bad regenerations to be reverted.
- [ ] **L — Harden container and Docker access.** The app now runs non-root with
dropped capabilities, no-new-privileges and a read-only root filesystem; isolate
the remaining Docker socket access behind a minimal privileged service.
- [x] **L — Harden container and Docker access.** The app runs non-root with dropped capabilities,
no-new-privileges and a read-only root filesystem; Compose Docker access is isolated behind a minimal container
proxy with only container inspection/control enabled.
### 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.
- [ ] 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.
- [ ] Isolate Docker socket access behind a minimal privileged service.
- [x] Isolate Docker socket access behind a minimal privileged service.
## P0 — Data integrity and recovery

View File

@ -6,6 +6,7 @@ import os
import re
import socket
from pathlib import Path
from urllib.parse import urlparse
def _decode_chunked_bytes(data: bytes) -> bytes:
@ -27,17 +28,37 @@ def _decode_chunked_bytes(data: bytes) -> bytes:
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]:
sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
if not Path(sock_path).exists():
raise RuntimeError(f"Docker socket not found: {sock_path}")
if not _docker_available():
raise RuntimeError("Docker control endpoint is unavailable")
request = (
f"GET {path} HTTP/1.1\r\n"
"Host: docker\r\n"
"Connection: close\r\n\r\n"
).encode("utf-8")
chunks: list[bytes] = []
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
with _docker_connection() as sock:
sock.settimeout(10)
sock.connect(sock_path)
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]:
sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
if not Path(sock_path).exists():
raise RuntimeError(f"Docker socket not found: {sock_path}")
if not _docker_available():
raise RuntimeError("Docker control endpoint is unavailable")
request = (
f"POST {path} HTTP/1.1\r\n"
@ -77,7 +97,7 @@ def _docker_post(path: str) -> tuple[int, str]:
).encode("utf-8")
chunks = []
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
with _docker_connection() as sock:
sock.settimeout(20)
sock.connect(sock_path)
sock.sendall(request)

View File

@ -1,4 +1,30 @@
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:
build:
context: .
@ -26,16 +52,14 @@ services:
ports:
- "7890:7890"
# Give the app user access to the Docker socket (GID of docker group on host)
group_add:
- "${DOCKER_GID:-988}"
depends_on:
- docker-socket-proxy
volumes:
- ${VOICE_HOST_DIR:-/home/sparky/Media/_Sounds/TTS_Voices}:/voices:rw
- ./config:/home/app/.config/tts-voice-creator:rw # settings, presets, routes
- ./logs:/logs:rw # application logs
- /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
- ./server.py:/app/server.py:ro
- ./core:/app/core:ro
@ -52,6 +76,7 @@ services:
- TTS_CONTAINER_NAME=tts-faster-qwen3-tts-voiceclone
- TTS_CONTAINER_NAMES=tts-faster-qwen3-tts-voiceclone,tts-faster-qwen3-tts-streaming
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign
- DOCKER_HOST=tcp://docker-socket-proxy:2375
extra_hosts:
- "host.docker.internal:host-gateway"

View File

@ -9,7 +9,7 @@ from urllib.parse import quote
import requests
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.validation import _normalize_service_url
@ -66,7 +66,7 @@ def _container_status(name: str) -> dict:
@router.get("/api/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:
entry = {k: v for k, v in defn.items()}

View File

@ -48,7 +48,7 @@ from core.tts_helpers import (
_fishspeech_request_audio,
)
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")
@ -528,7 +528,7 @@ async def restart_tts_container():
else:
results.append(container)
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:
errors.append(f"{container}: {e}")
@ -552,7 +552,7 @@ async def restart_tts_container():
@router.get("/api/tts/restart-info")
async def tts_restart_info():
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}

View File

@ -95,6 +95,12 @@ class SecurityValidationTests(unittest.TestCase):
finally:
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__":
unittest.main()