mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 03:36:48 +00:00
fix(gstfsd): append SSH keys, IPv6 loopback bind, server-side timeout
- Add the public key to authorized_keys instead of replacing the file. - Use AF_INET6 when GSTFSD_BIND_HOST is an IPv6 address (binding ::1 crashed with the AF_INET server). - Close idle client connections after 30 s. Tests run the real daemon against a small fake guestfs module over a socket; this also exercises the earlier sendall() fix.
This commit is contained in:
parent
32d7f279e7
commit
a6364f5d9b
2 changed files with 130 additions and 16 deletions
|
|
@ -8,6 +8,7 @@ import os
|
|||
import socketserver
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
|
||||
PORT = int(os.environ.get("GSTFSD_PORT", 16510))
|
||||
|
|
@ -36,9 +37,12 @@ import guestfs # noqa: E402
|
|||
|
||||
class MyTCPServer(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address = True
|
||||
address_family = socket.AF_INET6 if ":" in ADDRESS else socket.AF_INET
|
||||
|
||||
|
||||
class MyTCPServerHandler(socketserver.BaseRequestHandler):
|
||||
class MyTCPServerHandler(socketserver.StreamRequestHandler):
|
||||
timeout = 30 # seconds; an idle client must not hold a thread forever
|
||||
|
||||
def handle(self):
|
||||
# recive data
|
||||
d = self.request.recv(1024).strip()
|
||||
|
|
@ -65,7 +69,14 @@ class MyTCPServerHandler(socketserver.BaseRequestHandler):
|
|||
if not gfs.is_dir('/root/.ssh'):
|
||||
gfs.mkdir('/root/.ssh')
|
||||
gfs.chmod(0o700, "/root/.ssh")
|
||||
gfs.write('/root/.ssh/authorized_keys', data['key'])
|
||||
# Add the key; keep the ones already there.
|
||||
keys_path = '/root/.ssh/authorized_keys'
|
||||
keys = gfs.cat(keys_path) if gfs.is_file(keys_path) else ''
|
||||
new_key = data['key'].strip()
|
||||
if new_key not in keys.splitlines():
|
||||
if keys and not keys.endswith('\n'):
|
||||
keys += '\n'
|
||||
gfs.write(keys_path, keys + new_key + '\n')
|
||||
gfs.chmod(0o600, '/root/.ssh/authorized_keys')
|
||||
self.request.sendall(json.dumps({'return': 'success'}).encode())
|
||||
gfs.umount(part)
|
||||
|
|
|
|||
|
|
@ -1,31 +1,134 @@
|
|||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
GSTFSD = Path(__file__).resolve().parent.parent / "conf" / "daemon" / "gstfsd"
|
||||
|
||||
# A stand-in for libguestfs: one partition whose files live in a JSON file.
|
||||
FAKE_GUESTFS = '''
|
||||
import json, os
|
||||
STATE = os.environ["FAKE_GUESTFS_STATE"]
|
||||
def _load():
|
||||
with open(STATE) as f:
|
||||
return json.load(f)
|
||||
def _save(state):
|
||||
with open(STATE, "w") as f:
|
||||
json.dump(state, f)
|
||||
class GuestFS:
|
||||
def __init__(self, **kwargs): pass
|
||||
def add_domain(self, name): pass
|
||||
def launch(self): pass
|
||||
def list_partitions(self): return ["/dev/sda1"]
|
||||
def mount(self, part, mountpoint): pass
|
||||
def umount(self, part): pass
|
||||
def shutdown(self): pass
|
||||
def close(self): pass
|
||||
def is_file(self, path): return path in _load()["files"]
|
||||
def is_dir(self, path): return path in _load()["dirs"]
|
||||
def cat(self, path): return _load()["files"][path]
|
||||
def write(self, path, content):
|
||||
state = _load(); state["files"][path] = content; _save(state)
|
||||
def mkdir(self, path):
|
||||
state = _load(); state["dirs"].append(path); _save(state)
|
||||
def chmod(self, mode, path): pass
|
||||
'''
|
||||
|
||||
class GstfsdBindTestCase(unittest.TestCase):
|
||||
"""ROADMAP S-08: gstfsd is an unauthenticated root service; no remote bind by default."""
|
||||
|
||||
def _run(self, **env):
|
||||
def free_port(family=socket.AF_INET, host="127.0.0.1"):
|
||||
with socket.socket(family, socket.SOCK_STREAM) as s:
|
||||
s.bind((host, 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
class GstfsdTestCase(unittest.TestCase):
|
||||
"""ROADMAP S-08: gstfsd hardening."""
|
||||
|
||||
def setUp(self):
|
||||
# novncd (imported by other tests) sets SIGCHLD to SIG_IGN, which hides exit codes.
|
||||
previous = signal.signal(signal.SIGCHLD, signal.SIG_DFL)
|
||||
try:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(GSTFSD)],
|
||||
env=dict(os.environ, **env),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
finally:
|
||||
signal.signal(signal.SIGCHLD, previous)
|
||||
self.addCleanup(signal.signal, signal.SIGCHLD, previous)
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
Path(self.tmp.name, "guestfs.py").write_text(FAKE_GUESTFS)
|
||||
self.state = Path(self.tmp.name, "state.json")
|
||||
self.state.write_text(json.dumps({
|
||||
"files": {"/etc/shadow": "root:x:1::::::\n", "/root/.ssh/authorized_keys": "ssh-rsa OLD old@host\n"},
|
||||
"dirs": ["/root/.ssh"],
|
||||
}))
|
||||
|
||||
def _env(self, **extra):
|
||||
return dict(
|
||||
os.environ,
|
||||
PYTHONPATH=self.tmp.name,
|
||||
FAKE_GUESTFS_STATE=str(self.state),
|
||||
**extra,
|
||||
)
|
||||
|
||||
def _start(self, host, port, family=socket.AF_INET):
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(GSTFSD)],
|
||||
env=self._env(GSTFSD_BIND_HOST=host, GSTFSD_PORT=str(port)),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
self.addCleanup(proc.wait)
|
||||
self.addCleanup(proc.kill)
|
||||
for _ in range(100):
|
||||
if proc.poll() is not None:
|
||||
self.fail("gstfsd exited: " + proc.stderr.read())
|
||||
try:
|
||||
with socket.socket(family, socket.SOCK_STREAM) as s:
|
||||
s.connect((host, port))
|
||||
return proc
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
self.fail("gstfsd did not start listening")
|
||||
|
||||
def _request(self, host, port, data, family=socket.AF_INET):
|
||||
with socket.socket(family, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(10)
|
||||
s.connect((host, port))
|
||||
s.sendall(json.dumps(data).encode())
|
||||
return json.loads(s.recv(1024))
|
||||
|
||||
def test_refuses_non_loopback_bind_without_opt_in(self):
|
||||
result = self._run(GSTFSD_BIND_HOST="0.0.0.0")
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(GSTFSD)],
|
||||
env=self._env(GSTFSD_BIND_HOST="0.0.0.0"),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("GSTFSD_ALLOW_REMOTE", result.stderr)
|
||||
|
||||
def test_public_key_is_appended_not_overwritten(self):
|
||||
port = free_port()
|
||||
self._start("127.0.0.1", port)
|
||||
reply = self._request(
|
||||
"127.0.0.1", port, {"action": "publickey", "vname": "vm", "key": "ssh-ed25519 NEW new@host"}
|
||||
)
|
||||
self.assertEqual(reply["return"], "success")
|
||||
keys = json.loads(self.state.read_text())["files"]["/root/.ssh/authorized_keys"]
|
||||
self.assertEqual(keys.splitlines(), ["ssh-rsa OLD old@host", "ssh-ed25519 NEW new@host"])
|
||||
|
||||
@unittest.skipUnless(socket.has_ipv6, "no IPv6")
|
||||
def test_listens_on_ipv6_loopback(self):
|
||||
try:
|
||||
port = free_port(socket.AF_INET6, "::1")
|
||||
except OSError:
|
||||
self.skipTest("IPv6 loopback not available")
|
||||
self._start("::1", port, socket.AF_INET6)
|
||||
reply = self._request(
|
||||
"::1", port, {"action": "publickey", "vname": "vm", "key": "ssh-ed25519 V6 v6@host"},
|
||||
socket.AF_INET6,
|
||||
)
|
||||
self.assertEqual(reply["return"], "success")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue