hermes: read node access keys through CSI symlinks

The Vault CSI secrets-store projects /vault/secrets/node-ssh-public-key
as a symlink chain (file -> ..data/file -> ..<timestamp>/file), which
the O_NOFOLLOW hardened reader refuses with ELOOP, crash-looping the
hermes-node-ssh-access key reconciler on most nodes.

Resolve the projection safely instead of relaxing the reader: realpath
the secret strictly (loops and dangling links become a clean refusal),
refuse any resolution that escapes the read-only mount, then apply the
unchanged O_NOFOLLOW regular-file read to the resolved target. Host
account databases and authorized_keys keep the strict no-symlink read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jenkins 2026-08-18 16:20:08 -03:00
parent a5cafddd45
commit 0a4ff7f1f6
4 changed files with 99 additions and 1 deletions

View File

@ -22,6 +22,7 @@ from node_account_io import (
assert_unchanged,
atomic_write,
backup_once,
read_mounted_secret,
read_regular,
)
@ -214,7 +215,8 @@ def _key_identity(line: bytes) -> tuple[str, bytes] | None:
def _validated_public_key(path: Path) -> tuple[bytes, tuple[str, bytes]]:
value, _ = _read_regular(path, 16 * 1024)
# Vault CSI projects the key as a symlink chain inside its own mount.
value = read_mounted_secret(path, path.parent, 16 * 1024).value
line = value.strip()
if b"\n" in line or b"\r" in line:
raise HardeningError("Hermes public key must contain one line")

View File

@ -113,6 +113,27 @@ def read_regular(path: Path, maximum: int) -> FileSnapshot:
)
def read_mounted_secret(path: Path, mount_root: Path, maximum: int) -> FileSnapshot:
"""Read one bounded secret projected through mount-internal symlinks.
Kubernetes CSI and projected volumes present each file as a symlink into
a hidden ``..data`` directory, which the O_NOFOLLOW read refuses. Resolve
the full chain first, refuse any resolution that loops or escapes the
read-only mount, then apply the unchanged hardened read to the resolved
regular file.
"""
try:
resolved = Path(os.path.realpath(path, strict=True))
root = Path(os.path.realpath(mount_root, strict=True))
except FileNotFoundError:
raise
except OSError as exc:
raise HardeningError(f"unresolvable mounted secret: {path.name}") from exc
if root not in resolved.parents:
raise HardeningError(f"mounted secret escapes its mount: {path.name}")
return read_regular(resolved, maximum)
def assert_unchanged(path: Path, expected: FileSnapshot, maximum: int) -> None:
"""Fail if an account file changed since planning began."""
current = read_regular(path, maximum)

View File

@ -99,6 +99,21 @@ def test_preexisting_target_key_is_removed_before_legacy_cleanup_and_reinstalled
assert target.read_text() == key + "\n"
def test_reconcile_reads_csi_projected_public_key(tmp_path: Path, monkeypatch):
module, _originals, key, _other, public_key = _fixture(tmp_path, monkeypatch)
data = public_key.parent / "..2026_08_18.1"
data.mkdir()
(data / public_key.name).write_text(key + "\n", encoding="utf-8")
(public_key.parent / "..data").symlink_to(data.name)
public_key.unlink()
public_key.symlink_to(Path("..data") / public_key.name)
module.reconcile(public_key)
target = module.HOST_HOME / module.ACCOUNT / ".ssh/authorized_keys"
assert target.read_text() == key + "\n"
def test_account_database_write_preserves_mode_owner_and_xattrs(
tmp_path: Path, monkeypatch
):

View File

@ -106,6 +106,66 @@ def test_read_regular_rejects_directory_oversize_and_changed_read(
module.read_regular(source, 10)
def test_read_mounted_secret_follows_csi_projection_inside_mount(tmp_path):
module = _io_module()
mount = tmp_path / "vault-secrets"
data = mount / "..2026_08_18.1"
data.mkdir(parents=True)
(data / "node-ssh-public-key").write_bytes(b"ssh-ed25519 synthetic\n")
(mount / "..data").symlink_to(data.name)
(mount / "node-ssh-public-key").symlink_to(Path("..data") / "node-ssh-public-key")
snapshot = module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
assert snapshot.value == b"ssh-ed25519 synthetic\n"
def test_read_mounted_secret_reads_plain_regular_file_unchanged(tmp_path):
module = _io_module()
mount = tmp_path / "vault-secrets"
mount.mkdir()
secret = mount / "node-ssh-public-key"
secret.write_bytes(b"ssh-ed25519 synthetic\n")
snapshot = module.read_mounted_secret(secret, mount, 64)
assert snapshot == module.read_regular(secret, 64)
def test_read_mounted_secret_refuses_escape_from_mount(tmp_path):
module = _io_module()
mount = tmp_path / "vault-secrets"
mount.mkdir()
outside = tmp_path / "outside"
outside.write_bytes(b"planted\n")
(mount / "node-ssh-public-key").symlink_to(outside)
with pytest.raises(module.HardeningError, match="escapes its mount"):
module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
def test_read_mounted_secret_refuses_symlink_loop(tmp_path):
module = _io_module()
mount = tmp_path / "vault-secrets"
mount.mkdir()
(mount / "node-ssh-public-key").symlink_to("node-ssh-public-key")
with pytest.raises(module.HardeningError, match="unresolvable mounted secret"):
module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
def test_read_mounted_secret_reports_missing_secret_as_not_found(tmp_path):
module = _io_module()
mount = tmp_path / "vault-secrets"
mount.mkdir()
with pytest.raises(FileNotFoundError):
module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
def test_read_mounted_secret_refuses_non_regular_resolved_target(tmp_path):
module = _io_module()
mount = tmp_path / "vault-secrets"
data = mount / "..data"
data.mkdir(parents=True)
(mount / "node-ssh-public-key").symlink_to("..data")
with pytest.raises(module.HardeningError, match="unsafe regular"):
module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
def test_assert_unchanged_rejects_changed_snapshot(tmp_path):
module = _io_module()
source = tmp_path / "passwd"