Skip to content

Harden command execution and state-changing API routes - #103

Open
dupremathieu wants to merge 6 commits into
seapath:mainfrom
dupremathieu:security-fixes
Open

dupremathieu wants to merge 6 commits into
seapath:mainfrom
dupremathieu:security-fixes

Conversation

@dupremathieu

Copy link
Copy Markdown
Member

This series removes shell string interpolation from the value-handling paths
and tightens a few entry points.

  • Pacemaker.is_valid_host() and Pacemaker.find_resource() no longer build
    a bash -c "grep ..." command or a crm status | grep | awk pipeline. They
    run crm with an argument list and match the value in Python, so a host or
    resource name such as $(id) or x; id; # is treated as a literal.
  • The cluster code validates pinned_host / preferred_host before
    persisting them to RBD image metadata and again when reading them back.
    Those values are consumed by root-run HA paths on other nodes.
  • LibVirtManager.export_configuration() runs virsh with an argument list
    and writes the dump in Python, instead of a shell redirection.
  • console() validates the VM name with the existing _check_name() helper
    before touching subprocess, Pacemaker or libvirt.
  • The Flask API serves /stop and /start as POST-only, so a cross-site GET
    can no longer trigger them.

Tests are updated and added. The CI command
(pytest tests/ --ignore=tests/test_vm_manager_cluster.py --ignore=tests/test_vm_manager_cmd_cluster.py)
passes and coverage stays at 97%.

Pacemaker.is_valid_host() interpolated the caller-supplied host into a
bash -c command and ran it with shell=True, so a crafted
--pinned-host/--preferred-host (or Cockpit plugin value) could execute
arbitrary commands as root.

Run crm node server with an argv list and match the host against the
reported node names in Python instead. The True/False semantics are
unchanged and the value never reaches a shell.

Add unit tests covering valid, unknown and exact-match hosts plus shell
metacharacter payloads such as $(id) and x; id; #.

Signed-off-by: Mathieu Dupré <mathieu.dupre@savoirfairelinux.com>
find_resource() built a "crm status | grep | grep | awk" pipeline by
concatenating the resource name and ran it through shell=True. The
name reaches it from the console command's positional argument, so an
argument like "$(id)" executed as root.

Run ["crm", "status"] as an argv list and reproduce the pipeline in
Python instead: match lines anchored at the resource name, keep the
ones reporting Started, and return their last field, or None. The
name goes through re.escape(), which the grep pattern did not.

Signed-off-by: Mathieu Dupré <mathieu.dupre@savoirfairelinux.com>
console() passed its positional name straight to
Pacemaker.find_resource(). Commit removing the shell from find_resource
closes the injection there, but the cluster console entry point was the
only caller that never validated the name, unlike set_metadata,
add_colocation and add_pacemaker_remote.

Run the existing _check_name() first, so 'x; id; #' or '$(id)' is
refused before any subprocess, Pacemaker or libvirt call. The tests
record find_resource and assert it is never reached, and that the
recording itself works, so the guard cannot go vacuous unnoticed.

Signed-off-by: Mathieu Dupré <mathieu.dupre@savoirfairelinux.com>
pinned_host/preferred_host are persisted to the shared Ceph image
metadata and later re-read by root-run HA paths (enable_vm, clone) that
hand them to node placement helpers. A value written once could
therefore re-fire on every node acting on the VM.

Validate both values with a strict host pattern before any RBD write,
and validate them again when read back from the image, raising a clear
ValueError on a bad value. The shell sink itself is fixed separately.

Signed-off-by: Mathieu Dupré <mathieu.dupre@savoirfairelinux.com>
LibVirtManager.export_configuration() interpolated the caller-supplied
domain and destination path into a "virsh ... > path" string and ran it
with shell=True, so a crafted domain such as "$(id)" could execute
arbitrary commands as root. It is reachable through the
libvirt_cmd export subcommand.

Run virsh with an argv list, capture stdout and write it to the
destination path in Python. check=True still propagates failures, stderr
is still inherited and the XML is written byte-for-byte.

Add unit tests covering the argv list, shell metacharacters kept in a
single argument and error propagation, using a stubbed virsh and no
libvirt daemon.

Signed-off-by: Mathieu Dupré <mathieu.dupre@savoirfairelinux.com>
/stop/<guest> and /start/<guest> answered GET, so any page a browser
loaded could trigger them cross-site once the API was reachable. They
are now POST-only, which makes Flask answer 405 to a GET; the read-only
/ and /status/<guest> routes stay on GET.

The existing CSRFProtect was never functional: it had no secret key and
no route issuing tokens, so it did nothing while every route was a GET,
and it would have turned every POST into a 500 ("A secret key is
required to use CSRF"). It is removed; the stateless API relies on the
authenticated nginx in front of it, which the module and the overview
now document.

The tests POST on the success paths and add one 405 check per
state-changing route, with the backend monkeypatched to fail if a GET
reaches it.

Signed-off-by: Mathieu Dupré <mathieu.dupre@savoirfairelinux.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant