diff --git a/README.md b/README.md index f467298..f83d674 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,9 @@ at different directories or by using **p Root dir** in the TUI to switch at runt ~/.local/share/ssltui/ ├── ca.key # CA private key (0600) ├── ca.crt # CA certificate (self-signed) -├── index.json # issued cert metadata +├── ca.db # SQLite store: cert index, revocations, event log, counters +├── ca.crl # certificate revocation list (PEM) +├── api_token # generated API/dashboard bearer token (0600) ├── certs/ │ └── / │ ├── cert.crt # signed leaf certificate @@ -85,6 +87,12 @@ at different directories or by using **p Root dir** in the TUI to switch at runt └── renewal.log ``` +Cert and key material stays as flat PEM files under `certs//`; all +metadata — the cert index, revocation list, an append-only event log, and the +serial / CRL-number counters — lives in the single SQLite database `ca.db`. +It runs in WAL mode so the TUI, the cron `renew` job, and the Flask API can +read and write concurrently without explicit file locking. + ## Using the TUI The interactive TUI is the default mode (`uv run ssltui`). Press **i** to @@ -239,6 +247,14 @@ If you expose the server beyond localhost, treat that token like a secret. The API can return private keys, so it should only be reachable from trusted test infrastructure. +### Input validation + +The `` path parameter is validated against a strict hostname allow-list +(DNS hostnames and wildcards only). Anything else — path separators, `..`, or +control characters — is rejected with `400 Bad Request` before any lookup. +Certificate, key, and chain downloads are confined to the CA's `certs//` +directory, so a request can never read files outside it. + ### Endpoints - `GET /api/v1/certs` lists all certificates diff --git a/ssltui/api.py b/ssltui/api.py index 65afa07..2066f31 100644 --- a/ssltui/api.py +++ b/ssltui/api.py @@ -1213,6 +1213,13 @@ def _unexpected_error(e: Exception): # --- Internal helpers --- def _entry_or_404(cn: str) -> dict: + # Validate against the strict hostname allow-list before the CN is used + # for any lookup or path derivation — an explicit sanitizer for every + # endpoint that takes a route parameter. + try: + cn = config.validate_cn(cn) + except ValueError as exc: + abort(400, description=str(exc)) entry = store.get_cert(root, cn) if entry is None: abort(404, description=f"No cert found for CN={cn!r}") @@ -1243,6 +1250,10 @@ def issue(): cn = (body.get("cn") or "").strip() if not cn: abort(400, description="cn is required") + try: + cn = config.validate_cn(cn) + except ValueError as exc: + abort(400, description=str(exc)) if store.get_cert(root, cn) is not None: abort( 409, @@ -1277,21 +1288,26 @@ def renew(cn: str): abort(400, description=str(exc)) return jsonify(meta) # type: ignore[return-value] + # Downloads re-derive the path from the containment-checked cert_dir rather + # than trusting the stored path string, so a tampered ca.db cannot redirect + # a read outside certs//. @app.get("/api/v1/certs//cert.pem") def download_cert(cn: str): - entry = _entry_or_404(cn) - return _pem_response(Path(entry["cert"]), f"{_safe(cn)}.crt") + _entry_or_404(cn) + return _pem_response(config.cert_dir(root, cn) / "cert.crt", f"{_safe(cn)}.crt") @app.get("/api/v1/certs//key.pem") def download_key(cn: str): - entry = _entry_or_404(cn) + _entry_or_404(cn) store.add_event(root, "key_download", cn=cn, method="api") - return _pem_response(Path(entry["key"]), f"{_safe(cn)}.key") + return _pem_response(config.cert_dir(root, cn) / "cert.key", f"{_safe(cn)}.key") @app.get("/api/v1/certs//chain.pem") def download_chain(cn: str): - entry = _entry_or_404(cn) - return _pem_response(Path(entry["chain"]), f"{_safe(cn)}-chain.pem") + _entry_or_404(cn) + return _pem_response( + config.cert_dir(root, cn) / "chain.crt", f"{_safe(cn)}-chain.pem" + ) # --- Dashboard auth helpers --- @@ -1386,18 +1402,16 @@ def _generate(): @app.get("/dashboard/certs//cert.pem") @_require_session_api def dashboard_cert_download(cn: str): - entry = store.get_cert(root, cn) - if not entry: - abort(404) - return _pem_response(Path(entry["cert"]), f"{_safe(cn)}.crt") + _entry_or_404(cn) + return _pem_response(config.cert_dir(root, cn) / "cert.crt", f"{_safe(cn)}.crt") @app.get("/dashboard/certs//chain.pem") @_require_session_api def dashboard_chain_download(cn: str): - entry = store.get_cert(root, cn) - if not entry: - abort(404) - return _pem_response(Path(entry["chain"]), f"{_safe(cn)}-chain.pem") + _entry_or_404(cn) + return _pem_response( + config.cert_dir(root, cn) / "chain.crt", f"{_safe(cn)}-chain.pem" + ) # The root CA cert and CRL are public material (the CA cert is sent in every # TLS handshake; the CRL is meant to be published). They are served without a diff --git a/ssltui/ca.py b/ssltui/ca.py index 4e82fd0..94b6687 100644 --- a/ssltui/ca.py +++ b/ssltui/ca.py @@ -177,6 +177,11 @@ def issue_cert( if not config.ca_key_path(root).exists(): raise CAError("CA not initialised. Run init_ca() first.") + try: + cn = config.validate_cn(cn) + except ValueError as exc: + raise CAError(str(exc)) from exc + validity_days = min(validity_days, config.LEAF_VALIDITY_MAX) san_list = _build_san_list(cn, sans) diff --git a/ssltui/config.py b/ssltui/config.py index 192e4e8..ff6ca0b 100644 --- a/ssltui/config.py +++ b/ssltui/config.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re from pathlib import Path # --------------------------------------------------------------------------- @@ -40,7 +41,17 @@ def db_path(root: Path) -> Path: def cert_dir(root: Path, cn: str) -> Path: - return root / "certs" / _safe_cn(cn) + # Validate against the strict hostname allow-list first: this is the + # primary sanitizer for any CN that reaches a filesystem path, so every + # caller (issue/renew/revoke) is covered regardless of prior validation. + cn = validate_cn(cn) + base = (root / "certs").resolve() + candidate = (base / _safe_cn(cn)).resolve() + # Defense in depth: must be exactly one level under certs/ — blocks "..", + # absolute paths, and any other traversal that survives _safe_cn. + if candidate.parent != base: + raise ValueError(f"Invalid certificate common name: {cn!r}") + return candidate def crl_path(root: Path) -> Path: @@ -74,6 +85,23 @@ def renewal_log_path(root: Path) -> Path: # --------------------------------------------------------------------------- +# CN is a DNS hostname or wildcard label — strict allow-list. No path +# separators, no traversal, no control characters. +_CN_RE = re.compile(r"^(\*\.)?([A-Za-z0-9_-]+\.)*[A-Za-z0-9_-]+$") + + +def validate_cn(cn: str) -> str: + """Validate a certificate CN against a strict hostname allow-list. + + Returns the stripped CN. Raises ValueError on anything that isn't a plain + DNS hostname or wildcard (rejecting path separators, ``..``, control chars). + """ + cn = cn.strip() + if not cn or len(cn) > 253 or not _CN_RE.match(cn): + raise ValueError(f"Invalid certificate common name: {cn!r}") + return cn + + def _safe_cn(cn: str) -> str: """Map a CN to a safe filesystem name.""" return cn.replace("*", "wildcard").replace("/", "_")