feat(exec): require configured secret and header auth for /exec
This commit is contained in:
@@ -9,6 +9,7 @@ CLICKTHROUGH_GRID_COLS=12
|
|||||||
# CLICKTHROUGH_ALLOWED_REGION=0,0,1920,1080
|
# CLICKTHROUGH_ALLOWED_REGION=0,0,1920,1080
|
||||||
|
|
||||||
CLICKTHROUGH_EXEC_ENABLED=true
|
CLICKTHROUGH_EXEC_ENABLED=true
|
||||||
|
CLICKTHROUGH_EXEC_SECRET=replace-with-a-strong-random-secret
|
||||||
CLICKTHROUGH_EXEC_DEFAULT_SHELL=powershell
|
CLICKTHROUGH_EXEC_DEFAULT_SHELL=powershell
|
||||||
CLICKTHROUGH_EXEC_TIMEOUT_S=30
|
CLICKTHROUGH_EXEC_TIMEOUT_S=30
|
||||||
CLICKTHROUGH_EXEC_MAX_TIMEOUT_S=120
|
CLICKTHROUGH_EXEC_MAX_TIMEOUT_S=120
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ Environment variables:
|
|||||||
- `CLICKTHROUGH_GRID_COLS` (default `12`)
|
- `CLICKTHROUGH_GRID_COLS` (default `12`)
|
||||||
- `CLICKTHROUGH_ALLOWED_REGION` (optional `x,y,width,height`)
|
- `CLICKTHROUGH_ALLOWED_REGION` (optional `x,y,width,height`)
|
||||||
- `CLICKTHROUGH_EXEC_ENABLED` (default `true`)
|
- `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_DEFAULT_SHELL` (default `powershell`; one of `powershell`, `bash`, `cmd`)
|
||||||
- `CLICKTHROUGH_EXEC_TIMEOUT_S` (default `30`)
|
- `CLICKTHROUGH_EXEC_TIMEOUT_S` (default `30`)
|
||||||
- `CLICKTHROUGH_EXEC_MAX_TIMEOUT_S` (default `120`)
|
- `CLICKTHROUGH_EXEC_MAX_TIMEOUT_S` (default `120`)
|
||||||
|
|||||||
1
TODO.md
1
TODO.md
@@ -22,3 +22,4 @@
|
|||||||
- [x] Document exec API + config
|
- [x] Document exec API + config
|
||||||
- [x] Create backlog issues for OCR/find/window/input/session-state improvements
|
- [x] Create backlog issues for OCR/find/window/input/session-state improvements
|
||||||
- [ ] Open PR for exec feature branch and review/merge
|
- [ ] Open PR for exec feature branch and review/merge
|
||||||
|
- [x] Require configured exec secret + per-request exec secret header
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ Hotkey:
|
|||||||
|
|
||||||
Execute a shell command on the host running Clickthrough.
|
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: <secret>`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"command": "Get-Process | Select-Object -First 5",
|
"command": "Get-Process | Select-Object -First 5",
|
||||||
@@ -162,6 +166,7 @@ Notes:
|
|||||||
- if `shell` is omitted, server uses `CLICKTHROUGH_EXEC_DEFAULT_SHELL`
|
- if `shell` is omitted, server uses `CLICKTHROUGH_EXEC_DEFAULT_SHELL`
|
||||||
- output is truncated based on `CLICKTHROUGH_EXEC_MAX_OUTPUT_CHARS`
|
- output is truncated based on `CLICKTHROUGH_EXEC_MAX_OUTPUT_CHARS`
|
||||||
- endpoint can be disabled with `CLICKTHROUGH_EXEC_ENABLED=false`
|
- 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.
|
Response includes `stdout`, `stderr`, `exit_code`, timeout state, and execution metadata.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import base64
|
import base64
|
||||||
|
import hmac
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -49,6 +50,7 @@ SETTINGS = {
|
|||||||
"exec_default_timeout_s": int(os.getenv("CLICKTHROUGH_EXEC_TIMEOUT_S", "30")),
|
"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_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_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:
|
def _exec_command(req: ExecRequest) -> dict:
|
||||||
if not SETTINGS["exec_enabled"]:
|
if not SETTINGS["exec_enabled"]:
|
||||||
raise HTTPException(status_code=403, detail="exec endpoint disabled")
|
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
|
run_dry = SETTINGS["dry_run"] or req.dry_run
|
||||||
shell_name = _pick_shell(req.shell)
|
shell_name = _pick_shell(req.shell)
|
||||||
@@ -452,6 +456,7 @@ def health(_: None = Depends(_auth)):
|
|||||||
"allowed_region": SETTINGS["allowed_region"],
|
"allowed_region": SETTINGS["allowed_region"],
|
||||||
"exec": {
|
"exec": {
|
||||||
"enabled": SETTINGS["exec_enabled"],
|
"enabled": SETTINGS["exec_enabled"],
|
||||||
|
"secret_configured": bool(SETTINGS["exec_secret"]),
|
||||||
"default_shell": SETTINGS["exec_default_shell"],
|
"default_shell": SETTINGS["exec_default_shell"],
|
||||||
"default_timeout_s": SETTINGS["exec_default_timeout_s"],
|
"default_timeout_s": SETTINGS["exec_default_timeout_s"],
|
||||||
"max_timeout_s": SETTINGS["exec_max_timeout_s"],
|
"max_timeout_s": SETTINGS["exec_max_timeout_s"],
|
||||||
@@ -575,7 +580,17 @@ def action(req: ActionRequest, _: None = Depends(_auth)):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/exec")
|
@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)
|
result = _exec_command(req)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
|||||||
Reference in New Issue
Block a user