From 815c5d752e01fd6a7f3ac0d05613d8173a0a1f3b Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Sat, 31 Oct 2026 12:28:52 +0100 Subject: [PATCH] Contain legacy VNC behind a loopback inetd listener --- README.md | 5 ++++- chart/files/supervisord.conf | 4 +++- chart/files/vnc-listener.py | 23 ++++++++++++++++++++++ chart/templates/configmap.yaml | 2 ++ test.py | 35 +++++++++++++++++++++++----------- 5 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 chart/files/vnc-listener.py diff --git a/README.md b/README.md index b290ad2..5fe8517 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,10 @@ capabilities, no privilege escalation, RuntimeDefault seccomp, no service-accoun token, and bounded ephemeral writable mounts. A short init container runs as UID 0 with the same restrictions solely to copy bundled noVNC assets out of the upstream image's private `/root` directory. It downloads nothing. X11 TCP is -disabled and VNC listens on localhost; only noVNC HTTP/WebSocket is served. +disabled; only noVNC HTTP/WebSocket is served. A tiny Python listener binds +IPv4 loopback and starts x11vnc in inetd mode per connection. This avoids the +upstream LibVNCServer IPv6 listener that ignores x11vnc's interface/disable +flags. x11vnc itself opens no TCP listeners; CI verifies every listening socket. Chromium retains upstream's `--no-sandbox` compatibility flag. One shared desktop is used by every authorized visitor. Closing your tab does diff --git a/chart/files/supervisord.conf b/chart/files/supervisord.conf index d034545..d8ee626 100644 --- a/chart/files/supervisord.conf +++ b/chart/files/supervisord.conf @@ -10,8 +10,10 @@ command=/usr/bin/Xvfb :0 -screen 0 %(ENV_RES)s -nolisten tcp autorestart=true [program:x11vnc] -command=/usr/bin/x11vnc -localhost -listen 127.0.0.1 -no6 -forever -shared -nopw +command=/usr/bin/python /etc/oldipmi/vnc-listener.py autorestart=true +stopasgroup=true +killasgroup=true [program:novnc] command=/usr/bin/python /opt/novnc/site/utils/websockify --web /opt/novnc/site 8080 localhost:5900 diff --git a/chart/files/vnc-listener.py b/chart/files/vnc-listener.py new file mode 100644 index 0000000..4ace268 --- /dev/null +++ b/chart/files/vnc-listener.py @@ -0,0 +1,23 @@ +#!/usr/bin/python +"""Keep this old LibVNCServer behind an explicitly IPv4-loopback listener. + +Its built-in IPv6 listener ignores x11vnc's interface/disable flags. Inetd mode +uses an already-connected socket instead, so x11vnc opens no listening sockets. +""" +import SocketServer +import subprocess + + +class VNC(SocketServer.BaseRequestHandler): + def handle(self): + subprocess.call( + ['/usr/bin/x11vnc', '-inetd', '-shared', '-nopw'], + stdin=self.request, stdout=self.request, close_fds=True) + + +class LoopbackServer(SocketServer.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +LoopbackServer(('127.0.0.1', 5900), VNC).serve_forever() diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 225449c..52c27c5 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -5,3 +5,5 @@ metadata: data: supervisord.conf: | {{ .Files.Get "files/supervisord.conf" | indent 4 }} + vnc-listener.py: | +{{ .Files.Get "files/vnc-listener.py" | indent 4 }} diff --git a/test.py b/test.py index 609acfb..0619bfc 100755 --- a/test.py +++ b/test.py @@ -67,18 +67,28 @@ with tempfile.TemporaryDirectory(prefix='oldipmi-test-') as tmp: else: raise AssertionError('noVNC did not start') # A real WebSocket upgrade must reach the local X11 VNC server. - with socket.create_connection(('127.0.0.1', port), timeout=10) as ws: - key = base64.b64encode(os.urandom(16)).decode() - ws.sendall((f'GET /websockify HTTP/1.1\r\nHost: localhost:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: binary\r\n\r\n').encode()) - received = b'' - while b'RFB ' not in received: - data = ws.recv(4096) - assert data, 'VNC handshake closed early' - received += data - assert b'101 Switching Protocols' in received + for attempt in range(15): + try: + with socket.create_connection(('127.0.0.1', port), timeout=10) as ws: + key = base64.b64encode(os.urandom(16)).decode() + ws.sendall((f'GET /websockify HTTP/1.1\r\nHost: localhost:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: binary\r\n\r\n').encode()) + received = b'' + while b'RFB ' not in received: + data = ws.recv(4096) + assert data, 'VNC handshake closed early' + received += data + assert b'101 Switching Protocols' in received + break + except (AssertionError, OSError): + if attempt == 14: + raise + time.sleep(1) listeners = run('podman', 'exec', cid, 'netstat', '-lntp') - vnc_listeners = [line.split()[3] for line in listeners.splitlines() if 'x11vnc' in line] - assert vnc_listeners and all(address.startswith('127.0.0.1:') for address in vnc_listeners), listeners + assert not any('x11vnc' in line for line in listeners.splitlines()), listeners + http_port = str(app['ports'][0]['containerPort']) + for line in listeners.splitlines()[2:]: + address = line.split()[3] + assert address == '0.0.0.0:' + http_port or address.rsplit(':', 1)[0] == '127.0.0.1', listeners status = run('podman', 'exec', cid, 'cat', '/proc/1/status') assert 'NoNewPrivs:\t1' in status and 'CapEff:\t0000000000000000' in status assert run('podman', 'exec', cid, 'id', '-u') != '0' @@ -86,6 +96,9 @@ with tempfile.TemporaryDirectory(prefix='oldipmi-test-') as tmp: assert '/usr/lib/chromium-browser/chromium-browser' in processes assert '/usr/bin/Xvfb' in processes print('Non-root read-only runtime, browser, HTTP, WebSocket/RFB, and zero capabilities passed') + except Exception: + print(run('podman', 'exec', cid, 'sh', '-c', 'tail -n 25 /tmp/x11vnc-stderr* /tmp/novnc-stderr*')) + raise finally: subprocess.run(['podman', 'rm', '-f', cid], check=True, stdout=subprocess.DEVNULL) subprocess.run(container_args(init) + ['--rm', '--network=none', init['image'], '/bin/sh', '-ec', 'rm -rf /opt/novnc/site'], check=True)