Add validation checks to prevent arbitrary code execution via stitching.

This commit is contained in:
Julian Stirling 2025-08-03 18:54:11 +01:00
parent a632ddee92
commit 7569c7d6b2
5 changed files with 114 additions and 19 deletions

View file

@ -12,6 +12,7 @@ from datetime import datetime, timedelta
from pydantic import BaseModel, field_validator, field_serializer
from openflexure_microscope_server.utilities import requires_lock
from openflexure_microscope_server.utilities import make_name_safe
IMG_DIR_NAME = "images"
SCAN_ZERO_PAD_DIGITS = 4
@ -257,6 +258,7 @@ class ScanDirectoryManager:
For more explanation on the scan naming see `new_scan_dir`
"""
scan_name = make_name_safe(scan_name)
# A regex with the scan name and a group for the numbers
scan_regex = re.compile(
"^" + scan_name + "_([0-9]{" + str(SCAN_ZERO_PAD_DIGITS) + "})$"

View file

@ -14,6 +14,8 @@ import time
import labthings_fastapi as lt
from openflexure_microscope_server.utilities import make_path_safe
STITCHING_CMD = "openflexure-stitch"
STITCHING_RESOLUTION = (820, 616)
@ -21,6 +23,10 @@ DEFAULT_OVERLAP = 0.1
DEFAULT_RESIZE = 0.5
class StitcherValidationError(RuntimeError):
"""The stitcher received values that it deems unsafe to create a command from."""
class BaseStitcher:
"""A base stitching class for all stitchers. Don't initialise this directly."""
@ -36,24 +42,49 @@ class BaseStitcher:
# Set minimum overlap to 90% of the scan overlap to catch only images
# directly adjacent, not images with overlapping corners.
self.images_dir = images_dir
try:
overlap = float(overlap)
correlation_resize = float(correlation_resize)
except ValueError as e:
raise StitcherValidationError(
"Stitching inputs overlap or correlation_resize were not floats as "
"expected."
) from e
self.validate_path()
self.min_overlap = round(overlap * 0.9, 2)
self.correlation_resize = correlation_resize
self.correlation_resize = float(correlation_resize)
self._mode = "all"
self._extra_args = []
@property
def command(self) -> list[str]:
"""The command to run with subprocess.Popen."""
# Revalidate for good measure,
self.validate_path()
# The command, and the mode
initial_args = [STITCHING_CMD, "--stitching_mode", self._mode]
# Use float() just to really ensure that anything input is a float
setting_args = [
"--minimum_overlap",
f"{self.min_overlap}",
f"{float(self.min_overlap)}",
"--resize",
f"{self.correlation_resize}",
f"{float(self.correlation_resize)}",
]
return initial_args + self._extra_args + setting_args + [self.images_dir]
def validate_path(self):
"""Check path is safe before making a command to run with subprocess.
This is essential for stopping arbitrary code execution.
:raises RuntimeError: if inputs are unsafe.
"""
if self.images_dir != make_path_safe(self.images_dir):
raise StitcherValidationError(
"Invalid directory path contains unsafe characters."
)
def start(self) -> None:
"""Start stitching a stitching process.
@ -91,20 +122,6 @@ class PreviewStitcher(BaseStitcher):
self._mode = "preview_stitch"
@property
def command(self) -> list[str]:
"""The command to run with subprocess.Popen."""
return [
STITCHING_CMD,
"--stitching_mode",
"preview_stitch",
"--minimum_overlap",
f"{self.min_overlap}",
"--resize",
f"{self.correlation_resize}",
self.images_dir,
]
def start(self) -> None:
"""Start stitching a preview of the scan in a background subprocess.

View file

@ -3,6 +3,7 @@
from typing import TypeVar, Callable, ParamSpec
import os
import re
import sys
from threading import Thread
import logging
from importlib.metadata import version
@ -126,6 +127,46 @@ def _wrap_and_catch_errors(target, error_buffer, *args, **kwargs):
error_buffer.append(e)
# Compiled regular expressions for unsafe characters
_WINDOWS_UNSAFE_PATTERN = re.compile(r"[^a-zA-Z0-9_.\-:/\\]")
_POSIX_UNSAFE_PATTERN = re.compile(r"[^a-zA-Z0-9_.\-/]")
_NAME_UNSAFE_PATTERN = re.compile(r"[^a-zA-Z0-9_.\-]")
def make_path_safe(unsafe_path_string: str) -> str:
"""Replace unsafe characters in a file path with underscores, preserving separators.
This can be used to check that user inputs have not been concatenated into an
unsafe filepath.
This ensures compatibility across platforms by preserving only valid characters,
including path separators (forward slash on POSIX, and forward
slash/backslash/colon on Windows).
:param unsafe_path_string: The original path string to sanitise.
:returns: A version of the input string safe to use as a file path.
"""
unsafe_character_pattern = (
_WINDOWS_UNSAFE_PATTERN
if sys.platform.startswith("win")
else _POSIX_UNSAFE_PATTERN
)
return unsafe_character_pattern.sub("_", unsafe_path_string)
def make_name_safe(unsafe_name_string: str) -> str:
"""Replace unsafe characters in a filename or identifier with underscores.
This excludes all path separators, ensuring the result is safe to use as a
standalone filename or identifier component.
:param unsafe_name_string: The original name string to sanitise.
:returns: A version of the input string safe to use as a file name or identifier.
"""
return _NAME_UNSAFE_PATTERN.sub("_", unsafe_name_string)
class VersionData(BaseModel):
"""A BaseModel containing the information about the server version.