diff --git a/.env.example b/.env.example index 2ebd111..db26eed 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,7 @@ CLICKTHROUGH_GRID_COLS=12 # CLICKTHROUGH_ALLOWED_REGION=0,0,1920,1080 CLICKTHROUGH_EXEC_ENABLED=true +CLICKTHROUGH_EXEC_SECRET=replace-with-a-strong-random-secret CLICKTHROUGH_EXEC_DEFAULT_SHELL=powershell CLICKTHROUGH_EXEC_TIMEOUT_S=30 CLICKTHROUGH_EXEC_MAX_TIMEOUT_S=120 diff --git a/README.md b/README.md index cfbd57e..b57fc1c 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Environment variables: - `CLICKTHROUGH_GRID_COLS` (default `12`) - `CLICKTHROUGH_ALLOWED_REGION` (optional `x,y,width,height`) - `CLICKTHROUGH_EXEC_ENABLED` (default `true`) +- `CLICKTHROUGH_EXEC_SECRET` (**required for `/exec` to run**) - `CLICKTHROUGH_EXEC_DEFAULT_SHELL` (default `powershell`; one of `powershell`, `bash`, `cmd`) - `CLICKTHROUGH_EXEC_TIMEOUT_S` (default `30`) - `CLICKTHROUGH_EXEC_MAX_TIMEOUT_S` (default `120`) diff --git a/TODO.md b/TODO.md index 4a326c7..a5e56aa 100644 --- a/TODO.md +++ b/TODO.md @@ -22,3 +22,4 @@ - [x] Document exec API + config - [x] Create backlog issues for OCR/find/window/input/session-state improvements - [ ] Open PR for exec feature branch and review/merge +- [x] Require configured exec secret + per-request exec secret header diff --git a/docs/API.md b/docs/API.md index 1c9bf7d..26b6237 100644 --- a/docs/API.md +++ b/docs/API.md @@ -147,6 +147,10 @@ Hotkey: Execute a shell command on the host running Clickthrough. +Requirements: +- `CLICKTHROUGH_EXEC_SECRET` must be configured on the server +- send header `x-clickthrough-exec-secret: ` + ```json { "command": "Get-Process | Select-Object -First 5", @@ -162,6 +166,7 @@ Notes: - if `shell` is omitted, server uses `CLICKTHROUGH_EXEC_DEFAULT_SHELL` - output is truncated based on `CLICKTHROUGH_EXEC_MAX_OUTPUT_CHARS` - endpoint can be disabled with `CLICKTHROUGH_EXEC_ENABLED=false` +- if `CLICKTHROUGH_EXEC_SECRET` is missing, `/exec` is blocked (`403`) Response includes `stdout`, `stderr`, `exit_code`, timeout state, and execution metadata. diff --git a/server/app.py b/server/app.py index ab30f76..602fd6c 100644 --- a/server/app.py +++ b/server/app.py @@ -1,4 +1,5 @@ import base64 +import hmac import io import os import subprocess @@ -49,6 +50,7 @@ SETTINGS = { "exec_default_timeout_s": int(os.getenv("CLICKTHROUGH_EXEC_TIMEOUT_S", "30")), "exec_max_timeout_s": int(os.getenv("CLICKTHROUGH_EXEC_MAX_TIMEOUT_S", "120")), "exec_max_output_chars": int(os.getenv("CLICKTHROUGH_EXEC_MAX_OUTPUT_CHARS", "20000")), + "exec_secret": os.getenv("CLICKTHROUGH_EXEC_SECRET", "").strip(), } @@ -299,6 +301,8 @@ def _resolve_exec_program(shell_name: str, command: str) -> list[str]: def _exec_command(req: ExecRequest) -> dict: if not SETTINGS["exec_enabled"]: raise HTTPException(status_code=403, detail="exec endpoint disabled") + if not SETTINGS["exec_secret"]: + raise HTTPException(status_code=403, detail="exec secret not configured") run_dry = SETTINGS["dry_run"] or req.dry_run shell_name = _pick_shell(req.shell) @@ -452,6 +456,7 @@ def health(_: None = Depends(_auth)): "allowed_region": SETTINGS["allowed_region"], "exec": { "enabled": SETTINGS["exec_enabled"], + "secret_configured": bool(SETTINGS["exec_secret"]), "default_shell": SETTINGS["exec_default_shell"], "default_timeout_s": SETTINGS["exec_default_timeout_s"], "max_timeout_s": SETTINGS["exec_max_timeout_s"], @@ -575,7 +580,17 @@ def action(req: ActionRequest, _: None = Depends(_auth)): @app.post("/exec") -def exec_command(req: ExecRequest, _: None = Depends(_auth)): +def exec_command( + req: ExecRequest, + x_clickthrough_exec_secret: Optional[str] = Header(default=None), + _: None = Depends(_auth), +): + expected = SETTINGS["exec_secret"] + if not expected: + raise HTTPException(status_code=403, detail="exec secret not configured") + if not x_clickthrough_exec_secret or not hmac.compare_digest(x_clickthrough_exec_secret, expected): + raise HTTPException(status_code=401, detail="invalid exec secret") + result = _exec_command(req) return { "ok": True,