]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
orch/cephadm: fix redeploy --force, validate container image ref 68484/head
authorKobi Ginon <kginon@redhat.com>
Wed, 22 Apr 2026 18:06:56 +0000 (21:06 +0300)
committerKobi Ginon <kginon@redhat.com>
Wed, 29 Apr 2026 13:43:03 +0000 (16:43 +0300)
The redeploy handler had no boolean "force" parameter, so the CLI could
bind --force to the optional image argument. Pass force through to
daemon_action, validate container image ref in cephadm, and guard
against --force being captured as the image in the CLI.

Fixes: https://tracker.ceph.com/issues/75967
Signed-off-by: Kobi Ginon <kginon@redhat.com>
Made-with: Cursor

src/pybind/mgr/cephadm/module.py
src/pybind/mgr/mgr_util.py
src/pybind/mgr/orchestrator/module.py
src/pybind/mgr/tests/test_mgr_util.py

index e6704e8a023c2d4dd194164cf0f335c9f26c31bc..fbaae2b956189c47f09ea7a11083f5b6447e9ef8 100644 (file)
@@ -62,7 +62,7 @@ from mgr_module import (
     NotifyType,
     MonCommandFailed,
 )
-from mgr_util import build_url, NvmeofMetadataPoolHelper
+from mgr_util import build_url, is_valid_container_image_ref, NvmeofMetadataPoolHelper
 import orchestrator
 from orchestrator.module import to_format, Format
 
@@ -2722,6 +2722,9 @@ Then run the following:
                 raise OrchestratorError(
                     f'Cannot redeploy {daemon_type}.{daemon_id} with a new image: Supported '
                     f'types are: {", ".join(CEPH_IMAGE_TYPES)}')
+            if not is_valid_container_image_ref(image):
+                raise OrchestratorError(
+                    f'Invalid container image {image!r} (not a valid container image reference)')
 
             self.check_mon_command({
                 'prefix': 'config set',
index fa44e6c56b90fb1f6e5980892f430db05fb391c3..d862145a97922f415f68af4e3710f180420381ec 100644 (file)
@@ -17,6 +17,7 @@ import errno
 import socket
 import time
 import logging
+import re
 import sys
 from ipaddress import ip_address
 from threading import Lock, Condition
@@ -62,6 +63,67 @@ UNDERLINE_SEQ = "\033[4m"
 
 logger = logging.getLogger(__name__)
 
+# NAME and TAG are taken verbatim from the OCI distribution spec:
+#   https://github.com/opencontainers/distribution-spec/blob/main/spec.md
+# DIGEST is based on the OCI image-spec descriptor grammar:
+#   https://github.com/opencontainers/image-spec/blob/main/descriptor.md
+# REGISTRY is a practical heuristic, not defined by either spec.
+#
+# Catches malformed input.
+# Not a full OCI image reference parser.
+
+# distribution-spec, verbatim:
+NAME = r"[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(\/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*"
+
+# distribution-spec, verbatim:
+TAG = r"[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}"
+
+# image-spec descriptor grammar translated literally:
+GENERIC_DIGEST_RE = re.compile(
+    r"^[a-z0-9]+(?:[+._-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$",
+    re.ASCII,
+)
+
+# Practical heuristic for hostname[:port].
+REGISTRY = r"(?:[a-zA-Z0-9.-]+(?::[0-9]+)?)"
+
+STRICT_KNOWN_DIGESTS = {
+    "sha256": re.compile(r"^[a-f0-9]{64}$", re.ASCII),
+    "sha512": re.compile(r"^[a-f0-9]{128}$", re.ASCII),
+    "blake3": re.compile(r"^[a-f0-9]{64}$", re.ASCII),
+}
+
+IMAGE_RE = re.compile(
+    rf"""
+        ^
+        (?:{REGISTRY}/)?
+        {NAME}
+        (?::{TAG})?
+        (?:@(?P<digest>[a-z0-9]+(?:[+._-][a-z0-9]+)*:[a-zA-Z0-9=_-]+))?
+        $
+    """,
+    re.VERBOSE | re.ASCII,
+)
+
+
+def is_valid_digest(digest: str) -> bool:
+    if not GENERIC_DIGEST_RE.fullmatch(digest):
+        return False
+    algorithm, encoded = digest.split(":", 1)
+    checker = STRICT_KNOWN_DIGESTS.get(algorithm)
+    if checker is None:
+        return True
+    return checker.fullmatch(encoded) is not None
+
+
+def is_valid_container_image_ref(ref: str) -> bool:
+    """Basic sanity check for OCI/Docker-style container image references."""
+    m = IMAGE_RE.fullmatch(ref)
+    if m is None:
+        return False
+    digest = m.group("digest")
+    return digest is None or is_valid_digest(digest)
+
 
 class PortAlreadyInUse(Exception):
     pass
index 83db20187f7b54ee6cf2e80e2b862f2c3a2f2a2e..d2df42ceec044a2fecc92d0e70b13c961abe17f5 100644 (file)
@@ -22,7 +22,13 @@ from ceph.deployment.hostspec import SpecValidationError
 from ceph.deployment.utils import unwrap_ipv6
 from ceph.utils import datetime_now
 from ceph.cephadm.images import NonCephImageServiceTypes
-from mgr_util import to_pretty_timedelta, format_bytes, parse_combined_pem_file, NvmeofMetadataPoolHelper
+from mgr_util import (
+    is_valid_container_image_ref,
+    to_pretty_timedelta,
+    format_bytes,
+    parse_combined_pem_file,
+    NvmeofMetadataPoolHelper,
+)
 from mgr_module import MgrModule, HandleCommandResult, Option
 from object_format import Format
 
@@ -1791,11 +1797,16 @@ Usage:
     @OrchestratorCLICommand.Write('orch daemon redeploy')
     def _daemon_action_redeploy(self,
                                 name: str,
-                                image: Optional[str] = None) -> HandleCommandResult:
+                                image: Optional[str] = None,
+                                force: bool = False) -> HandleCommandResult:
         """Redeploy a daemon (with a specific image)"""
         if '.' not in name:
             raise OrchestratorError('%s is not a valid daemon name' % name)
-        completion = self.daemon_action("redeploy", name, image=image)
+        if image is not None and not is_valid_container_image_ref(image):
+            raise OrchestratorError(
+                f'Invalid container image {image!r} (not a valid container image reference)'
+            )
+        completion = self.daemon_action("redeploy", name, image=image, force=force)
         raise_if_exception(completion)
         return HandleCommandResult(stdout=completion.result_str())
 
index b9307ccca4270894ad0334d7c25789fd183c999b..fb17ac271b36a5a29b872b8abfc5893fe919593e 100644 (file)
@@ -85,3 +85,71 @@ class TestCephFsEarmarkResolver:
         mock_parse_earmark.side_effect = mgr_util.EarmarkParseError
         result = resolver.check_earmark("error.test", mgr_util.EarmarkTopScope.SMB)
         assert result is False
+
+
+_SHA256_OK = "a" * 64
+_SHA256_BAD_HEX = "g" * 64
+_SHA256_SHORT = "a" * 63
+_SHA512_OK = "b" * 128
+_SHA512_SHORT = "b" * 127
+_BLAKE3_OK = "c" * 64
+
+
+@pytest.mark.parametrize(
+    "digest, expected",
+    [
+        (f"sha256:{_SHA256_OK}", True),
+        (f"sha512:{_SHA512_OK}", True),
+        (f"blake3:{_BLAKE3_OK}", True),
+        (f"sha256:{_SHA256_BAD_HEX}", False),
+        (f"sha256:{_SHA256_SHORT}", False),
+        (f"sha512:{_SHA512_SHORT}", False),
+        ("notadigest", False),
+        ("", False),
+        ("nocolon", False),
+        # Unknown algorithm: generic descriptor grammar only (no length check).
+        ("foo:bar", True),
+        ("acme:encoded-value_1", True),
+    ],
+)
+def test_is_valid_digest(digest: str, expected: bool):
+    assert mgr_util.is_valid_digest(digest) is expected
+
+
+@pytest.mark.parametrize(
+    "ref, expected",
+    [
+        # Typical registry + path + tag
+        ("quay.io/ceph/ceph", True),
+        ("quay.io/ceph/ceph:v18.2.0", True),
+        ("registry.example.com:5000/foo/bar:latest", True),
+        ("docker.io/library/nginx:1.25", True),
+        ("gcr.io/google-containers/pause:3.1", True),
+        ("mcr.microsoft.com/devcontainers/base:debian", True),
+        (f"quay.io/foo/bar@sha256:{_SHA256_OK}", True),
+        (f"localhost:5000/myapp/service@sha512:{_SHA512_OK}", True),
+        (f"127.0.0.1:5000/img/name@blake3:{_BLAKE3_OK}", True),
+        # Shorthand names (no registry)
+        ("ceph/ceph", True),
+        ("library/nginx:1", True),
+        ("alpine", True),
+        ("postgres:16-alpine", True),
+        # Tag charset (OCI distribution-spec tag)
+        ("quay.io/x/img:v1.2.3-rc.1", True),
+        ("registry.io/a/b/c/d/e:1", True),
+        # Reject common CLI/flag confusions
+        ("--force", False),
+        ("-f", False),
+        ("--image", False),
+        ("", False),
+        # Path traversal–looking strings can still match the permissive REGISTRY/NAME
+        # heuristic (e.g. ".."/"escape"). Use characters NAME/TAG reject instead:
+        ("quay.io/ceph/ceph:bad tag", False),
+        ("!not/valid", False),
+        # Malformed or wrong-length digest
+        (f"quay.io/foo@sha256:{_SHA256_BAD_HEX}", False),
+        (f"docker.io/lib/img@sha256:{_SHA256_SHORT}", False),
+    ],
+)
+def test_is_valid_container_image_ref(ref: str, expected: bool):
+    assert mgr_util.is_valid_container_image_ref(ref) is expected