From 341fbb1c983487e661d0b1debcc4fb50d02e15e2 Mon Sep 17 00:00:00 2001 From: dolonet Date: Fri, 23 Oct 2026 14:52:30 +0000 Subject: [PATCH] fix: run the shared-hosting backend on a Unix socket On shared hosting 127.0.0.1 is shared by every account on the server. server.py trusted any loopback peer and shared no secret with api.php, so a co-tenant could: - call the backend directly, bypassing the web server's auth, and open the server-side connections from websh.json; - bind 127.0.0.1:8765 while the backend was down, after which api.php forwarded users' SSH credentials and keystrokes to that process. Two websh installs on one host also collided on the default port, so one account's proxy could end up talking to the other's backend. server.py gains WEBSH_SOCKET (env-only): listen on a Unix socket created 0600 instead of HOST:PORT. A stale socket left by a crashed backend is replaced; a live socket or a non-socket file is refused. Peers on the socket are trusted for X-Forwarded-For and WEBSH_AUTH_HEADER. api.php now always reaches the backend over websh.sock next to websh.json (override with WEBSH_SOCKET) and auto-starts server.py with that path; WEBSH_PORT is gone. The auto-start lock moves from the shared temp dir to the socket's directory. A socket in a directory other users can write would reopen the hole (a co-tenant plants one first), so api.php and server.py both require the directory to belong to the account and be closed to other users' writes, and api.php refuses a socket owned by someone else. Misconfigurations (no Unix socket support in curl, which needs PHP 7.0.7+ and libcurl 7.40+; a path over 107 bytes; an unusable directory or lock file) answer 500 with the reason, never a TCP fallback. Docker, systemd and reverse-proxy setups keep the TCP default. Upgrade note: a backend started by an older api.php keeps listening on 127.0.0.1:8765 until it is stopped. Tests cover the socket server (0600 mode; stale, live and non-socket paths; a directory others can write; main() wiring) and socket peers in _client_ip/_client_identity. The PHP smoke runs its stub on a socket, auto-starts the real server.py end to end (0600 socket, no TCP port) and checks that a socket in a shared directory is refused. --- api.php | 127 ++++++++++++++++++++++++-------- docs/configuration.md | 17 +++-- docs/deployment.md | 33 +++++++-- docs/security.md | 9 ++- server.py | 127 +++++++++++++++++++++++++++++--- tests/backend/test_connect.py | 36 +++++++++ tests/backend/test_transport.py | 111 ++++++++++++++++++++++++++++ tests/php/smoke.sh | 93 ++++++++++++++++++++--- tests/php/stub_backend.py | 19 ++++- 9 files changed, 504 insertions(+), 68 deletions(-) diff --git a/api.php b/api.php index 60ddd8e..b556c9f 100644 --- a/api.php +++ b/api.php @@ -3,7 +3,7 @@ * websh — PHP proxy. * * Forwards AJAX requests from the browser to the Python REST API - * running on 127.0.0.1. This allows all traffic to flow through + * over a Unix socket. This allows all traffic to flow through * the hosting provider's HTTPS — no exposed ports, no WebSocket. * * Compatible with PHP 5.3+ and requires only the curl extension. @@ -28,9 +28,10 @@ } if (!extension_loaded('curl')) { - header('HTTP/1.1 500 Internal Server Error'); - echo '{"error":"PHP curl extension is required"}'; - exit; + config_error('PHP curl extension is required'); +} +if (!defined('CURLOPT_UNIX_SOCKET_PATH')) { + config_error('PHP curl lacks Unix socket support (needs PHP 7.0.7+ and libcurl 7.40+)'); } if ($action !== 'stream') { @@ -38,13 +39,32 @@ header('Cache-Control: no-cache, no-store, must-revalidate'); } -$BACKEND = 'http://127.0.0.1:' . (getenv('WEBSH_PORT') ?: '8765'); - // Path to config file (must be OUTSIDE the web root for security). $WEBSH_CONFIG = getenv('WEBSH_CONFIG') ?: dirname(__FILE__) . '/../../websh.json'; +// The backend listens on a Unix socket, not a TCP port: on shared +// hosting 127.0.0.1 is common to every account, so any co-tenant could +// reach a TCP backend past the web server's auth (and use the +// server-side connections in websh.json), or take the port while the +// backend is down and receive what users type. server.py creates the +// socket 0600 next to websh.json, outside the web root, so only this +// account can connect to it or create it there. +$config_dir = @realpath(dirname($WEBSH_CONFIG)) ?: dirname($WEBSH_CONFIG); +$WEBSH_SOCKET = trim((string) getenv('WEBSH_SOCKET')) ?: $config_dir . '/websh.sock'; +// sun_path holds 107 bytes; past that bind() and connect() just fail. +if (strlen($WEBSH_SOCKET) > 107) { + config_error('backend socket path is too long; set WEBSH_SOCKET to a ' + . 'shorter path in a directory only this account can write'); +} +$problem = socket_path_problem($WEBSH_SOCKET); +if ($problem !== '') { + config_error($problem); +} +// Only the Host header sees this; the connection goes to the socket. +$BACKEND = 'http://localhost'; + // Auto-start: launch server.py if it's not running. -ensure_backend($BACKEND, $WEBSH_CONFIG); +ensure_backend($BACKEND, $WEBSH_CONFIG, $WEBSH_SOCKET); switch ($action) { case 'config': proxy_get($BACKEND . '/api/config'); break; @@ -112,15 +132,48 @@ // ── Helpers ────────────────────────────────────────────────────────── +// A deployment problem only the operator can fix: 500 with the reason. +function config_error($msg) { + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode(array('error' => $msg)); + exit; +} + +// A socket path other accounts can create files at is a trap: a +// co-tenant plants a socket there first and receives what users type. +// So the directory must belong to this account and be closed to other +// users' writes, and an existing socket must be ours. Returns the +// problem, or '' when the path is safe. +function socket_path_problem($socket) { + $uid = function_exists('posix_geteuid') ? posix_geteuid() : getmyuid(); + $gid = function_exists('posix_getegid') ? posix_getegid() : getmygid(); + $dir = @stat(dirname($socket)); + if (!$dir) { + return 'cannot access the backend socket directory (open_basedir?); ' + . 'set WEBSH_SOCKET to a path in a directory this account owns'; + } + if ($dir['uid'] !== $uid || ($dir['mode'] & 0002) + || (($dir['mode'] & 0020) && $dir['gid'] !== $gid)) { + return 'the backend socket directory must belong to this account ' + . 'and be writable only by it; set WEBSH_SOCKET'; + } + $owner = @fileowner($socket); + if ($owner !== false && $owner !== $uid) { + return 'the backend socket belongs to another user'; + } + return ''; +} + // Headers every proxied backend request must carry. The X-Forwarded-For -// is the load-bearing one: all backend calls originate from 127.0.0.1 -// (this PHP process), so without it the server sees every browser client +// is the load-bearing one: all backend calls come from this PHP process +// over the socket, so without it the server sees every browser client // as the loopback address and the entire per-IP defense layer collapses // to a single shared bucket — one client exhausts RATE_LIMIT_MAX for // everyone, MAX_SESSIONS_PER_IP caps all users combined, and the access // log records 127.0.0.1 for every connect. The backend trusts only the -// FIRST token of X-Forwarded-For from a trusted proxy (loopback by -// default), so we send exactly one token and OVERWRITE rather than +// FIRST token of X-Forwarded-For from a trusted proxy (any client of +// its socket), so we send exactly one token and OVERWRITE rather than // append. REMOTE_ADDR is the real client on the typical shared-hosting // layout where PHP faces the browser directly; if PHP itself sits behind // another proxy, configure that proxy to populate REMOTE_ADDR. @@ -132,6 +185,17 @@ function backend_headers($extra = array()) { return $h; } +// Every backend request goes over the socket. +function backend_curl($url) { + global $WEBSH_SOCKET; + $ch = curl_init($url); + if (!curl_setopt($ch, CURLOPT_UNIX_SOCKET_PATH, $WEBSH_SOCKET)) { + // libcurl built without Unix sockets: never fall back to TCP. + config_error('PHP curl cannot connect to Unix sockets'); + } + return $ch; +} + // curl_close() only frees the handle before PHP 8.0; since then handles // are objects freed when they go out of scope, and 8.5 deprecates the // call. Hosts with display_errors on would print that notice into the @@ -142,7 +206,7 @@ function release_curl($ch) { } function ping_backend($backend) { - $ch = curl_init($backend . '/api/ping'); + $ch = backend_curl($backend . '/api/ping'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 2); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 1); @@ -153,7 +217,7 @@ function ping_backend($backend) { function proxy_post($url) { $body = file_get_contents('php://input'); - $ch = curl_init($url); + $ch = backend_curl($url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); @@ -174,7 +238,7 @@ function proxy_post($url) { } function proxy_get($url) { - $ch = curl_init($url); + $ch = backend_curl($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, backend_headers()); curl_setopt($ch, CURLOPT_TIMEOUT, 50); @@ -201,7 +265,7 @@ function proxy_upload($url) { return; } - $ch = curl_init($url); + $ch = backend_curl($url); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); curl_setopt($ch, CURLOPT_UPLOAD, true); curl_setopt($ch, CURLOPT_INFILE, $in); @@ -232,7 +296,7 @@ function proxy_download($url) { @ob_implicit_flush(true); $sent_headers = false; - $ch = curl_init($url); + $ch = backend_curl($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, false); curl_setopt($ch, CURLOPT_HTTPHEADER, backend_headers()); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); @@ -277,7 +341,7 @@ function ($ch, $header) use (&$sent_headers) { // DELETE helper for /api/save. Mirrors proxy_post/proxy_get by // forwarding the backend's status code, including 204 No Content. function proxy_delete($url) { - $ch = curl_init($url); + $ch = backend_curl($url); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, backend_headers()); @@ -316,7 +380,7 @@ function proxy_stream($url) { @ob_implicit_flush(true); $sent_headers = false; - $ch = curl_init($url); + $ch = backend_curl($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, false); curl_setopt($ch, CURLOPT_HEADER, false); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); @@ -369,13 +433,19 @@ function ($ch, $header) use (&$sent_headers) { } } -function ensure_backend($backend, $config_path) { +function ensure_backend($backend, $config_path, $socket) { if (ping_backend($backend)) return; - // Lock to prevent double-start. - $lock = fopen(sys_get_temp_dir() . '/websh_start.lock', 'c'); - if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) { - if ($lock) fclose($lock); + // Lock to prevent double-start. It sits next to the socket, not in + // the system temp dir: with a shared /tmp every account on the host + // would contend for one lock file owned by whoever created it. + $lock = @fopen($socket . '.lock', 'c'); + if (!$lock) { + config_error('cannot create the auto-start lock next to the backend ' + . 'socket; its directory must be writable by this account'); + } + if (!flock($lock, LOCK_EX | LOCK_NB)) { + fclose($lock); usleep(1500000); ping_backend($backend); return; @@ -386,15 +456,12 @@ function ensure_backend($backend, $config_path) { $script = dirname(__FILE__) . '/server.py'; if (file_exists($script)) { - // Pass PORT so the spawned server.py binds the SAME port this proxy - // pings ($BACKEND uses WEBSH_PORT). Without it the backend always - // binds its own default 8765, so on a non-default WEBSH_PORT the - // ping never succeeds, every request re-execs python3, and the - // browser sees permanent 502s. + // WEBSH_SOCKET makes the spawned server.py listen on the socket + // this proxy connects to instead of a loopback TCP port. $cmd = sprintf( - 'WEBSH_CONFIG=%s PORT=%s nohup python3 %s /dev/null 2>&1 &', + 'WEBSH_CONFIG=%s WEBSH_SOCKET=%s nohup python3 %s /dev/null 2>&1 &', escapeshellarg($config_path), - escapeshellarg(getenv('WEBSH_PORT') ?: '8765'), + escapeshellarg($socket), escapeshellarg($script) ); exec($cmd); diff --git a/docs/configuration.md b/docs/configuration.md index 1c3050b..d06a3ba 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,14 +24,16 @@ below — including the `WEBSH_` prefix where the table has one, e.g. `1`/`true` (env string or JSON literal). Env-only exceptions: `WEBSH_CONFIG` (it locates the JSON), and -`HOST` / `TRUSTED_PROXIES` — a writable `websh.json` must not be able -to rebind the loopback-only server to a public interface or take over -X-Forwarded-For trust. +`HOST` / `TRUSTED_PROXIES` / `WEBSH_SOCKET` — a writable `websh.json` +must not be able to rebind the loopback-only server to a public +interface or a path of its choosing, or take over X-Forwarded-For +trust. | Variable | Default | Description | |---|---|---| | `PORT` | `8765` | Listen port | | `HOST` | `127.0.0.1` | Bind address | +| `WEBSH_SOCKET` | *(unset)* | Listen on this Unix socket path instead of `HOST`:`PORT`. The socket is created `0600`, so only the websh user can connect; clients on it are trusted like `TRUSTED_PROXIES`. Its directory must belong to the websh user and not be writable by others (no `/tmp`). The PHP proxy always uses one (default `websh.sock` next to `websh.json`) — see [`deployment.md`](deployment.md#shared-hosting-php--python). | | `SESSION_TIMEOUT` | `300` | Idle timeout in seconds | | `MAX_SESSIONS` | `50` | Max concurrent SSH sessions | | `MAX_SESSIONS_PER_IP` | `0` | Max concurrent sessions per source IP (`0` disables; counts foreground + background together) | @@ -61,8 +63,7 @@ X-Forwarded-For trust. | `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 `-.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). | | `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). | -The PHP proxy reads `WEBSH_PORT` (default `8765`) to find the backend — -and since the alias rule above makes `server.py` honor `WEBSH_PORT` -too, in the shared-hosting flow (where the auto-started backend -inherits PHP's environment) that one variable points both sides at the -same port. Under systemd the unit's `PORT=` still pins the backend. +The PHP proxy talks to the backend over `WEBSH_SOCKET` (default +`websh.sock` next to `websh.json`) and starts `server.py` with that +same value, so `PORT` and `HOST` play no part in the shared-hosting +flow. Under systemd the unit's `PORT=` still pins the backend. diff --git a/docs/deployment.md b/docs/deployment.md index 436832f..15cdc3b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -10,6 +10,7 @@ A typical shared-hosting directory layout: /home/user/ example.com/ <- site root websh.json <- config (OUTSIDE www — not accessible via HTTP) + websh.sock <- backend socket (created by server.py) www/ <- web root (public) console/ index.html <- frontend @@ -27,15 +28,27 @@ A typical shared-hosting directory layout: That's it. `api.php` starts `server.py` automatically on the first request. +The backend listens on a Unix socket, `websh.sock`, next to `websh.json`, +not on a TCP port. On shared hosting `127.0.0.1` is shared by every account +on the server, so a port would let other customers reach the backend +around your web server's authentication. The socket is created with +owner-only permissions, so only your account can connect to it — provided +PHP runs as your account (suPHP, per-user PHP-FPM pools, LSAPI: the usual +shared-hosting setup). Override the path with `WEBSH_SOCKET`: under 108 +bytes, in a directory that belongs to your account and that no one else +can write to — never `/tmp`. Both `api.php` and `server.py` refuse a +directory other users could plant a socket in. + > **Path details:** `api.php` looks for `websh.json` two directories up from itself > (i.e. the site root, above `www/`). This works for most hosting providers. > If your layout is different, set the `WEBSH_CONFIG` environment variable > or edit the `$WEBSH_CONFIG` default near the top of `api.php`. -For manual control (e.g. custom config path): +For manual control (e.g. custom config path), start it on the socket +`api.php` connects to: ```bash -WEBSH_CONFIG=/path/to/websh.json nohup python3 server.py & +WEBSH_CONFIG=/path/to/websh.json WEBSH_SOCKET=/path/to/websh.sock nohup python3 server.py & ``` ### Troubleshooting @@ -50,9 +63,19 @@ WEBSH_CONFIG=/path/to/websh.json nohup python3 server.py & - Set `WEBSH_CONFIG=/full/path/to/websh.json` environment variable if your layout differs - Check JSON syntax: `python3 -c "import json; json.load(open('websh.json'))"` -**Port already in use:** -- Another instance of `server.py` may be running: `ps aux | grep server.py` -- Change the port: `PORT=8766 python3 server.py` +**Upgrading from a version that listened on a TCP port:** +- The old backend keeps running on `127.0.0.1:8765` (or your + `WEBSH_PORT`), reachable by anyone on the server, until it is stopped: + `pkill -f server.py` (the next request starts the new one), or ask your + host to restart your processes. + +**An error about the backend socket or its directory:** +- The directory holding `websh.sock` (by default the one with `websh.json`) + must belong to your account and be writable by it alone; with + `open_basedir`, it must also be inside the allowed paths. Otherwise point + `WEBSH_SOCKET` at such a directory. +- A stale `websh.sock` left by a crashed backend is replaced automatically; + a regular file at that path is not — remove it. ## Python only (no PHP) diff --git a/docs/security.md b/docs/security.md index 515dedf..608dbe3 100644 --- a/docs/security.md +++ b/docs/security.md @@ -8,6 +8,12 @@ It is meant to be lightweight — add access control at the web server level: - **Cloudflare Access**, **Tailscale Funnel**, or similar zero-trust tools - IP allowlisting via firewall rules +That only holds if nothing reaches the backend around the web server. +On shared hosting `127.0.0.1` is common to every account on the server, +so the PHP proxy runs the backend on an owner-only Unix socket +(`WEBSH_SOCKET`) instead of a loopback port. With the default TCP bind, +run websh on a machine you don't share. + ## SSH host keys The backend connects with `StrictHostKeyChecking=no` by default to avoid @@ -200,7 +206,8 @@ once** (an appending proxy would otherwise let a client-smuggled first value win), and reads it only from `TRUSTED_PROXIES` peers. Two operational notes: with the defaults (`HOST=127.0.0.1`, `TRUSTED_PROXIES=127.0.0.1`) any local process on a shared box can -mint identities — inherent to loopback trust; and enable +mint identities — inherent to loopback trust, which `WEBSH_SOCKET` +avoids; and enable `WEBSH_AUTH_HEADER` at startup only — sessions are stamped at creation, so flipping it on over a live registry orphans existing sessions (deny-by-default, by design). Cross-user probes are recorded in the diff --git a/server.py b/server.py index c42921c..b4f3a6e 100644 --- a/server.py +++ b/server.py @@ -9,6 +9,8 @@ Environment variables: PORT — listen port (default: 8765) HOST — bind address (default: 127.0.0.1) + WEBSH_SOCKET — listen on this Unix socket path instead of + HOST:PORT (the PHP proxy always sets it) SESSION_TIMEOUT — seconds of inactivity before cleanup (default: 300) MAX_SESSIONS — max concurrent SSH sessions (default: 50) WEBSH_CONFIG — path to websh.json config file (optional) @@ -49,6 +51,7 @@ import base64 import binascii import datetime +import errno import fcntl import ipaddress import json @@ -59,6 +62,8 @@ import selectors import signal import socket +import socketserver +import stat import struct import subprocess import sys @@ -182,6 +187,12 @@ def _int_env(name, default): PORT = _int_env("PORT", "8765") HOST = str(_knob("HOST", "127.0.0.1")) +# Listen on a Unix socket at this path instead of HOST:PORT. The PHP +# proxy always sets it: on shared hosting 127.0.0.1 is common to every +# account, so a TCP port is reachable by any co-tenant. Env-only (read +# directly, not via _knob): a websh.json write must not pick a path the +# server unlinks and binds. +WEBSH_SOCKET = os.environ.get("WEBSH_SOCKET", "").strip() # Optional header-trust authentication. When set to a header name (e.g. # "Remote-User" from oauth2-proxy / Authelia / authentik), every request # except /api/ping requires that header, its value becomes the request's @@ -3211,14 +3222,25 @@ def _ping(self): # ── Source-IP / session-ID validation ─────────────────────────── + def _peer(self): + """(address, trusted) for the connection's peer. A Unix-socket + peer has no IP (client_address is a path, usually ''); only + processes of the websh account can open the 0600 socket — in + practice api.php — so it is trusted and reported as loopback.""" + if not isinstance(self.client_address, tuple): + return "127.0.0.1", True + peer = self.client_address[0] + return peer, peer in _TRUSTED_PROXIES + def _client_ip(self): """Return the IP we treat as the request's source for rate-limit and per-IP-cap purposes. Uses the TCP peer by default. When the peer is in TRUSTED_PROXIES - we read the FIRST X-Forwarded-For token — but only if it parses - as a valid IP literal. Anything else (typo, intentional garbage, - an injected oversized blob) falls back to the peer. + (or on the Unix socket) we read the FIRST X-Forwarded-For token — + but only if it parses as a valid IP literal. Anything else (typo, + intentional garbage, an injected oversized blob) falls back to + the peer. Important: when running behind a reverse proxy, the proxy MUST OVERWRITE this header (`proxy_set_header X-Forwarded-For $remote_addr;` @@ -3229,8 +3251,8 @@ def _client_ip(self): attacker-controlled non-IP bytes from ending up as the registry comparison key — it does NOT compensate for an appending proxy. """ - peer = self.client_address[0] - if peer in _TRUSTED_PROXIES: + peer, trusted = self._peer() + if trusted: xff = self.headers.get("X-Forwarded-For", "") if xff: token = xff.split(",", 1)[0].strip() @@ -3249,7 +3271,7 @@ def _client_identity(self): X-Forwarded-For) so a direct client cannot mint identities.""" if not WEBSH_AUTH_HEADER: return "" - if self.client_address[0] not in _TRUSTED_PROXIES: + if not self._peer()[1]: return "" values = self.headers.get_all(WEBSH_AUTH_HEADER) or [] if len(values) > 1: @@ -4587,6 +4609,89 @@ def _run_under_semaphore(self, request, client_address): self._req_sem.release() +class UnixServer(Server): + """Server on a Unix socket (WEBSH_SOCKET) instead of loopback TCP. + + On shared hosting every account shares 127.0.0.1: any co-tenant + could reach a TCP backend directly, past the web server's auth, and + use the server-side connections in websh.json — or take the port + while the backend is down and receive the credentials api.php + forwards. The socket file is created 0600, so only the websh + account can connect to it; living in that account's directory, only + that account can create it. + """ + + address_family = socket.AF_UNIX + # SO_REUSE* mean nothing for a path; stale files are handled below. + allow_reuse_address = False + allow_reuse_port = False + # Set once bind() succeeds; socketserver calls server_close() on a + # failed bind too, and that must not unlink someone else's file. + _sock_ino = None + + def server_bind(self): + _check_socket_dir(self.server_address) + _clear_stale_socket(self.server_address) + # umask instead of a chmod after bind(): no window where the + # socket exists with looser permissions. + old = os.umask(0o177) + try: + # HTTPServer.server_bind would getfqdn() a (host, port) pair. + socketserver.TCPServer.server_bind(self) + finally: + os.umask(old) + self._sock_ino = os.lstat(self.server_address).st_ino + self.server_name = "localhost" + self.server_port = 0 + + def server_close(self): + Server.server_close(self) + try: + # Only our own file — never a socket a newer backend bound. + if os.lstat(self.server_address).st_ino == self._sock_ino: + os.unlink(self.server_address) + except OSError: + pass + + +def _check_socket_dir(path): + """Refuse a socket directory other users can create files in: a + co-tenant could plant a socket there before we bind, and api.php + would hand it what users type.""" + d = os.path.dirname(os.path.abspath(path)) + st = os.stat(d) + if (st.st_uid != os.geteuid() or st.st_mode & stat.S_IWOTH + or (st.st_mode & stat.S_IWGRP and st.st_gid != os.getegid())): + raise OSError(errno.EPERM, "WEBSH_SOCKET directory {} must belong to " + "this user and be writable only by it".format(d)) + + +def _clear_stale_socket(path): + """Unlink a socket file left by a backend that died without cleaning + up, so bind() does not fail with EADDRINUSE. Anything that is not a + socket, or a socket that still accepts connections, is left alone.""" + try: + st = os.lstat(path) + except FileNotFoundError: + return + if stat.S_ISSOCK(st.st_mode): + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + # A full backlog (wedged backend) blocks connect(); count it as + # in use rather than hang every auto-start attempt. + probe.settimeout(1) + try: + probe.connect(path) + except ConnectionRefusedError: + os.unlink(path) + return + except OSError: + pass + finally: + probe.close() + raise OSError(errno.EADDRINUSE, + "WEBSH_SOCKET {} is in use or not a socket".format(path)) + + def _warn_per_ip_misconfig(): """Emit a WARN when MAX_SESSIONS_PER_IP cannot ever trip. @@ -4682,7 +4787,12 @@ def main(): t = Thread(target=_cleanup_loop, daemon=True) t.start() - server = Server((HOST, PORT), Handler) + if WEBSH_SOCKET: + server = UnixServer(WEBSH_SOCKET, Handler) + where = "unix:" + WEBSH_SOCKET + else: + server = Server((HOST, PORT), Handler) + where = "http://{}:{}".format(HOST, PORT) stop_event = Event() @@ -4709,8 +4819,7 @@ def _request_stop(signum, frame): serve_thread = Thread(target=server.serve_forever, daemon=True) serve_thread.start() - _log("INFO", "websh v{} listening on http://{}:{}".format( - __version__, HOST, PORT)) + _log("INFO", "websh v{} listening on {}".format(__version__, where)) if ACCESS_LOG_PATH: _log("INFO", "access log: {}".format(ACCESS_LOG_PATH)) if not HAS_CRYPTOGRAPHY: diff --git a/tests/backend/test_connect.py b/tests/backend/test_connect.py index 7819686..bbab863 100644 --- a/tests/backend/test_connect.py +++ b/tests/backend/test_connect.py @@ -147,6 +147,9 @@ def __init__(self, mapping): def get(self, name, default=""): return self._m.get(name, default) + def get_all(self, name, default=None): + return [self._m[name]] if name in self._m else default + def _make_handler(self, peer, headers=None): h = server.Handler.__new__(server.Handler) h.client_address = (peer, 12345) @@ -225,6 +228,39 @@ def test_invalid_ip_with_extra_chars_falls_back(self): {"X-Forwarded-For": "1.2.3.4abc"}) self.assertEqual(h._client_ip(), "127.0.0.1") + def _make_unix_handler(self, headers=None): + # socketserver hands a Unix-socket connection client_address ''. + h = server.Handler.__new__(server.Handler) + h.client_address = "" + h.headers = self._FakeHeaders(headers or {}) + return h + + def test_unix_socket_peer_trusts_xff(self): + # The socket is 0600: its only clients are the websh account's + # own processes (api.php), whatever TRUSTED_PROXIES says. + server._TRUSTED_PROXIES = {"10.0.0.5"} + h = self._make_unix_handler({"X-Forwarded-For": "1.2.3.4"}) + self.assertEqual(h._client_ip(), "1.2.3.4") + + def test_bound_unix_client_is_trusted_not_used_as_ip(self): + # A client that bound its own path shows up as that path. + h = self._make_unix_handler() + h.client_address = "client.sock" + self.assertEqual(h._client_ip(), "127.0.0.1") + + def test_unix_socket_peer_without_xff_is_loopback(self): + h = self._make_unix_handler() + self.assertEqual(h._client_ip(), "127.0.0.1") + + def test_unix_socket_peer_carries_auth_identity(self): + orig = server.WEBSH_AUTH_HEADER + server.WEBSH_AUTH_HEADER = "Remote-User" + try: + h = self._make_unix_handler({"Remote-User": "alice"}) + self.assertEqual(h._client_identity(), "alice") + finally: + server.WEBSH_AUTH_HEADER = orig + class TestRateLimit(unittest.TestCase): diff --git a/tests/backend/test_transport.py b/tests/backend/test_transport.py index bf0618a..b26669a 100644 --- a/tests/backend/test_transport.py +++ b/tests/backend/test_transport.py @@ -5,6 +5,7 @@ """ import base64 +import http.client import io import json import os @@ -2145,5 +2146,115 @@ def test_unwritable_dir_disables_recording(self): server.WEBSH_RECORD_DIR = orig + +class _UnixHTTPConnection(http.client.HTTPConnection): + def __init__(self, path): + http.client.HTTPConnection.__init__(self, "localhost", timeout=5) + self._path = path + + def connect(self): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(self.timeout) + self.sock.connect(self._path) + + +class TestUnixSocketServer(unittest.TestCase): + """WEBSH_SOCKET: the backend listens on a 0600 Unix socket instead + of loopback TCP, which every account on a shared host can reach.""" + + def setUp(self): + # Short dir: sun_path caps the socket path at ~107 bytes. + self.tmpdir = tempfile.mkdtemp(dir="/tmp") + self.path = os.path.join(self.tmpdir, "websh.sock") + self.httpd = None + + def tearDown(self): + if self.httpd is not None: + self.httpd.shutdown() + self.httpd.server_close() + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def _start(self): + self.httpd = server.UnixServer(self.path, server.Handler) + threading.Thread(target=self.httpd.serve_forever, daemon=True).start() + + def _get(self, path): + c = _UnixHTTPConnection(self.path) + try: + c.request("GET", path) + r = c.getresponse() + return r.status, json.loads(r.read()) + finally: + c.close() + + def test_serves_api_over_owner_only_socket(self): + umask = os.umask(0o022) + os.umask(umask) + self._start() + self.assertEqual(os.stat(self.path).st_mode & 0o777, 0o600) + self.assertEqual(os.umask(umask), umask, "bind leaked its umask") + status, body = self._get("/api/ping") + self.assertEqual(status, 200) + self.assertTrue(body["ok"]) + + def test_close_removes_socket_file(self): + self._start() + self.httpd.shutdown() + self.httpd.server_close() + self.httpd = None + self.assertFalse(os.path.exists(self.path)) + + def test_stale_socket_is_replaced(self): + # A backend killed with SIGKILL leaves its socket file behind. + dead = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + dead.bind(self.path) + dead.close() + self._start() + self.assertEqual(self._get("/api/ping")[0], 200) + + def test_live_socket_is_not_taken_over(self): + self._start() + with self.assertRaises(OSError): + server.UnixServer(self.path, server.Handler) + self.assertEqual(self._get("/api/ping")[0], 200) + + def test_dir_writable_by_others_is_refused(self): + # A co-tenant could plant a socket there before the backend binds. + os.chmod(self.tmpdir, 0o777) + with self.assertRaises(OSError): + server.UnixServer(self.path, server.Handler) + self.assertFalse(os.path.exists(self.path)) + + def test_non_socket_file_is_left_alone(self): + with open(self.path, "w") as f: + f.write("keep me") + with self.assertRaises(OSError): + server.UnixServer(self.path, server.Handler) + with open(self.path) as f: + self.assertEqual(f.read(), "keep me") + + def test_main_listens_on_socket_from_env(self): + env = dict(os.environ) + env["PYTHONPATH"] = REPO_ROOT + os.pathsep + env.get("PYTHONPATH", "") + env["WEBSH_SOCKET"] = self.path + env["WEBSH_VAULT_ENABLE"] = "0" + env.pop("WEBSH_CONFIG", None) + proc = subprocess.Popen( + [sys.executable, "-c", "import server; server.main()"], env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + for _ in range(500): + if os.path.exists(self.path): + break + time.sleep(0.01) + self.assertEqual(self._get("/api/ping")[0], 200) + finally: + proc.send_signal(signal.SIGTERM) + _, err = proc.communicate(timeout=20) + self.assertEqual(proc.returncode, 0, err) + self.assertIn(b"listening on unix:" + self.path.encode(), err) + self.assertFalse(os.path.exists(self.path)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/php/smoke.sh b/tests/php/smoke.sh index db93782..c7080fe 100755 --- a/tests/php/smoke.sh +++ b/tests/php/smoke.sh @@ -9,23 +9,42 @@ set -euo pipefail cd "$(dirname "$0")/../.." -# Kernel-assigned ports: parallel CI jobs / dev boxes can't collide. -STUB_OUT=$(mktemp) -UNKNOWN_OUT=$(mktemp) -python3 tests/php/stub_backend.py > "$STUB_OUT" & +# Private temp dir for the backend sockets; kernel-assigned ports for +# php -S, so parallel CI jobs / dev boxes can't collide. +WORK=$(mktemp -d) +STUB_OUT=$WORK/stub.out +UNKNOWN_OUT=$WORK/unknown.out +STUB_SOCK=$WORK/stub.sock +python3 tests/php/stub_backend.py "$STUB_SOCK" > "$STUB_OUT" & STUB_PID=$! PHP_PID= -trap 'kill $STUB_PID $PHP_PID 2>/dev/null || true; rm -f "$STUB_OUT" "$UNKNOWN_OUT"' EXIT +AUTO_PID= +PLANTED_PID= +SHARED_PID= +cleanup() { + kill $STUB_PID $PHP_PID $AUTO_PID $PLANTED_PID $SHARED_PID 2>/dev/null || true + # The backend api.php auto-starts in step 8 runs from its own copy; + # give its SIGTERM shutdown a moment before removing the tree. + pkill -f "$WORK/site/www/console/server.py" 2>/dev/null || true + for i in $(seq 1 50); do + pgrep -f "$WORK/site/www/console/server.py" >/dev/null || break + sleep 0.1 + done + rm -rf "$WORK" +} +trap cleanup EXIT for i in $(seq 1 50); do [ -s "$STUB_OUT" ] && break; sleep 0.1; done -STUB_PORT=$(head -1 "$STUB_OUT") -[ -n "$STUB_PORT" ] || { echo "stub backend never reported its port"; exit 1; } -PHP_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()') +[ -S "$STUB_SOCK" ] || { echo "stub backend never bound $STUB_SOCK"; exit 1; } +free_port() { + python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()' +} +PHP_PORT=$(free_port) # NOTE: if the stub ping ever failed here, api.php's ensure_backend -# would nohup a REAL server.py outside our trap. The echo asserts below -# would all fail loudly in that case, so it can't false-pass — accepted. +# would nohup a REAL server.py; it refuses a socket the stub still +# holds, and the echo asserts below would fail loudly otherwise. # output_buffering=0: a production php.ini buffers 4 KiB, which would # let a stray notice slip out without breaking the status code. -WEBSH_PORT=$STUB_PORT php -d display_errors=1 -d error_reporting=-1 \ +WEBSH_SOCKET=$STUB_SOCK php -d display_errors=1 -d error_reporting=-1 \ -d output_buffering=0 -S 127.0.0.1:$PHP_PORT -t . >/dev/null 2>&1 & PHP_PID=$! @@ -107,4 +126,56 @@ r=$(curl -fsS "http://127.0.0.1:$PHP_PORT/api.php?action=stream&session_id=s1") echo "$r" | grep -q '"path": "/api/stream"' || fail "stream path" "$r" json_ok stream "$r" +# 8. Auto-start, end to end: with no WEBSH_SOCKET, api.php starts the +# real server.py on websh.sock next to websh.json (two levels up, in +# the documented layout), owner-only, and the backend opens no TCP +# port. +mkdir -p "$WORK/site/www/console" +cp api.php server.py "$WORK/site/www/console/" +echo '{}' > "$WORK/site/websh.json" +AUTO_PORT=$(free_port) +env -u WEBSH_SOCKET -u WEBSH_CONFIG php -d display_errors=1 -d error_reporting=-1 \ + -d output_buffering=0 -S 127.0.0.1:$AUTO_PORT -t "$WORK/site/www" >/dev/null 2>&1 & +AUTO_PID=$! +r= +for i in $(seq 1 100); do + r=$(curl -fsS "http://127.0.0.1:$AUTO_PORT/console/api.php?action=ping" 2>/dev/null) && break + r= + sleep 0.1 +done +echo "$r" | grep -q '"proto"' || fail "auto-started backend did not answer ping" "$r" +json_ok "auto-start ping" "$r" +[ -S "$WORK/site/websh.sock" ] || fail "no socket next to websh.json" "$(ls -la "$WORK/site")" +mode=$(stat -c %a "$WORK/site/websh.sock") +[ "$mode" = "600" ] || fail "backend socket is not owner-only" "$mode" +pid=$(pgrep -f "$WORK/site/www/console/server.py" | head -1) +[ -n "$pid" ] || fail "auto-started server.py not found" "$(ps aux | grep server.py)" +# exec() hands php -S's own listening socket down to the child; any +# other listener is one server.py opened. +command -v ss >/dev/null || fail "ss is needed for the TCP-listener check" "" +own_tcp=$(ss -Hltnp 2>/dev/null | grep "pid=$pid," | grep -v "127.0.0.1:$AUTO_PORT " || true) +[ -z "$own_tcp" ] || fail "auto-started backend listens on TCP" "$own_tcp" + +# 9. A socket path in a directory other users can write is refused +# before anything is sent: a co-tenant could have planted the socket. +mkdir -m 777 "$WORK/shared" +chmod 777 "$WORK/shared" +python3 tests/php/stub_backend.py "$WORK/shared/websh.sock" > "$WORK/planted.out" & +PLANTED_PID=$! +for i in $(seq 1 50); do [ -S "$WORK/shared/websh.sock" ] && break; sleep 0.1; done +SHARED_PORT=$(free_port) +WEBSH_SOCKET=$WORK/shared/websh.sock php -d display_errors=1 -d error_reporting=-1 \ + -d output_buffering=0 -S 127.0.0.1:$SHARED_PORT -t . >/dev/null 2>&1 & +SHARED_PID=$! +code= +for i in $(seq 1 50); do + code=$(curl -s -o "$UNKNOWN_OUT" -w '%{http_code}' \ + "http://127.0.0.1:$SHARED_PORT/api.php?action=ping" 2>/dev/null) && [ "$code" != "000" ] && break + sleep 0.1 +done +kill $PLANTED_PID $SHARED_PID 2>/dev/null || true +[ "$code" = "500" ] || fail "socket in a shared dir was used" "$code $(cat "$UNKNOWN_OUT")" +grep -q 'writable only by it' "$UNKNOWN_OUT" || fail "shared-dir refusal reason" "$(cat "$UNKNOWN_OUT")" +json_ok "shared-dir refusal" "$(cat "$UNKNOWN_OUT")" + echo "PHP proxy smoke: all assertions passed" diff --git a/tests/php/stub_backend.py b/tests/php/stub_backend.py index ff95e3b..0e79f4b 100644 --- a/tests/php/stub_backend.py +++ b/tests/php/stub_backend.py @@ -6,13 +6,25 @@ required to set (X-Forwarded-For, Content-Type). /api/ping returns the shape ensure_backend() expects so api.php never tries to auto-start a real server.py. session_id=gone answers 404, so the smoke can check -that the proxy forwards backend status codes. +that the proxy forwards backend status codes. Listens on the Unix +socket given as argv[1], as server.py does under the PHP proxy. """ import json +import socket +import socketserver import sys from http.server import BaseHTTPRequestHandler, HTTPServer +class UnixHTTPServer(HTTPServer): + address_family = socket.AF_UNIX + + def server_bind(self): + # HTTPServer.server_bind would getfqdn() a (host, port) pair. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = "localhost", 0 + + class Echo(BaseHTTPRequestHandler): def _reply(self): status = 200 @@ -44,7 +56,6 @@ def log_message(self, *a): if __name__ == "__main__": - # Port 0 = kernel-assigned; print the real port for the caller. - srv = HTTPServer(("127.0.0.1", int(sys.argv[1]) if len(sys.argv) > 1 else 0), Echo) - print(srv.server_address[1], flush=True) + srv = UnixHTTPServer(sys.argv[1], Echo) + print("ready", flush=True) srv.serve_forever()