fix(libvirt): reject shell injection via guest init-script interface fields - #1085
Closed
doublewhy wants to merge 6 commits into
Closed
fix(libvirt): reject shell injection via guest init-script interface fields#1085doublewhy wants to merge 6 commits into
doublewhy wants to merge 6 commits into
Conversation
The generated guest init script (run as root inside the appliance) interpolated interface `mac`, `ip`, and `cidr_prefix` into shell text unquoted. A malformed or hostile value such as a mac of `aa:bb:cc:dd:ee:ff) ; rm -rf /outside #` or an ip containing `$(...)` injected arbitrary commands into a root-run script; only the hostname was quoted. Validate each interpolated field to its structural shape (MAC address, IPv4/IPv6 address, integer CIDR prefix) and reject malformed input with a typed ValueError, then quote every value with the existing _shell_quote as defense in depth. Both the native and guest-certified init scripts now share one validated helper, closing the identical injection in guest_appliance.py. Also harden _cpio_newc: a member path containing a newline silently corrupted the newline-delimited cpio archive (subprocess ran with check=False), so reject newline-bearing paths before invoking cpio. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`guest_appliance._init_script` builds the same root-run script as the TechVault appliance and was routed through the shared validated helper, but its only coverage lives in `test_libvirt_backend_guest_certified.py`, which needs a static BusyBox and `cpio` and so does not run on hosts without them. The generator is now exercised directly, so the second injection site is covered wherever the suite runs. Six of these cases fail against the pre-fix module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`str.isdigit` also accepts characters such as "²" that `int` then refuses, so a prefix like that escaped `_validated_cidr_prefix` as a bare interpreter message instead of the module's own. `isdecimal` matches what `int` will actually accept. Also covers the remaining validation branches on this boundary: a non-string ip, a bool and a float prefix, a decimal-string prefix, and a malformed interface entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A macOS directory-metadata file was picked up by a broad `git add`. The repository .gitignore does not list .DS_Store, so it entered the tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two init-script test modules assert the same guarantees against two entry points, and the second was added by copying the first, leaving identical helper and test bodies in both. SonarCloud flagged one new issue per file for the duplication. The valid interface, the hostile-field case table, and the expected quoted output now live in `libvirt_interface_fixtures.py`, matching the existing `*_fixtures.py` helpers. Each module keeps only what is specific to its own generator, and a new hostile case added in one place now covers both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both parametrized rejection tests wrapped two calls in one `pytest.raises` block -- the fixture builder and the generator under test -- so nothing stated which was expected to raise, and a builder that started raising would satisfy the assertion. SonarCloud reported this as one new issue per file (python:S5779). The domain is now built before the block, leaving the generator as the only call inside it. The 20 hostile-input cases still fail against the pre-fix modules and pass after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collaborator
|
Please note in the comments or body which issue this PR closes. If no issue exists, please create one and link it. Thank you! |
Author
|
Linked the focused guest-interface validation issue in the PR body: this PR closes #1116. Thank you. |
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related issues
Closes #1116
What breaks
A hostile value in a scenario definition could run arbitrary commands as root inside the guest VM. The guest's startup script was assembled as shell text, and three fields from the scenario — a network interface's MAC address, IP address, and CIDR prefix — were pasted into it without quoting or validation.
Concretely
Give a node an interface whose
macis:The generated script closes the shell
casearm early and runs whatever follows.ipandcidr_prefixwere injectable the same way with$(...)or backticks. The generated line was:The same flaw was present in two files:
techvault_appliance.pyandguest_appliance.py.Why it happens
Both
_init_scriptfunctions built the interface arms with f-strings:Only the hostname went through the module's existing
_shell_quote. This is also inconsistent with the rest of the backend, which uses argv lists rather than shell text (cloudinit.py,drivers/oci.py).The fix
Both files now share one helper that validates each field to its declared shape and refuses malformed input, then quotes it as a second layer:
macmust match a MAC address patternipmust parse viaipaddress.ip_addresscidr_prefixmust be an integer within range for that address familyA value that is not the shape it claims to be is a bug in the plan, not text to escape, so it raises
ValueError— matching howtechvault_matrix.py, which produces these values, already reports problems.Two related items:
_cpio_newcpiped newline-joined file paths intocpiowithcheck=False, so a filename containing a newline silently corrupted the initramfs archive. Such paths are now refused.str.isdigitaccepts characters like²thatintthen rejects, which leaked a raw interpreter message instead of the module's own.isdecimalmatches whatintactually accepts.How I know
7 tests fail against the current
techvault_applianceand pass after; 6 of theguest_appliancecases likewise.guest_appliance.pyhad no coverage on this path on any machine without a static BusyBox andcpioinstalled, because its only test module needs them. Its script generator is now exercised directly, so the second injection point stays covered wherever the suite runs.I also confirmed the quoting holds in a real shell. An IPv6 zone-ID payload that
ipaddresshappens to accept is printed literally rather than executed:Full
nox -s verifygreen on Ubuntu 22.04 / Python 3.12, all six lanes, 91% total coverage.🤖 Generated with Claude Code