Improve security and error handling for VM state and guest file operations; clarify permissions requirements in documentation.
This commit is contained in:
+1
-1
@@ -733,7 +733,7 @@ ip route show default
|
|||||||
iptables -t nat -S POSTROUTING
|
iptables -t nat -S POSTROUTING
|
||||||
iptables -S FORWARD
|
iptables -S FORWARD
|
||||||
ls -la /var/lib/uvm/vms
|
ls -la /var/lib/uvm/vms
|
||||||
cat /var/lib/uvm/state.json
|
sudo cat /var/lib/uvm/state.json # Contains plaintext guest passwords; do not share.
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not use these commands to mutate UVM resources manually while a lifecycle
|
Do not use these commands to mutate UVM resources manually while a lifecycle
|
||||||
|
|||||||
@@ -236,9 +236,9 @@ State writes are locked and atomic. Lifecycle operations share an additional
|
|||||||
operation lock so concurrent `create`, `stop`, `destroy`, and `install`
|
operation lock so concurrent `create`, `stop`, `destroy`, and `install`
|
||||||
commands cannot overwrite each other's state or binaries.
|
commands cannot overwrite each other's state or binaries.
|
||||||
|
|
||||||
The registry stores guest usernames and passwords in plaintext as requested,
|
The registry stores guest usernames and passwords in plaintext, so `state.json`
|
||||||
so `state.json` is mode `0600`. Treat it as a secret, do not include it in bug
|
is mode `0600`. Treat it as a secret, do not include it in bug reports, and
|
||||||
reports, and replace the default `root` password immediately.
|
replace the default `root` password immediately.
|
||||||
|
|
||||||
Credential provisioning applies only to VMs created by this version. Existing
|
Credential provisioning applies only to VMs created by this version. Existing
|
||||||
VM disks are not modified automatically.
|
VM disks are not modified automatically.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from uvm.config import Settings
|
|||||||
from uvm.errors import UvmError
|
from uvm.errors import UvmError
|
||||||
from uvm.images import (
|
from uvm.images import (
|
||||||
_FIRECRACKER_DEMO_PUBLIC_KEY,
|
_FIRECRACKER_DEMO_PUBLIC_KEY,
|
||||||
|
_GuestFile,
|
||||||
_enable_ssh_password_authentication,
|
_enable_ssh_password_authentication,
|
||||||
_set_shadow_password,
|
_set_shadow_password,
|
||||||
_without_firecracker_demo_key,
|
_without_firecracker_demo_key,
|
||||||
@@ -96,6 +97,8 @@ class ImageStoreTests(unittest.TestCase):
|
|||||||
"HostKey /etc/ssh/ssh_host_ecdsa_key\n"
|
"HostKey /etc/ssh/ssh_host_ecdsa_key\n"
|
||||||
"HostKey /etc/ssh/ssh_host_ed25519_key\n"
|
"HostKey /etc/ssh/ssh_host_ed25519_key\n"
|
||||||
"PasswordAuthentication no\n"
|
"PasswordAuthentication no\n"
|
||||||
|
"KbdInteractiveAuthentication no\n"
|
||||||
|
"ChallengeResponseAuthentication no\n"
|
||||||
"PermitRootLogin yes\n"
|
"PermitRootLogin yes\n"
|
||||||
"Match User root\n"
|
"Match User root\n"
|
||||||
" PasswordAuthentication yes\n"
|
" PasswordAuthentication yes\n"
|
||||||
@@ -105,6 +108,16 @@ class ImageStoreTests(unittest.TestCase):
|
|||||||
self.assertNotIn("/insecure/shared-key", updated)
|
self.assertNotIn("/insecure/shared-key", updated)
|
||||||
self.assertNotIn("PermitRootLogin prohibit-password", updated)
|
self.assertNotIn("PermitRootLogin prohibit-password", updated)
|
||||||
|
|
||||||
|
def test_non_root_credentials_preserve_the_existing_root_login_policy(self) -> None:
|
||||||
|
updated = _enable_ssh_password_authentication(
|
||||||
|
"PermitRootLogin no\nChallengeResponseAuthentication yes\n",
|
||||||
|
"service",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("PermitRootLogin no", updated)
|
||||||
|
self.assertIn("Match User service\n PasswordAuthentication yes", updated)
|
||||||
|
self.assertNotIn("ChallengeResponseAuthentication yes", updated)
|
||||||
|
|
||||||
def test_removes_only_the_public_firecracker_demo_key(self) -> None:
|
def test_removes_only_the_public_firecracker_demo_key(self) -> None:
|
||||||
own_key = "ssh-ed25519 AAAA-own-key developer@example"
|
own_key = "ssh-ed25519 AAAA-own-key developer@example"
|
||||||
|
|
||||||
@@ -150,3 +163,27 @@ class ImageStoreTests(unittest.TestCase):
|
|||||||
Path(temporary_directory) / "authorized_keys",
|
Path(temporary_directory) / "authorized_keys",
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_refuses_to_replace_files_with_unpreserved_security_metadata(self) -> None:
|
||||||
|
runner = PasswordHashRunner()
|
||||||
|
store = ImageStore(Settings(), runner=runner) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
for guest_file in (
|
||||||
|
_GuestFile(Path("/tmp/shadow"), 0o640, 0, 42, links=2),
|
||||||
|
_GuestFile(
|
||||||
|
Path("/tmp/shadow"),
|
||||||
|
0o640,
|
||||||
|
0,
|
||||||
|
42,
|
||||||
|
has_extended_attributes=True,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with self.subTest(guest_file=guest_file):
|
||||||
|
with self.assertRaises(UvmError):
|
||||||
|
store._write_guest_file(
|
||||||
|
Path("/tmp/rootfs.ext4"),
|
||||||
|
"/etc/shadow",
|
||||||
|
guest_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(runner.calls, [])
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from uvm.config import Settings
|
from uvm.config import Settings
|
||||||
from uvm.domain import VmRecord
|
from uvm.domain import VmRecord
|
||||||
|
from uvm.errors import StateError
|
||||||
from uvm.state import StateStore
|
from uvm.state import StateStore
|
||||||
|
|
||||||
|
|
||||||
@@ -97,3 +99,14 @@ class StateStoreTests(unittest.TestCase):
|
|||||||
self.store.load()
|
self.store.load()
|
||||||
|
|
||||||
self.assertEqual(stat.S_IMODE(self.settings.state_path.stat().st_mode), 0o600)
|
self.assertEqual(stat.S_IMODE(self.settings.state_path.stat().st_mode), 0o600)
|
||||||
|
|
||||||
|
def test_load_explains_when_registry_permissions_require_root(self) -> None:
|
||||||
|
self.base.mkdir(parents=True)
|
||||||
|
self.settings.state_path.write_text('{"vms": {}}', encoding="utf-8")
|
||||||
|
self.settings.state_path.chmod(0o644)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("uvm.state.os.chmod", side_effect=PermissionError),
|
||||||
|
self.assertRaisesRegex(StateError, "Run this command with sudo"),
|
||||||
|
):
|
||||||
|
self.store.load()
|
||||||
|
|||||||
+59
-3
@@ -24,6 +24,8 @@ _FIRECRACKER_DEMO_PUBLIC_KEY = (
|
|||||||
)
|
)
|
||||||
_MODE_PATTERN = re.compile(r"Mode:\s+0*([0-7]+)")
|
_MODE_PATTERN = re.compile(r"Mode:\s+0*([0-7]+)")
|
||||||
_OWNER_PATTERN = re.compile(r"User:\s+(\d+)\s+Group:\s+(\d+)")
|
_OWNER_PATTERN = re.compile(r"User:\s+(\d+)\s+Group:\s+(\d+)")
|
||||||
|
_FLAGS_PATTERN = re.compile(r"Flags:\s+0x([0-9a-fA-F]+)")
|
||||||
|
_LINKS_PATTERN = re.compile(r"Links:\s+(\d+)")
|
||||||
_HOST_KEY_TYPES = (
|
_HOST_KEY_TYPES = (
|
||||||
("rsa", "3072"),
|
("rsa", "3072"),
|
||||||
("ecdsa", "256"),
|
("ecdsa", "256"),
|
||||||
@@ -43,6 +45,9 @@ class _GuestFile:
|
|||||||
mode: int
|
mode: int
|
||||||
uid: int
|
uid: int
|
||||||
gid: int
|
gid: int
|
||||||
|
flags: int | None = None
|
||||||
|
links: int = 1
|
||||||
|
has_extended_attributes: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ImageStore:
|
class ImageStore:
|
||||||
@@ -184,7 +189,14 @@ class ImageStore:
|
|||||||
)
|
)
|
||||||
mode_match = _MODE_PATTERN.search(stat_result.stdout)
|
mode_match = _MODE_PATTERN.search(stat_result.stdout)
|
||||||
owner_match = _OWNER_PATTERN.search(stat_result.stdout)
|
owner_match = _OWNER_PATTERN.search(stat_result.stdout)
|
||||||
if mode_match is None or owner_match is None:
|
flags_match = _FLAGS_PATTERN.search(stat_result.stdout)
|
||||||
|
links_match = _LINKS_PATTERN.search(stat_result.stdout)
|
||||||
|
if (
|
||||||
|
mode_match is None
|
||||||
|
or owner_match is None
|
||||||
|
or flags_match is None
|
||||||
|
or links_match is None
|
||||||
|
):
|
||||||
output = f"{stat_result.stdout}\n{stat_result.stderr}".lower()
|
output = f"{stat_result.stdout}\n{stat_result.stderr}".lower()
|
||||||
if not required and "file not found" in output:
|
if not required and "file not found" in output:
|
||||||
return None
|
return None
|
||||||
@@ -198,14 +210,35 @@ class ImageStore:
|
|||||||
)
|
)
|
||||||
if not destination.is_file():
|
if not destination.is_file():
|
||||||
raise UvmError(f"could not read guest rootfs file: {guest_path}")
|
raise UvmError(f"could not read guest rootfs file: {guest_path}")
|
||||||
|
attributes = self._runner.run(
|
||||||
|
("debugfs", "-R", f"ea_list {guest_path}", disk),
|
||||||
|
check=False,
|
||||||
|
capture=True,
|
||||||
|
)
|
||||||
|
diagnostics = [
|
||||||
|
line
|
||||||
|
for line in attributes.stderr.splitlines()
|
||||||
|
if line and not line.startswith("debugfs ")
|
||||||
|
]
|
||||||
|
if attributes.returncode != 0 or diagnostics:
|
||||||
|
raise UvmError(f"could not inspect guest rootfs metadata: {guest_path}")
|
||||||
return _GuestFile(
|
return _GuestFile(
|
||||||
path=destination,
|
path=destination,
|
||||||
mode=int(mode_match.group(1), 8),
|
mode=int(mode_match.group(1), 8),
|
||||||
uid=int(owner_match.group(1)),
|
uid=int(owner_match.group(1)),
|
||||||
gid=int(owner_match.group(2)),
|
gid=int(owner_match.group(2)),
|
||||||
|
flags=int(flags_match.group(1), 16),
|
||||||
|
links=int(links_match.group(1)),
|
||||||
|
has_extended_attributes=bool(attributes.stdout.strip()),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _write_guest_file(self, disk: Path, guest_path: str, source: _GuestFile) -> None:
|
def _write_guest_file(self, disk: Path, guest_path: str, source: _GuestFile) -> None:
|
||||||
|
if source.links != 1:
|
||||||
|
raise UvmError(f"cannot safely replace hard-linked guest file: {guest_path}")
|
||||||
|
if source.has_extended_attributes:
|
||||||
|
raise UvmError(
|
||||||
|
f"cannot safely replace guest file with extended attributes: {guest_path}"
|
||||||
|
)
|
||||||
self._runner.run(("debugfs", "-w", "-R", f"rm {guest_path}", disk), capture=True)
|
self._runner.run(("debugfs", "-w", "-R", f"rm {guest_path}", disk), capture=True)
|
||||||
self._runner.run(
|
self._runner.run(
|
||||||
("debugfs", "-w", "-R", f"write {source.path} {guest_path}", disk),
|
("debugfs", "-w", "-R", f"write {source.path} {guest_path}", disk),
|
||||||
@@ -226,6 +259,17 @@ class ImageStore:
|
|||||||
),
|
),
|
||||||
capture=True,
|
capture=True,
|
||||||
)
|
)
|
||||||
|
if source.flags is not None:
|
||||||
|
self._runner.run(
|
||||||
|
(
|
||||||
|
"debugfs",
|
||||||
|
"-w",
|
||||||
|
"-R",
|
||||||
|
f"set_inode_field {guest_path} flags 0x{source.flags:x}",
|
||||||
|
disk,
|
||||||
|
),
|
||||||
|
capture=True,
|
||||||
|
)
|
||||||
|
|
||||||
verification = source.path.with_name(f"{source.path.name}.verify")
|
verification = source.path.with_name(f"{source.path.name}.verify")
|
||||||
written = self._read_guest_file(disk, guest_path, verification)
|
written = self._read_guest_file(disk, guest_path, verification)
|
||||||
@@ -235,6 +279,7 @@ class ImageStore:
|
|||||||
or written.mode != source.mode
|
or written.mode != source.mode
|
||||||
or written.uid != source.uid
|
or written.uid != source.uid
|
||||||
or written.gid != source.gid
|
or written.gid != source.gid
|
||||||
|
or (source.flags is not None and written.flags != source.flags)
|
||||||
):
|
):
|
||||||
raise UvmError(f"could not verify updated guest rootfs file: {guest_path}")
|
raise UvmError(f"could not verify updated guest rootfs file: {guest_path}")
|
||||||
|
|
||||||
@@ -315,7 +360,14 @@ def _set_shadow_password(contents: str, username: str, password_hash: str) -> st
|
|||||||
|
|
||||||
|
|
||||||
def _enable_ssh_password_authentication(contents: str, username: str) -> str:
|
def _enable_ssh_password_authentication(contents: str, username: str) -> str:
|
||||||
managed_directives = {"hostkey", "passwordauthentication", "permitrootlogin"}
|
managed_directives = {
|
||||||
|
"challengeresponseauthentication",
|
||||||
|
"hostkey",
|
||||||
|
"kbdinteractiveauthentication",
|
||||||
|
"passwordauthentication",
|
||||||
|
}
|
||||||
|
if username == "root":
|
||||||
|
managed_directives.add("permitrootlogin")
|
||||||
unmanaged_lines = []
|
unmanaged_lines = []
|
||||||
for line in contents.splitlines():
|
for line in contents.splitlines():
|
||||||
stripped = line.lstrip()
|
stripped = line.lstrip()
|
||||||
@@ -328,13 +380,17 @@ def _enable_ssh_password_authentication(contents: str, username: str) -> str:
|
|||||||
"HostKey /etc/ssh/ssh_host_ecdsa_key",
|
"HostKey /etc/ssh/ssh_host_ecdsa_key",
|
||||||
"HostKey /etc/ssh/ssh_host_ed25519_key",
|
"HostKey /etc/ssh/ssh_host_ed25519_key",
|
||||||
"PasswordAuthentication no",
|
"PasswordAuthentication no",
|
||||||
|
"KbdInteractiveAuthentication no",
|
||||||
|
"ChallengeResponseAuthentication no",
|
||||||
]
|
]
|
||||||
if username == "root":
|
if username == "root":
|
||||||
directives.append("PermitRootLogin yes")
|
directives.append("PermitRootLogin yes")
|
||||||
return (
|
return (
|
||||||
"# Managed by uvm\n"
|
"# Managed by uvm\n"
|
||||||
+ "\n".join(directives)
|
+ "\n".join(directives)
|
||||||
+ f"\nMatch User {username}\n PasswordAuthentication yes\nMatch all\n"
|
+ f"\nMatch User {username}\n"
|
||||||
|
+ " PasswordAuthentication yes\n"
|
||||||
|
+ "Match all\n"
|
||||||
+ "\n".join(unmanaged_lines).rstrip()
|
+ "\n".join(unmanaged_lines).rstrip()
|
||||||
+ "\n"
|
+ "\n"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ class StateStore:
|
|||||||
os.chmod(path, 0o600)
|
os.chmod(path, 0o600)
|
||||||
with path.open(encoding="utf-8") as state_file:
|
with path.open(encoding="utf-8") as state_file:
|
||||||
raw = json.load(state_file)
|
raw = json.load(state_file)
|
||||||
|
except PermissionError as error:
|
||||||
|
raise StateError(
|
||||||
|
f"cannot access protected VM registry {path}. Run this command with sudo."
|
||||||
|
) from error
|
||||||
except (OSError, json.JSONDecodeError) as error:
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
raise StateError(f"cannot read {path}: {error}") from error
|
raise StateError(f"cannot read {path}: {error}") from error
|
||||||
return State.from_dict(raw)
|
return State.from_dict(raw)
|
||||||
|
|||||||
Reference in New Issue
Block a user