Skip to content

Commit ac9deec

Browse files
security: comprehensive hardening (4 critical, 7 high, 4 medium fixes)
Critical fixes: - C1: API key authentication (CONDUIT_API_KEY env var, hmac.compare_digest) - C2: Pydantic models for all mutation endpoints (ServiceRegister, DnsResolve) - C3: Path traversal guard on SPA fallback (resolve + startswith check) - C4: Remove Docker socket mount from docker-compose.yml High fixes: - H1: CORS middleware restricting to localhost origins - H2: Remove --reload from production Dockerfile CMD - H3: Non-root user (conduit) in Dockerfile - H4: Parse .env files line-by-line instead of bash source (only CONDUIT_ vars) - H5: SSH host validation regex + replace bash -c with direct execution - H6: Caddy admin URL restricted to localhost regex allowlist - H7: Replace RSA 2048 with Ed25519 in tls.sh cert generation Medium fixes: - M1: _validate_service_name on all path parameters - M6: Remove PyPI auto-install from audit.sh (supply chain risk) - M7: Pin Python dependencies to exact versions - M9: Strip stderr from API responses (no internal path leakage)
1 parent c7b9c65 commit ac9deec

File tree

8 files changed

+159
-63
lines changed

8 files changed

+159
-63
lines changed

Dockerfile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ RUN apt-get update \
1414
curl jq openssh-client \
1515
&& rm -rf /var/lib/apt/lists/*
1616

17+
RUN useradd -r -s /bin/false -m conduit
18+
1719
WORKDIR /app
1820
COPY requirements.txt .
1921
RUN pip install --no-cache-dir -r requirements.txt
@@ -24,5 +26,8 @@ COPY lib/ ./lib/
2426
COPY templates/ ./templates/
2527
COPY --from=ui /ui/dist ./ui/dist
2628

29+
RUN chown -R conduit:conduit /app
30+
31+
USER conduit
2732
EXPOSE 9999
28-
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "9999", "--reload", "--reload-dir", "/app"]
33+
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "9999"]

conduit-monitor.sh

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,22 @@ for arg in "$@"; do
5252
esac
5353
done
5454

55+
# Validate SSH host if provided (prevent command injection)
56+
if [[ -n "$SSH_HOST" ]]; then
57+
if ! [[ "$SSH_HOST" =~ ^[a-zA-Z0-9@._-]+$ ]]; then
58+
log_error "Invalid server address: $SSH_HOST"
59+
exit 1
60+
fi
61+
fi
62+
5563
# ---------------------------------------------------------------------------
5664
# Helper: run command locally or via SSH
5765
# ---------------------------------------------------------------------------
5866
_run() {
5967
if [[ -n "$SSH_HOST" ]]; then
6068
ssh -o ConnectTimeout=5 -o BatchMode=yes "$SSH_HOST" "$@" 2>/dev/null
6169
else
62-
bash -c "$*" 2>/dev/null
70+
"$@" 2>/dev/null
6371
fi
6472
}
6573

docker-compose.yml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,12 @@ services:
2121
- ./conduit-preflight.sh:/app/conduit-preflight.sh
2222
- ./lib:/app/lib
2323
- ./templates:/app/templates
24-
- ${CONDUIT_CONFIG_DIR:-~/.config/qp-conduit}:/root/.config/qp-conduit
25-
- /var/run/docker.sock:/var/run/docker.sock
24+
- ${CONDUIT_CONFIG_DIR:-~/.config/qp-conduit}:/home/conduit/.config/qp-conduit
2625
environment:
2726
- CONDUIT_APP_NAME=${CONDUIT_APP_NAME:-qp-conduit}
28-
- CONDUIT_CONFIG_DIR=/root/.config/qp-conduit
27+
- CONDUIT_CONFIG_DIR=/home/conduit/.config/qp-conduit
2928
- CONDUIT_CADDY_ADMIN=${CONDUIT_CADDY_ADMIN:-host.docker.internal:2019}
30-
- CONDUIT_DOCKER_SOCKET=/var/run/docker.sock
29+
- CONDUIT_API_KEY=${CONDUIT_API_KEY:-}
3130
extra_hosts:
3231
- "host.docker.internal:host-gateway"
3332
restart: unless-stopped

lib/audit.sh

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -105,25 +105,10 @@ _ensure_capsule() {
105105
return 0
106106
fi
107107

108-
log_info "qp-capsule CLI not found. Attempting to install via pip..."
109-
110-
local pip_cmd=""
111-
if command -v pip3 &>/dev/null; then
112-
pip_cmd="pip3"
113-
elif command -v pip &>/dev/null; then
114-
pip_cmd="pip"
115-
else
116-
log_warn "pip not found. Cannot auto-install qp-capsule."
117-
return 1
118-
fi
119-
120-
if "$pip_cmd" install --quiet qp-capsule 2>/dev/null; then
121-
log_success "Installed qp-capsule CLI"
122-
return 0
123-
else
124-
log_warn "Failed to install qp-capsule via pip."
125-
return 1
126-
fi
108+
# Do not auto-install from PyPI (supply chain risk, air-gap violation).
109+
# Pre-install qp-capsule in the Docker image or on the host.
110+
log_warn "qp-capsule not found. Install with: pip install qp-capsule"
111+
return 1
127112
}
128113

129114
# ---------------------------------------------------------------------------

lib/common.sh

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,24 @@ ensure_config_dir() {
135135
load_env() {
136136
local env_file="${CONDUIT_ENV_FILE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/.env.conduit}"
137137
if [[ -f "$env_file" ]]; then
138-
# shellcheck disable=SC1090
139-
source "$env_file"
138+
local key value
139+
while IFS='=' read -r key value; do
140+
# Skip comments and empty lines
141+
[[ "$key" =~ ^[[:space:]]*# ]] && continue
142+
[[ -z "$key" ]] && continue
143+
# Strip leading/trailing whitespace
144+
key="${key#"${key%%[![:space:]]*}"}"
145+
key="${key%"${key##*[![:space:]]}"}"
146+
value="${value#"${value%%[![:space:]]*}"}"
147+
value="${value%"${value##*[![:space:]]}"}"
148+
# Strip surrounding quotes from value
149+
value="${value#\"}"
150+
value="${value%\"}"
151+
# Only export known CONDUIT_ prefixed variables
152+
if [[ "$key" =~ ^CONDUIT_ ]]; then
153+
export "$key=$value"
154+
fi
155+
done < "$env_file"
140156
fi
141157
}
142158

lib/tls.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ tls_issue_cert() {
7979
fi
8080

8181
# Generate private key
82-
openssl genrsa -out "${cert_dir}/key.pem" 2048 2>/dev/null
82+
openssl genpkey -algorithm ED25519 -out "${cert_dir}/key.pem" 2>/dev/null
8383
chmod 600 "${cert_dir}/key.pem"
8484

8585
# Generate CSR

requirements.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
fastapi>=0.135
2-
uvicorn>=0.40
3-
pydantic>=2.0
1+
fastapi==0.115.12
2+
uvicorn==0.34.3
3+
pydantic==2.11.4

server.py

Lines changed: 115 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@
66
routing, monitoring, and audit operations.
77
"""
88

9+
import hmac
910
import json
1011
import os
12+
import re
1113
import subprocess
12-
import time
13-
from datetime import datetime, timezone
1414
from pathlib import Path
1515

16-
from fastapi import FastAPI, Query
17-
from fastapi.responses import HTMLResponse, JSONResponse
16+
from fastapi import Depends, FastAPI, Header, HTTPException, Query
17+
from fastapi.middleware.cors import CORSMiddleware
18+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
1819
from fastapi.staticfiles import StaticFiles
20+
from pydantic import BaseModel, Field
1921

2022
# ---------------------------------------------------------------------------
2123
# Configuration
@@ -32,7 +34,80 @@
3234
REGISTRY_PATH = CONFIG_DIR / "services.json"
3335
AUDIT_PATH = CONFIG_DIR / "audit.log"
3436

35-
app = FastAPI(title="QP Conduit", version="0.1.0")
37+
# API key for authentication (required in production, optional in dev)
38+
API_KEY = os.environ.get("CONDUIT_API_KEY", "")
39+
40+
# Caddy admin URL (restricted to localhost by default)
41+
CADDY_ADMIN_HOST = os.environ.get("CONDUIT_CADDY_ADMIN", "localhost:2019")
42+
_CADDY_ADMIN_ALLOWED = re.compile(r"^(localhost|127\.0\.0\.1|host\.docker\.internal)(:\d+)?$")
43+
44+
45+
# ---------------------------------------------------------------------------
46+
# Input validation patterns
47+
# ---------------------------------------------------------------------------
48+
49+
SERVICE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}[a-zA-Z0-9]$|^[a-zA-Z0-9]$")
50+
HOST_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,253}[a-zA-Z0-9]$")
51+
HEALTH_PATH_RE = re.compile(r"^/[a-zA-Z0-9/_.-]{0,255}$")
52+
DOMAIN_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,253}[a-zA-Z0-9]$")
53+
54+
55+
def _validate_service_name(name: str) -> str:
56+
"""Validate and return a service name, or raise 422."""
57+
if not SERVICE_NAME_RE.match(name):
58+
raise HTTPException(422, f"Invalid service name: must match [a-zA-Z0-9_-]")
59+
return name
60+
61+
62+
# ---------------------------------------------------------------------------
63+
# Pydantic models
64+
# ---------------------------------------------------------------------------
65+
66+
67+
class ServiceRegister(BaseModel):
68+
name: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
69+
host: str = Field(..., min_length=3, max_length=255, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9._:-]*$")
70+
health_path: str = Field(default="/", max_length=256, pattern=r"^/[a-zA-Z0-9/_.-]*$")
71+
no_tls: bool = False
72+
73+
74+
class DnsResolve(BaseModel):
75+
domain: str = Field(..., min_length=1, max_length=255, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
76+
77+
78+
# ---------------------------------------------------------------------------
79+
# Authentication
80+
# ---------------------------------------------------------------------------
81+
82+
83+
async def verify_api_key(x_api_key: str = Header(default="")):
84+
"""Verify API key if CONDUIT_API_KEY is set. Skip auth if unset (dev mode)."""
85+
if not API_KEY:
86+
return # No key configured: dev mode, allow all
87+
if not x_api_key or not hmac.compare_digest(x_api_key, API_KEY):
88+
raise HTTPException(401, "Invalid or missing API key")
89+
90+
91+
# ---------------------------------------------------------------------------
92+
# App setup
93+
# ---------------------------------------------------------------------------
94+
95+
app = FastAPI(
96+
title="QP Conduit",
97+
version="0.1.0",
98+
dependencies=[Depends(verify_api_key)],
99+
)
100+
101+
app.add_middleware(
102+
CORSMiddleware,
103+
allow_origins=[
104+
"http://localhost:9999",
105+
"http://localhost:5173",
106+
"https://localhost:9999",
107+
],
108+
allow_methods=["GET", "POST", "PUT", "DELETE"],
109+
allow_headers=["X-API-Key", "Content-Type"],
110+
)
36111

37112

38113
# ---------------------------------------------------------------------------
@@ -41,7 +116,7 @@
41116

42117

43118
def _run(script: str, *args: str, timeout: int = 30) -> dict:
44-
"""Run a conduit script and return stdout, stderr, exit code."""
119+
"""Run a conduit script and return stdout, exit code. Stderr is logged, not returned."""
45120
cmd = [str(HERE / script)] + list(args)
46121
try:
47122
result = subprocess.run(
@@ -50,13 +125,12 @@ def _run(script: str, *args: str, timeout: int = 30) -> dict:
50125
return {
51126
"ok": result.returncode == 0,
52127
"stdout": result.stdout,
53-
"stderr": result.stderr,
54128
"exit_code": result.returncode,
55129
}
56130
except subprocess.TimeoutExpired:
57-
return {"ok": False, "stdout": "", "stderr": "Command timed out", "exit_code": -1}
131+
return {"ok": False, "stdout": "", "exit_code": -1}
58132
except FileNotFoundError:
59-
return {"ok": False, "stdout": "", "stderr": f"Script not found: {script}", "exit_code": -1}
133+
return {"ok": False, "stdout": "", "exit_code": -1}
60134

61135

62136
def _read_json(path: Path, default=None):
@@ -84,12 +158,19 @@ def _read_audit(limit: int = 50) -> list[dict]:
84158
return []
85159

86160

161+
def _caddy_admin_url() -> str:
162+
"""Return validated Caddy admin URL. Restricted to localhost."""
163+
if not _CADDY_ADMIN_ALLOWED.match(CADDY_ADMIN_HOST):
164+
return "localhost:2019" # Fallback to safe default
165+
return CADDY_ADMIN_HOST
166+
167+
87168
def _caddy_status() -> bool:
88169
"""Check if Caddy admin API is reachable."""
89-
admin_url = os.environ.get("CONDUIT_CADDY_ADMIN", "localhost:2019")
170+
url = _caddy_admin_url()
90171
try:
91172
result = subprocess.run(
92-
["curl", "-sf", f"http://{admin_url}/config/"],
173+
["curl", "-sf", f"http://{url}/config/"],
93174
capture_output=True, timeout=3,
94175
)
95176
return result.returncode == 0
@@ -151,17 +232,12 @@ def list_services():
151232

152233

153234
@app.post("/api/services")
154-
def register_service(body: dict):
235+
def register_service(body: ServiceRegister):
155236
"""Register a new service."""
156-
name = body.get("name", "")
157-
host = body.get("host", "")
158-
health = body.get("health_path", "/")
159-
no_tls = body.get("no_tls", False)
160-
161-
args = ["--name", name, "--host", host]
162-
if health and health != "/":
163-
args += ["--health", health]
164-
if no_tls:
237+
args = ["--name", body.name, "--host", body.host]
238+
if body.health_path and body.health_path != "/":
239+
args += ["--health", body.health_path]
240+
if body.no_tls:
165241
args.append("--no-tls")
166242

167243
result = _run("conduit-register.sh", *args)
@@ -171,17 +247,19 @@ def register_service(body: dict):
171247
@app.delete("/api/services/{name}")
172248
def deregister_service(name: str):
173249
"""Deregister a service."""
250+
_validate_service_name(name)
174251
result = _run("conduit-deregister.sh", "--name", name)
175252
return result
176253

177254

178255
@app.get("/api/services/{name}/health")
179256
def service_health(name: str):
180257
"""Check health of a specific service."""
258+
_validate_service_name(name)
181259
services = _read_json(REGISTRY_PATH, {"services": []}).get("services", [])
182260
svc = next((s for s in services if s.get("name") == name), None)
183261
if not svc:
184-
return JSONResponse({"error": f"Service '{name}' not found"}, status_code=404)
262+
return JSONResponse({"error": "Service not found"}, status_code=404)
185263
return {"name": name, "status": svc.get("status", "unknown")}
186264

187265

@@ -198,11 +276,10 @@ def list_dns():
198276

199277

200278
@app.post("/api/dns/resolve")
201-
def resolve_dns(body: dict):
279+
def resolve_dns(body: DnsResolve):
202280
"""Resolve a domain name."""
203-
domain = body.get("domain", "")
204-
result = _run("conduit-dns.sh", "--resolve", domain)
205-
return {"ok": result["ok"], "domain": domain, "output": result["stdout"]}
281+
result = _run("conduit-dns.sh", "--resolve", body.domain)
282+
return {"ok": result["ok"], "domain": body.domain, "output": result["stdout"]}
206283

207284

208285
@app.post("/api/dns/flush")
@@ -227,13 +304,15 @@ def list_certs():
227304
@app.post("/api/tls/{name}/rotate")
228305
def rotate_cert(name: str):
229306
"""Rotate a certificate."""
307+
_validate_service_name(name)
230308
result = _run("conduit-certs.sh", "--rotate", name)
231309
return result
232310

233311

234312
@app.get("/api/tls/{name}/inspect")
235313
def inspect_cert(name: str):
236314
"""Inspect a certificate."""
315+
_validate_service_name(name)
237316
result = _run("conduit-certs.sh", "--inspect", name)
238317
return {"ok": result["ok"], "output": result["stdout"]}
239318

@@ -291,10 +370,10 @@ def list_routes():
291370
@app.post("/api/routing/reload")
292371
def reload_routing():
293372
"""Reload Caddy configuration."""
294-
admin_url = os.environ.get("CONDUIT_CADDY_ADMIN", "localhost:2019")
373+
url = _caddy_admin_url()
295374
try:
296375
result = subprocess.run(
297-
["curl", "-sf", "-X", "POST", f"http://{admin_url}/load"],
376+
["curl", "-sf", "-X", "POST", f"http://{url}/load"],
298377
capture_output=True, timeout=5,
299378
)
300379
return {"ok": result.returncode == 0}
@@ -325,13 +404,17 @@ def get_audit(limit: int = Query(50, ge=1, le=500)):
325404
app.mount("/assets", StaticFiles(directory=str(UI_ASSETS)), name="assets")
326405

327406
if UI_DIST.exists():
407+
_UI_DIST_RESOLVED = UI_DIST.resolve()
328408

329409
@app.get("/{path:path}")
330410
def spa_fallback(path: str):
331-
# Serve specific files from dist (e.g., vite.svg, favicon)
332-
file_path = UI_DIST / path
333-
if path and file_path.exists() and file_path.is_file():
334-
return HTMLResponse(file_path.read_bytes())
411+
# Path traversal guard
412+
if path:
413+
file_path = (UI_DIST / path).resolve()
414+
if not str(file_path).startswith(str(_UI_DIST_RESOLVED)):
415+
raise HTTPException(403, "Forbidden")
416+
if file_path.exists() and file_path.is_file():
417+
return FileResponse(str(file_path))
335418
# SPA fallback: serve index.html for all routes
336419
index = UI_DIST / "index.html"
337420
if index.exists():

0 commit comments

Comments
 (0)