Skip to content

fix: run the shared-hosting backend on a Unix socket - #155

Open
dolonet wants to merge 1 commit into
fix/php85-curl-closefrom
fix/backend-unix-socket
Open

dolonet wants to merge 1 commit into
fix/php85-curl-closefrom
fix/backend-unix-socket

Conversation

@dolonet

@dolonet dolonet commented Oct 23, 2026

Copy link
Copy Markdown
Owner

Stacked on #154 (fix/php85-curl-close): this diff contains only the socket change. Please merge #153 first. It rewrites the outdated "PHP 5.3+" lines in api.php and the README, which this PR leaves untouched to avoid a conflict. The new floor for the PHP proxy is PHP 7.0.7+ with libcurl 7.40+.

Problem

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.

Changes

  • server.py
    • New env-only WEBSH_SOCKET. When it is set, the backend listens on a Unix socket created with mode 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.
    • The socket's directory must belong to the account and must not be writable by other users.
    • Socket peers are trusted for X-Forwarded-For and WEBSH_AUTH_HEADER, the same way loopback peers are today.
  • api.php
    • Always reaches the backend through websh.sock next to websh.json. WEBSH_SOCKET overrides the path.
    • Auto-starts server.py with that socket path. WEBSH_PORT is removed.
    • The auto-start lock moves out of the shared temp directory and into the socket's directory.
    • Refuses a socket directory that other users can write to, and a socket owned by another account.
    • Every misconfiguration answers 500 with the reason and never falls back to TCP: no Unix socket support in curl, a path over 107 bytes, an unusable directory or lock file.
  • Docs: deployment, configuration and security pages are updated, including troubleshooting for socket-directory errors.
  • Docker, systemd and reverse-proxy setups are unchanged and 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. After upgrading, stop it (pkill -f server.py) or wait for the host to recycle the process. Hosts where PHP doesn't run as the account itself (for example, mod_php under a shared user), or where open_basedir blocks the site root, now get a 500 that explains the problem, where before they ran without this protection.

Test plan

  • ruff, backend suite: 541 tests OK
  • New unit tests:
    • socket server: 0600 mode, umask restored, stale/live/non-socket paths, a directory others can write to, main() wiring;
    • socket peers in _client_ip and _client_identity.
  • PHP smoke on 7.4, 8.3 and 8.5:
    • the stub backend runs on a socket;
    • the real server.py is auto-started end to end: 0600 socket and no TCP listener;
    • a socket planted in a world-writable directory is refused;
    • the check fails if that protection is disabled.
  • Manual check: a socket owned by another uid is refused with 500.
  • CI green

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.
@dolonet
dolonet requested a review from gorevds October 23, 2026 18:21
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.

1 participant