Contain legacy VNC behind a loopback inetd listener
Validate, publish and promote Helm chart / Validate chart (push) Successful in 7s
Validate, publish and promote Helm chart / Publish and promote chart (push) Successful in 2s

This commit is contained in:
feedc0de committed 2026-10-31 12:28:52 +01:00
1 parent 73b767d807
commit 815c5d752e
5 files changed
+56 -13

No files matched your search

+4 -1
View File
@@ -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
+3 -1
View File
@@ -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
+23
View File
@@ -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()
+2
View File
@@ -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 }}
+24 -11
View File
@@ -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)