Back to Blog
critical SEVERITY9 min read

How command injection happens in Python subprocess and how to fix it

A critical command injection vulnerability was discovered in `docling/models/stages/ocr/tesseract_ocr_cli_model.py`, where user-controlled inputs such as language identifiers, file paths, and the Tesseract executable path were passed directly into `subprocess.run()` calls without validation. An attacker who could influence these values — for example, by supplying a maliciously crafted document or configuration — could inject arbitrary shell arguments or commands. The fix introduces strict input

O
By Orbis AppSec
Published July 9, 2026Reviewed July 9, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in Python's `subprocess.run()` calls inside `docling/models/stages/ocr/tesseract_ocr_cli_model.py`. The root cause is that user-controlled values — Tesseract language identifiers, file paths, and the executable path — were passed directly into subprocess commands without sanitization. The fix adds static validation methods (`_sanitize_lang`, `_sanitize_cmd`, `_sanitize_path`) that reject any input containing shell metacharacters, and pre-validates all arguments at object construction time so no unsanitized data ever reaches a subprocess call.

Vulnerability at a Glance

cweCWE-78
fixPre-validate all subprocess arguments at construction time using regex allowlists and sanitization methods
riskArbitrary command execution on the host system
languagePython
root causeUser-controlled language identifiers, paths, and executable names passed to subprocess.run() without validation
vulnerabilityCommand Injection via unsanitized subprocess arguments

How Command Injection Happens in Python Subprocess and How to Fix It

Summary

A critical command injection vulnerability was discovered in docling/models/stages/ocr/tesseract_ocr_cli_model.py, where user-controlled inputs — including Tesseract language identifiers, file paths, and the executable path — were passed directly into subprocess.run() calls without any validation. An attacker who could influence these values could inject arbitrary arguments or commands into the OCR pipeline. The fix introduces strict allowlist-based validation at object construction time, ensuring no unsanitized data ever reaches a subprocess call.


Quick Answer: This is a CWE-78 command injection vulnerability in Python's subprocess.run(). The TesseractOcrCliModel class in docling passed user-controlled language identifiers, paths, and executable names directly to subprocess calls without sanitization. The fix adds _sanitize_lang(), _sanitize_cmd(), and _sanitize_path() methods that validate inputs against strict allowlists at construction time, before any subprocess call can be made.


Introduction

The docling/models/stages/ocr/tesseract_ocr_cli_model.py file is responsible for invoking the Tesseract OCR engine via the command line to extract text from documents. It's a critical part of the document processing pipeline — and it calls subprocess.run() in at least three places (lines 127, 158, and 195). The problem: the command lists passed to those calls were built using values from self.options.lang, self.options.path, and self.options.tesseract_cmd — all of which could be influenced by user-supplied document metadata or configuration — without any sanitization or validation.

This is precisely the kind of vulnerability that's easy to overlook. The code already did the right thing by using a list argument with subprocess.run() instead of a shell string. But that's not enough when the contents of the list are tainted.


The Vulnerability Explained

How subprocess.run() with a list can still be dangerous

A common misconception in Python security is: "I'm using subprocess.run() with a list, not shell=True, so I'm safe from command injection."

This is partially true — shell metacharacters like ;, |, and && won't be interpreted by a shell when you use a list. But argument injection is still possible. If an attacker can control a value that ends up in the command list, they can inject unexpected flags, paths, or arguments that alter the behavior of the subprocess.

In TesseractOcrCliModel, the __init__ method stored options directly:

# Before the fix — options used directly, no validation
self.options.tesseract_cmd  # e.g., "tesseract" — but what if it's "tesseract --config evil.cfg"?
self.options.path           # tessdata path — what if it contains path traversal sequences?
self.options.lang           # e.g., ["eng"] — what if it's ["eng", "--oem", "0"]?

These values were then used to construct the cmd list passed to subprocess.run() at lines 127, 158, and 195. If any of these values contained unexpected characters or argument-like strings, they would be passed directly to the Tesseract binary.

A concrete attack scenario

Consider a document processing service built on docling that accepts user-provided OCR language preferences. A user submits a request specifying the language as:

eng+--oem 0 --psm 11

Without validation, this string could be split and injected into the Tesseract command, altering OCR behavior in unexpected ways — or, depending on how Tesseract handles certain options, triggering unintended file reads or writes. More critically, if tesseract_cmd or path is user-influenced (e.g., loaded from a document's embedded metadata or a user-editable config file), an attacker could point the executable path to a malicious binary or use path traversal to access sensitive tessdata locations.

Why this is rated Critical

  • Arbitrary command execution: A compromised tesseract_cmd value could point to any executable on the system.
  • Argument injection: Malicious language identifiers or paths could manipulate Tesseract's behavior, trigger file system access, or exploit Tesseract's own configuration loading.
  • Broad attack surface: The vulnerability exists at three separate subprocess.run() call sites (lines 127, 158, 195), meaning any one of them could be the entry point.

The Fix

The fix takes a defense-in-depth approach: validate everything at construction time, so that by the time any subprocess.run() call is made, all arguments have already been checked.

Step 1: A regex allowlist for language identifiers

A compiled regular expression is added at module level to define exactly what a valid Tesseract language identifier looks like:

# Added at module level
import re

# Regex for valid Tesseract language identifiers (e.g. "eng", "script/Latin", "eng+deu")
_VALID_LANG_RE = re.compile(r"^[a-zA-Z0-9_/+-]+$")

This allowlist permits only alphanumeric characters, underscores, forward slashes, plus signs, and hyphens — exactly the characters that appear in legitimate Tesseract language codes like eng, script/Latin, and eng+deu. Any other character (including spaces, dashes used as flag prefixes, semicolons, etc.) will be rejected.

Step 2: Sanitization methods on the class

Three static methods are added to TesseractOcrCliModel:

@staticmethod
def _sanitize_lang(lang: str) -> str:
    """Validate and sanitize a Tesseract language identifier to prevent argument injection."""
    if not _VALID_LANG_RE.match(lang):
        raise ValueError(f"Invalid Tesseract language identifier: {lang!r}")
    return lang

# _sanitize_cmd and _sanitize_path follow the same pattern,
# validating the executable name and tessdata path respectively

These methods act as a validation gate: if any input fails the check, a ValueError is raised immediately, before any subprocess call can be made.

Step 3: Pre-validation at construction time

The most important architectural decision in the fix is when validation happens — at object construction, not at call time:

# Before the fix: options used directly at subprocess call sites
# subprocess.run([self.options.tesseract_cmd, input_path, ...])

# After the fix: validated values stored at construction time
self._safe_tesseract_cmd: str = self._sanitize_cmd(self.options.tesseract_cmd)
self._safe_tessdata_path: Optional[str] = (
    self._sanitize_path(self.options.path)
    if self.options.path is not None
    else None
)
if self.options.lang:
    for _lang_token in self.options.lang:
        if _lang_token != "auto":
            self._sanitize_lang(_lang_token)

By storing _safe_tesseract_cmd and _safe_tessdata_path as instance variables and validating language tokens at __init__ time, all subsequent subprocess calls use only pre-validated values. There is no risk of a tainted value sneaking in at a later call site.

Before vs. After

Before (vulnerable):

# Somewhere in the subprocess call construction:
cmd = [self.options.tesseract_cmd, str(input_path), str(output_path)]
if self.options.path:
    cmd += ["--tessdata-dir", self.options.path]
if lang:
    cmd += ["-l", lang]
subprocess.run(cmd, ...)
# No validation — any of these values could be attacker-controlled

After (fixed):

# At construction time:
self._safe_tesseract_cmd = self._sanitize_cmd(self.options.tesseract_cmd)
self._safe_tessdata_path = self._sanitize_path(self.options.path) if self.options.path else None
# All lang tokens validated in __init__

# At call time:
cmd = [self._safe_tesseract_cmd, str(input_path), str(output_path)]
if self._safe_tessdata_path:
    cmd += ["--tessdata-dir", self._safe_tessdata_path]
# Only pre-validated values reach subprocess.run()

Prevention & Best Practices

1. Validate subprocess inputs with allowlists, not denylists

Denylists (blocking known-bad characters) are fragile — there's always a character you forgot. Allowlists (permitting only known-good patterns) are robust. The fix uses ^[a-zA-Z0-9_/+-]+$ — a tight allowlist that precisely matches the expected format of Tesseract language codes.

2. Validate early, validate once

The fix validates at __init__ time and stores safe values. This is better than validating at each call site, which is error-prone and easy to forget. If construction fails, the object never reaches a state where it could make a dangerous subprocess call.

3. Never use shell=True with user-controlled input

If shell=True had been used here, the impact would have been even worse — full shell injection rather than argument injection. Always use list-form subprocess calls, and validate the list contents.

4. Apply the principle of least privilege to subprocess calls

If your subprocess only needs to run a specific binary with specific arguments, enforce that at the code level. Don't allow arbitrary executable names or paths unless absolutely necessary.

5. Use security linters in your CI pipeline

Tools that can catch this class of issue:
- Bandit: Python security linter, flags subprocess calls with user-controlled input
- Semgrep: Highly configurable, with rules for subprocess injection patterns
- Orbis AppSec: Detected and auto-fixed this exact issue

Relevant Standards

  • CWE-78: Improper Neutralization of Special Elements used in an OS Command
  • OWASP A03:2021: Injection — command injection is a primary example
  • OWASP Command Injection Defense Cheat Sheet: Recommends allowlist validation for all inputs to OS commands

Key Takeaways

  • Language identifiers in TesseractOcrCliModel are a trust boundary: they originate from user-supplied options and must be validated before reaching subprocess.run().
  • Using subprocess.run() with a list is necessary but not sufficient: argument injection is still possible if list contents are unsanitized.
  • Pre-validate at construction time: storing _safe_tesseract_cmd and _safe_tessdata_path as instance variables means validation happens once and safe values are reused everywhere.
  • The _VALID_LANG_RE regex is the security control: ^[a-zA-Z0-9_/+-]+$ is a precise allowlist that matches all legitimate Tesseract language codes and rejects everything else.
  • Three call sites (lines 127, 158, 195) were all protected by a single fix: validating at construction time means you don't have to remember to validate at each individual call site.

How Orbis AppSec Detected This

  • Source: User-controlled values from self.options.lang, self.options.tesseract_cmd, and self.options.path — potentially derived from document metadata, API parameters, or user-editable configuration.
  • Sink: subprocess.run(cmd, ...) at lines 127, 158, and 195 of docling/models/stages/ocr/tesseract_ocr_cli_model.py, where the cmd list was constructed using unvalidated option values.
  • Missing control: No allowlist validation, type checking, or sanitization was applied to any of the values incorporated into the subprocess command list before execution.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
  • Fix: Added _sanitize_lang(), _sanitize_cmd(), and _sanitize_path() static methods with regex allowlist validation, and pre-validated all subprocess arguments at TesseractOcrCliModel.__init__() construction time.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

This vulnerability in TesseractOcrCliModel is a textbook example of why input validation must be treated as a first-class concern, even when you're already using safer APIs like list-form subprocess.run(). The Tesseract OCR CLI integration in docling is a high-value target precisely because it sits at the boundary between user-supplied document data and OS-level command execution.

The fix is elegant in its simplicity: define exactly what valid input looks like (a tight regex allowlist), validate at the earliest possible moment (object construction), and store only the validated values for later use. This pattern — validate early, store safe values, use them everywhere — is one that every developer working with subprocess calls, shell commands, or external tool integrations should internalize.

Security is not just about using the right API; it's about ensuring the data flowing through that API is trustworthy.


References

Frequently Asked Questions

What is command injection in Python subprocess?

Command injection occurs when user-controlled data is incorporated into a system command without sanitization, allowing attackers to append or alter the command being executed. In Python, even when using `subprocess.run()` with a list (rather than `shell=True`), malicious arguments can still manipulate program behavior by injecting unexpected flags or argument sequences.

How do you prevent command injection in Python subprocess calls?

Use allowlist-based validation (e.g., regex) to ensure only expected characters and formats are accepted for any user-controlled input before it reaches a subprocess call. Pre-validate inputs at the earliest possible point — ideally at object construction time — so tainted data never reaches the execution site.

What CWE is command injection?

Command injection is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

Is using a list argument in subprocess.run() enough to prevent command injection?

Not entirely. While using a list instead of a shell string prevents shell metacharacter interpretation by the shell itself, it does not prevent argument injection — where a malicious value adds unexpected flags or paths that alter the program's behavior. Input validation is still required.

Can static analysis detect command injection in Python?

Yes. Tools like Semgrep, Bandit, and commercial SAST scanners can detect patterns where user-controlled data flows into subprocess calls. Orbis AppSec's multi-agent AI scanner detected this exact issue in `tesseract_ocr_cli_model.py` and automatically generated the fix.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #3283

Related Articles

high

How Authentication Bypass happens in PyJWT and how to fix it

A critical authentication bypass vulnerability in PyJWT 2.12.1 allowed attackers to forge valid JSON Web Tokens, potentially bypassing application authentication mechanisms entirely. The vulnerability was fixed in PyJWT 2.13.0 through security improvements to token validation logic. This fix is essential for any application relying on JWT-based authentication.

high

How unsafe pickle deserialization happens in Keras/TensorFlow notebooks and how to fix it

A high-severity untrusted deserialization vulnerability was discovered in `TransferLearningTF.ipynb`, a transfer learning tutorial notebook that loads VGG16 model weights from the internet without verifying their integrity. Because Keras relies on Python's pickle-based serialization format under the hood, a tampered or substituted weights file could execute arbitrary code with the full privileges of the notebook user. The fix adds a SHA-256 checksum verification step immediately after the weight

critical

How Chromium launch-argument injection happens in Python Crawl4AI and how to fix it

A critical unauthenticated remote code execution vulnerability in Crawl4AI 0.8.9 allowed attackers to inject arbitrary Chromium launch arguments through the `browser_config.extra_args` parameter, potentially taking full control of the host process. The fix upgrades to Crawl4AI 0.9.0 and refactors the crawler initialization in `agent/tools/crawler.py` to use the new `BrowserConfig` and `CrawlerRunConfig` APIs, which enforce proper argument validation. This change eliminates the injection surface

critical

How integer overflow in buffer size calculation happens in C++ and how to fix it

A critical integer overflow vulnerability was discovered in OpenCV's HAL filter implementation where multiplying image dimensions without overflow protection could allocate dangerously undersized buffers. An attacker supplying crafted image dimensions (e.g., 65536×65536) could trigger heap corruption through out-of-bounds writes. The fix promotes the calculation to 64-bit arithmetic with a single cast.

critical

How buffer overflow via strcpy() happens in C zlib and how to fix it

A critical buffer overflow vulnerability was discovered in `general/libzlib/gzlib.c` where multiple `strcpy()` and `strcat()` calls operated without bounds checking. An attacker controlling file paths or error messages could overflow destination buffers, potentially achieving arbitrary code execution. The fix replaces these unsafe string operations with bounded `memcpy()` calls that respect pre-calculated buffer lengths.

critical

How hardcoded API key placeholders in documentation happen in Python and how to fix it

A high-severity security issue was discovered in the Context7 API documentation where a hardcoded API key placeholder (`CONTEXT7_API_KEY`) could be copied directly into production code. The fix replaced the static string with a proper environment variable reference using `os.environ`, preventing developers from accidentally deploying exposed credentials.