security: isolate docker socket behind proxy
This commit is contained in:
parent
4a0101f4a4
commit
742b43d860
@ -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.
|
||||
|
||||
8
TODO.md
8
TODO.md
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user