Skip to content

Commit b3229a4

Browse files
authored
feat: opt-in asciicast v2 session recording (#149)
1 parent 664c5f7 commit b3229a4

4 files changed

Lines changed: 362 additions & 0 deletions

File tree

docs/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ X-Forwarded-For trust.
5858
| `WEBSH_TMUX_CAPTURE_LINES` | `100000` | Max lines `/api/tmux_capture` reads from the tmux scrollback (`-S -N`); bounds capture RAM. |
5959
| `WEBSH_TMUX_CAPTURE_BYTES` | `16777216` (16 MiB) | Absolute byte ceiling on a tmux capture; output past it is truncated to the freshest tail with a marker. |
6060
| `WEBSH_ACCESS_LOG` | *(unset)* | Path to a JSON-line access log; when unset, no access log is written. See [`security.md`](security.md#access-log) for the record format. |
61+
| `WEBSH_RECORD_DIR` | *(unset)* | Opt-in session recording: directory for one [asciicast v2](https://docs.asciinema.org/manual/asciicast/v2/) `.cast` file per session (created `0600`, named `<timestamp>-<sid>.cast`; replayable with `asciinema play`). Output-only by default. Best-effort: a write failure disables recording for that session, never the session itself. Mind retention/privacy — see [`security.md`](security.md#session-recording). |
62+
| `WEBSH_RECORD_INPUT` | `0` | With recording on, `1` also records keystrokes (`"i"` events). **Everything typed into the remote shell lands in the file — including passwords typed at prompts inside the session.** The browser-form ssh password is not recorded as input (the auto-type bypasses the tee); see the echo caveat in [`security.md`](security.md#session-recording). |
6163

6264
The PHP proxy reads `WEBSH_PORT` (default `8765`) to find the backend —
6365
and since the alias rule above makes `server.py` honor `WEBSH_PORT`

docs/security.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,30 @@ Known limitation: persistent tmux **slots** live on the target host
213213
keyed by slot id; ownership is enforced on websh sessions, while
214214
`resume_slot_id` re-attachment authenticates through ssh itself (the
215215
user must still hold valid credentials for the target).
216+
217+
## Session recording
218+
219+
`WEBSH_RECORD_DIR` writes one asciicast v2 file per session. Recording is
220+
**output-only**: keystroke/input recording is hard-disabled (the input tee
221+
is compiled out), so passwords typed at a prompt *inside* the session are
222+
never written. `WEBSH_RECORD_INPUT` is ignored — if set, the server logs a
223+
warning at startup and records output only. Treat the directory as
224+
sensitive:
225+
226+
- terminal output routinely contains secrets (cat'ed configs, env dumps),
227+
so the recordings are sensitive even without keystrokes;
228+
- files are created `0600` under the server user, but there is no built-in
229+
rotation or retention — pair it with a tmpwatch/logrotate policy and tell
230+
your users they are being recorded where the law requires it;
231+
- the browser-form ssh password is never recorded as input (the auto-type
232+
happened below the input tee, which is now removed). One caveat on the
233+
output side: a malicious/non-OpenSSH target that prints a password-looking
234+
prompt WITHOUT disabling terminal echo would cause the auto-typed password
235+
to echo back into the output stream — and therefore into the recording. A
236+
genuine OpenSSH prompt disables echo first, so the standard flow never
237+
records it; the hostile-server case already hands the password to the
238+
attacker, the recording merely adds local persistence. Also add
239+
`WEBSH_RECORD_MAX_BYTES` (default 64 MiB) to your sizing math — the cap
240+
stops a runaway recording, not the session.
241+
242+
Replay with `asciinema play <file>` or any asciicast v2 player.

server.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,27 @@ def _int_env(name, default):
192192
# client talking to the backend directly cannot mint identities; with
193193
# the feature on, an untrusted peer is simply unauthenticated (401).
194194
WEBSH_AUTH_HEADER = os.environ.get("WEBSH_AUTH_HEADER", "").strip()
195+
# Opt-in session recording (asciicast v2, one .cast file per session,
196+
# created 0600). OFF unless WEBSH_RECORD_DIR names a directory the
197+
# server user can write. Recording is OUTPUT-ONLY; keystroke/input
198+
# recording is hard-disabled (see WEBSH_RECORD_INPUT below). Recording is
199+
# best-effort: a write failure disables it for that session, never the PTY.
200+
WEBSH_RECORD_DIR = os.environ.get("WEBSH_RECORD_DIR", "").strip()
201+
# Input recording (keystrokes — which include any password typed at a
202+
# sudo/login prompt INSIDE the session) is HARD-DISABLED. Capturing
203+
# keystrokes to disk on a public-facing deployment is a liability we do
204+
# not accept, so recording is always output-only regardless of the
205+
# environment. The env var is read ONLY to warn an operator who sets it
206+
# (see main()). The browser-form ssh password was never recorded either
207+
# way (it is auto-typed below the input tee).
208+
_WEBSH_RECORD_INPUT_REQUESTED = os.environ.get("WEBSH_RECORD_INPUT") == "1"
209+
WEBSH_RECORD_INPUT = False
210+
# Per-file ceiling. A `cat /dev/urandom` session would otherwise write
211+
# unbounded (and invalid UTF-8 inflates ~3-5x through replacement +
212+
# JSON escaping). Hitting the cap stops the recording with one WARN;
213+
# the session itself is never touched. 0 disables the cap.
214+
WEBSH_RECORD_MAX_BYTES = max(0, _int_env("WEBSH_RECORD_MAX_BYTES",
215+
str(64 * 1024 * 1024)))
195216
SESSION_TIMEOUT = _int_env("SESSION_TIMEOUT", "300")
196217
MAX_SESSIONS = _int_env("MAX_SESSIONS", "50")
197218
# Per-source-IP active session cap. 0 disables the check (preserve legacy
@@ -1583,6 +1604,10 @@ def __init__(self, session_id, host, port, username, password, cols, rows,
15831604
self.pid = None
15841605
self.output_buf = b""
15851606
self.buf_lock = Lock()
1607+
# Session recording (asciicast v2); None when disabled.
1608+
self._rec = None
1609+
self._rec_t0 = 0.0
1610+
self._rec_lock = Lock()
15861611
# Cross-thread wake signal: the PTY read-loop calls _signal() (which
15871612
# is _data_event.set()) after every output_buf update; consumers
15881613
# (_stream / _output) park in wait_for_data() which waits on the
@@ -1769,6 +1794,10 @@ def _spawn(self, host, port, username, cols, rows):
17691794

17701795
ssh_cmd = self._build_ssh_cmd(host, port, username)
17711796

1797+
# Open the recording before the fork so even the earliest PTY
1798+
# bytes (banner, password prompt) land in the file.
1799+
self._rec_open(cols, rows)
1800+
17721801
pid, fd = pty.fork()
17731802
if pid == 0:
17741803
# In the forked child: only call os._exit on execvpe failure.
@@ -1802,6 +1831,98 @@ def _set_winsize(self, cols, rows):
18021831

18031832
# ── Read loop / output buffer ───────────────────────────────────
18041833

1834+
# ── Session recording (asciicast v2) ────────────────────────────
1835+
1836+
def _rec_open(self, cols, rows):
1837+
"""Start recording when WEBSH_RECORD_DIR is set. Best-effort."""
1838+
if not WEBSH_RECORD_DIR:
1839+
return
1840+
try:
1841+
# 0700 + explicit chmod (makedirs mode is ignored for an
1842+
# existing dir): the FILENAME carries the live session id —
1843+
# the API's only bearer credential — so a world-listable
1844+
# directory would let any local user hijack sessions.
1845+
os.makedirs(WEBSH_RECORD_DIR, mode=0o700, exist_ok=True)
1846+
os.chmod(WEBSH_RECORD_DIR, 0o700)
1847+
name = "%s-%s.cast" % (time.strftime("%Y%m%d-%H%M%S"), self.id)
1848+
path = os.path.join(WEBSH_RECORD_DIR, name)
1849+
# O_EXCL: the sid is unique, so a collision means something
1850+
# is impersonating the path — refuse rather than truncate.
1851+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
1852+
# Line-buffered so a crash loses at most one event and the
1853+
# file can be live-tailed, matching asciinema's behavior.
1854+
self._rec = os.fdopen(fd, "w", encoding="utf-8", buffering=1)
1855+
self._rec_t0 = time.monotonic()
1856+
self._rec_bytes = 0
1857+
# Incremental decoders (one per direction): a multibyte
1858+
# UTF-8 sequence split across PTY reads must not become
1859+
# U+FFFD at every chunk boundary.
1860+
import codecs as _codecs
1861+
self._rec_dec = {}
1862+
self._rec_mkdec = lambda: _codecs.getincrementaldecoder(
1863+
"utf-8")("replace")
1864+
self._rec.write(json.dumps({
1865+
"version": 2,
1866+
"width": cols,
1867+
"height": rows,
1868+
"timestamp": int(time.time()),
1869+
"env": {"TERM": "xterm-256color"},
1870+
}) + "\n")
1871+
except Exception as e:
1872+
_log("WARN", "session {} recording disabled: {}".format(
1873+
self.id, e))
1874+
self._rec = None
1875+
1876+
def _record(self, kind, text):
1877+
"""Append one event ([t, kind, text] JSONL). Never raises: a
1878+
broken recording must not kill the session — it logs once and
1879+
disables itself. getattr: tests (and the cleanup loop) touch
1880+
sessions built via __new__ that never ran __init__."""
1881+
if getattr(self, "_rec", None) is None:
1882+
return
1883+
try:
1884+
if isinstance(text, bytes):
1885+
dec = self._rec_dec.get(kind)
1886+
if dec is None:
1887+
dec = self._rec_dec[kind] = self._rec_mkdec()
1888+
text = dec.decode(text)
1889+
if not text:
1890+
return # mid-sequence; flushed with the next chunk
1891+
with self._rec_lock:
1892+
f = self._rec
1893+
if f is None:
1894+
return
1895+
# Timestamp under the lock so concurrent writers (the
1896+
# PTY reader vs input/resize handlers) cannot produce
1897+
# out-of-order events.
1898+
line = json.dumps(
1899+
[round(time.monotonic() - self._rec_t0, 6), kind, text])
1900+
f.write(line + "\n")
1901+
self._rec_bytes += len(line) + 1
1902+
if (WEBSH_RECORD_MAX_BYTES
1903+
and self._rec_bytes >= WEBSH_RECORD_MAX_BYTES):
1904+
self._rec = None
1905+
f.close()
1906+
_log("WARN", "session {} recording capped at {} bytes"
1907+
.format(self.id, WEBSH_RECORD_MAX_BYTES))
1908+
except Exception as e:
1909+
_log("WARN", "session {} recording stopped: {}".format(
1910+
self.id, e))
1911+
self._rec = None
1912+
1913+
def _rec_close(self):
1914+
if getattr(self, "_rec", None) is None:
1915+
self._rec = None
1916+
return
1917+
with self._rec_lock:
1918+
f = self._rec
1919+
self._rec = None
1920+
if f is not None:
1921+
try:
1922+
f.close()
1923+
except Exception:
1924+
pass
1925+
18051926
def _read_loop(self):
18061927
"""Background thread: reads PTY output into buffer."""
18071928
# Entry-guard: pty.fork() may have failed in __init__ (or a test
@@ -1836,6 +1957,7 @@ def _read_loop(self):
18361957
break
18371958
if not data:
18381959
break
1960+
self._record("o", data)
18391961

18401962
# Auto-type password on prompt (accumulate to handle split reads)
18411963
if self._password and not self._password_sent:
@@ -1920,6 +2042,7 @@ def _read_loop(self):
19202042
if r:
19212043
leftover = os.read(self.master_fd, PTY_READ_SIZE)
19222044
if leftover:
2045+
self._record("o", leftover)
19232046
with self.buf_lock:
19242047
self.output_buf += leftover
19252048
self._signal()
@@ -1965,6 +2088,11 @@ def _read_loop(self):
19652088
# Wake any consumer parked in wait_for_data so it observes
19662089
# alive=False without waiting up to KEEPALIVE_INTERVAL.
19672090
self._signal()
2091+
# The reader owns the recording tail: closing here (after
2092+
# the drain above) captures the session's final output.
2093+
# close()'s _rec_close stays as a backstop for sessions
2094+
# whose reader never ran.
2095+
self._rec_close()
19682096

19692097
def _poll_child_exit(self):
19702098
"""Non-blocking WNOHANG check for the ssh child's self-exit, run from
@@ -2236,6 +2364,9 @@ def write(self, data):
22362364
self.alive = False
22372365
return False
22382366
self.last_activity = time.time()
2367+
# Input recording (keystrokes — which include passwords typed at
2368+
# prompts inside the session) is hard-disabled; see WEBSH_RECORD_INPUT.
2369+
# The 'i' event is never written — recording is output-only.
22392370
try:
22402371
os.write(self.master_fd, data)
22412372
return True
@@ -2245,6 +2376,7 @@ def write(self, data):
22452376

22462377
def resize(self, cols, rows):
22472378
self.last_activity = time.time()
2379+
self._record("r", "%dx%d" % (cols, rows))
22482380
self._set_winsize(cols, rows)
22492381

22502382
# ── Persistent tmux (terminate / capture / push options) ────────
@@ -2736,6 +2868,7 @@ def download_file(self, remote_path):
27362868

27372869
def close(self):
27382870
self.alive = False
2871+
self._rec_close()
27392872
# Wake any consumer parked in wait_for_data so it observes
27402873
# alive=False and exits via the normal
27412874
# `if not session.alive: break` path. Setting an Event whose
@@ -4586,6 +4719,10 @@ def _request_stop(signum, frame):
45864719
_log("INFO", "credential vault: disabled (set WEBSH_VAULT_ENABLE=1 to opt in)")
45874720
else:
45884721
_log("INFO", "credential vault: enabled")
4722+
if _WEBSH_RECORD_INPUT_REQUESTED:
4723+
_log("WARN", "WEBSH_RECORD_INPUT is set but IGNORED: keystroke/input "
4724+
"recording is hard-disabled (recording is output-only). "
4725+
"Unset it to silence this warning.")
45894726

45904727
stop_event.wait()
45914728
_log("INFO", "shutting down")

0 commit comments

Comments
 (0)