fix(gluetun): stop leaking proxy/vpn secrets to process list

- Switch docker run to use a temporary --env-file instead of per-var -e flags\n- Ensure temp env file is always removed (best-effort overwrite + unlink)\n- Tighten _is_container_running to exact-name matching via anchored docker filter\n- Close requests.Session used for IP verification to release connections\n- Redact more secret-like env keys in debug logs\n
This commit is contained in:
Andy
2026-02-07 19:22:13 -07:00
parent 774b9ba96c
commit a04f1ad4db

View File

@@ -2,7 +2,9 @@ import atexit
import logging
import os
import re
import stat
import subprocess
import tempfile
import threading
import time
from typing import Optional
@@ -750,7 +752,8 @@ class Gluetun(Proxy):
# Debug log environment variables (redact sensitive values)
if debug_logger:
safe_env = {k: ("***" if "KEY" in k or "PASSWORD" in k else v) for k, v in env_vars.items()}
redact_markers = ("KEY", "PASSWORD", "PASS", "TOKEN", "SECRET", "USER")
safe_env = {k: ("***" if any(m in k for m in redact_markers) else v) for k, v in env_vars.items()}
debug_logger.log(
level="DEBUG",
operation="gluetun_env_vars",
@@ -771,12 +774,40 @@ class Gluetun(Proxy):
f"127.0.0.1:{port}:8888/tcp",
]
# Add environment variables
# Avoid exposing credentials in process listings by using --env-file instead of many "-e KEY=VALUE".
env_file_path: str | None = None
try:
fd, env_file_path = tempfile.mkstemp(prefix=f"unshackle-{container_name}-", suffix=".env")
try:
# Best-effort restrictive permissions.
if os.name != "nt":
if hasattr(os, "fchmod"):
os.fchmod(fd, 0o600)
else:
os.chmod(env_file_path, 0o600)
else:
os.chmod(env_file_path, stat.S_IREAD | stat.S_IWRITE)
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
for key, value in env_vars.items():
cmd.extend(["-e", f"{key}={value}"])
if "=" in key:
raise ValueError(f"Invalid env var name for docker env-file: {key!r}")
v = "" if value is None else str(value)
if "\n" in v or "\r" in v:
raise ValueError(f"Invalid env var value (contains newline) for {key!r}")
f.write(f"{key}={v}\n")
except Exception:
# If we fail before fdopen closes the descriptor, make sure it's not leaked.
try:
os.close(fd)
except Exception:
pass
raise
cmd.extend(["--env-file", env_file_path])
# Add Gluetun image
cmd.append("qmcgaw/gluetun:latest")
cmd.append(gluetun_image)
# Execute docker run
try:
@@ -788,6 +819,17 @@ class Gluetun(Proxy):
encoding="utf-8",
errors="replace",
)
except subprocess.TimeoutExpired:
if debug_logger:
debug_logger.log(
level="ERROR",
operation="gluetun_container_create_timeout",
message=f"Docker run timed out for {container_name}",
context={"container_name": container_name},
success=False,
duration_ms=(time.time() - start_time) * 1000,
)
raise RuntimeError("Docker run command timed out")
if result.returncode != 0:
error_msg = result.stderr or "unknown error"
@@ -826,29 +868,51 @@ class Gluetun(Proxy):
success=True,
duration_ms=duration_ms,
)
except subprocess.TimeoutExpired:
if debug_logger:
debug_logger.log(
level="ERROR",
operation="gluetun_container_create_timeout",
message=f"Docker run timed out for {container_name}",
context={"container_name": container_name},
success=False,
duration_ms=(time.time() - start_time) * 1000,
)
raise RuntimeError("Docker run command timed out")
finally:
if env_file_path:
# Best-effort "secure delete": overwrite then unlink (not guaranteed on all filesystems).
try:
with open(env_file_path, "r+b") as f:
try:
f.seek(0, os.SEEK_END)
length = f.tell()
f.seek(0)
if length > 0:
f.write(b"\x00" * length)
f.flush()
os.fsync(f.fileno())
except Exception:
pass
except Exception:
pass
try:
os.remove(env_file_path)
except FileNotFoundError:
pass
except Exception:
pass
def _is_container_running(self, container_name: str) -> bool:
"""Check if a Docker container is running."""
try:
result = subprocess.run(
["docker", "ps", "--filter", f"name={container_name}", "--format", "{{.Names}}"],
[
"docker",
"ps",
"--filter",
f"name=^{re.escape(container_name)}$",
"--format",
"{{.Names}}",
],
capture_output=True,
text=True,
timeout=5,
)
return result.returncode == 0 and container_name in result.stdout
if result.returncode != 0:
return False
names = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
return any(name == container_name for name in names)
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
@@ -1132,6 +1196,7 @@ class Gluetun(Proxy):
# Create a session with the proxy configured
session = requests.Session()
try:
session.proxies = {"http": proxy_url, "https": proxy_url}
# Retry with exponential backoff
@@ -1224,6 +1289,11 @@ class Gluetun(Proxy):
if attempt < max_retries - 1:
wait_time = 2**attempt # 1, 2, 4 seconds
time.sleep(wait_time)
finally:
try:
session.close()
except Exception:
pass
# All retries exhausted
duration_ms = (time.time() - start_time) * 1000