Skip to content

Commit 1e2a1c7

Browse files
harden(cloud): scrub secrets on LXC + QEMU-REST error paths; bound cloud-init password (#223)
2 parents b5c325e + 41b7979 commit 1e2a1c7

6 files changed

Lines changed: 79 additions & 12 deletions

File tree

proxbox_api/routes/cloud/CLAUDE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,12 @@ Proxmox `cipassword` cloud-init field, so a cloned VM supports username+password
7575
SSH when the source template also permits password auth (`ssh_pwauth: true`,
7676
baked by netbox-packer). `cipassword`/`password` are treated as secrets: they are
7777
never logged and are redacted by `utils/log_scrubbing.scrub_cloud_init` at the
78-
journal/write boundary.
78+
journal/write boundary. The redaction is applied **on every provisioning error
79+
surface** (#222): the QEMU REST step-rollback wrapper scrubs even on the default
80+
`enforce_cloud_network=False` path (parity with the SSE stream, which always
81+
scrubs), and the LXC `provision_lxc` failure handler scrubs the 502 body + log
82+
line too (the LXC create carries a `password` field). `CloudInitPayload.password`
83+
is bounded to `max_length=128`.
7984

8085
QEMU and LXC provisioning also accept `enforce_cloud_network` (default
8186
`false`). When true, proxbox-api resolves the customer-network settings from

proxbox_api/routes/cloud/lxc.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from proxbox_api.services.proxmox_helpers import get_node_storage_content, get_node_task_status
3333
from proxbox_api.session.netbox import get_netbox_async_session
3434
from proxbox_api.session.proxmox import ProxmoxSession
35+
from proxbox_api.utils.log_scrubbing import scrub_cloud_init
3536

3637
router = APIRouter()
3738

@@ -467,10 +468,14 @@ async def provision_lxc(
467468
# Best-effort rollback keeps a failed LXC create from consuming a
468469
# customer-network IP when Proxmox rejects the request after allocation.
469470
await _release_lxc_cloud_network_lease(lease)
470-
logger.warning("lxc provision failed endpoint=%s: %s", req.endpoint_id, error)
471+
# Scrub the cloud-init password (Proxmox `password`) out of the error
472+
# text before it reaches the log or the client 502 body — parity with
473+
# the QEMU provision path (the LXC create carries `password` too).
474+
safe_error = scrub_cloud_init({"error": str(error)}).get("error", str(error))
475+
logger.warning("lxc provision failed endpoint=%s: %s", req.endpoint_id, safe_error)
471476
raise HTTPException(
472477
status_code=status.HTTP_502_BAD_GATEWAY,
473-
detail={"reason": "lxc_provision_failed", "error": str(error)},
478+
detail={"reason": "lxc_provision_failed", "error": safe_error},
474479
) from error
475480
finally:
476481
if proxmox is not None:

proxbox_api/routes/cloud/provision.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -350,17 +350,16 @@ async def _run_proxmox_step_with_cloud_network_rollback(
350350
operation: Awaitable[_T],
351351
lease: _CloudNetworkLease | None,
352352
) -> _T:
353-
if lease is None:
354-
# No cloud-network allocation to roll back: preserve legacy behavior
355-
# exactly (the original exception propagates unchanged, same status/body)
356-
# so enforce_cloud_network=False stays byte-identical to pre-feature.
357-
return await operation
358353
try:
359354
return await operation
360355
except Exception as error: # noqa: BLE001
361356
# NetBox allocation happens before Proxmox writes so the guest can
362357
# receive a stable CIDR; release it if any later Proxmox step fails.
363-
await _release_cloud_network_lease(lease)
358+
if lease is not None:
359+
await _release_cloud_network_lease(lease)
360+
# Always route through _proxmox_step_failed so the cipassword is scrubbed
361+
# from the log + client body even on the default enforce_cloud_network=
362+
# False path — parity with the SSE stream, which scrubs unconditionally.
364363
raise _proxmox_step_failed(step, error) from error
365364

366365

@@ -575,7 +574,10 @@ def _build_agent_override(existing_config: dict | None) -> str:
575574
``fstrim_cloned_disks`` on clone. Mirror ``_build_net0_override`` and merge:
576575
keep every non-``enabled`` sub-option and force a single ``enabled=1``.
577576
"""
578-
existing = str((existing_config or {}).get("agent") or "").strip()
577+
# Normalize through mapping_from_response like the sibling helpers
578+
# (_build_net0_override, _has_cloudinit_drive) so a Pydantic model or a
579+
# {"data": ...} envelope is handled defensively instead of raising.
580+
existing = str(mapping_from_response(existing_config).get("agent") or "").strip()
579581
if not existing:
580582
return "enabled=1"
581583
merged: list[str] = []

proxbox_api/routes/intent/cloud_init.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from urllib.parse import quote
77

88
import yaml
9-
from pydantic import BaseModel, field_validator
9+
from pydantic import BaseModel, Field, field_validator
1010

1111
_DNS_SEARCH_DOMAIN_RE = re.compile(
1212
r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
@@ -16,7 +16,7 @@
1616

1717
class CloudInitPayload(BaseModel):
1818
user: Optional[str] = None
19-
password: Optional[str] = None
19+
password: Optional[str] = Field(default=None, max_length=128)
2020
ssh_keys: Optional[list[str]] = None
2121
user_data: Optional[Union[str, dict]] = None
2222
network: Optional[dict] = None

tests/cloud/test_lxc_templates.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,24 @@ def test_build_lxc_create_params_adds_cloud_network_net0() -> None:
122122
)
123123

124124
assert params["net0"] == "name=eth0,bridge=vmbr1,tag=2050,ip=168.0.98.10/24,gw=168.0.98.1"
125+
126+
127+
def test_lxc_provision_error_handler_scrubs_secrets() -> None:
128+
"""#222: the LXC provision failure path must scrub the cloud-init password
129+
out of the log + 502 body (parity with the QEMU path). The LXC create
130+
carries a `password` field, so a raw `str(error)` passthrough could leak it.
131+
Assert the module imports the scrubber and the handler routes the error
132+
through it instead of embedding raw `str(error)` in the client detail."""
133+
import inspect
134+
135+
from proxbox_api.utils.log_scrubbing import scrub_cloud_init
136+
137+
assert lxc.scrub_cloud_init is scrub_cloud_init
138+
# The lxc_provision_failed 502 handler (which carries the create password)
139+
# must build its detail from the scrubbed value, not raw str(error).
140+
handler_source = inspect.getsource(lxc.provision_lxc)
141+
# The error is scrubbed and the client detail is built from the scrubbed value.
142+
assert 'scrub_cloud_init({"error": str(error)})' in handler_source
143+
assert '"error": safe_error' in handler_source
144+
# Both the log line and the 502 detail use safe_error (scrubbed), not raw.
145+
assert handler_source.count("safe_error") >= 2

tests/cloud/test_provision_lifecycle.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,40 @@ def test_build_agent_override_merges_existing_options():
8888
== "enabled=1,fstrim_cloned_disks=1,type=virtio"
8989
)
9090
assert _build_agent_override({"agent": "enabled=0,type=virtio"}) == "enabled=1,type=virtio"
91+
# #222: normalized through mapping_from_response, so a {"data": ...} envelope
92+
# (or a non-dict) is handled defensively instead of raising AttributeError.
93+
assert _build_agent_override({"data": {"agent": "type=virtio"}}) == "enabled=1,type=virtio"
94+
assert _build_agent_override("not-a-dict") == "enabled=1" # type: ignore[arg-type]
95+
96+
97+
def test_cloud_init_password_is_length_bounded() -> None:
98+
"""#222: CloudInitPayload.password must be bounded (max 128) so an oversized
99+
cipassword is rejected client-side rather than bloating the VM config or
100+
producing an opaque Proxmox error."""
101+
import pytest as _pytest
102+
from pydantic import ValidationError
103+
104+
CloudInitPayload(password="x" * 128) # ok at the boundary
105+
with _pytest.raises(ValidationError):
106+
CloudInitPayload(password="x" * 129)
107+
108+
109+
@pytest.mark.asyncio
110+
async def test_step_rollback_scrubs_cipassword_even_without_lease() -> None:
111+
"""#222: the default enforce_cloud_network=False path (lease is None) must
112+
still route exceptions through _proxmox_step_failed so the cipassword is
113+
redacted from the client 502 body — parity with the SSE stream."""
114+
from fastapi import HTTPException
115+
116+
async def _boom() -> None:
117+
raise RuntimeError("proxmox rejected cipassword=SuperSecret123 on config.put")
118+
119+
with pytest.raises(HTTPException) as exc:
120+
await provision_route._run_proxmox_step_with_cloud_network_rollback(
121+
"configure_cloud_init", _boom(), None
122+
)
123+
assert exc.value.status_code == 502
124+
assert "SuperSecret123" not in repr(exc.value.detail)
91125

92126

93127
@pytest.mark.asyncio

0 commit comments

Comments
 (0)