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(). TheTesseractOcrCliModelclass indoclingpassed 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_cmdvalue 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
TesseractOcrCliModelare a trust boundary: they originate from user-supplied options and must be validated before reachingsubprocess.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_cmdand_safe_tessdata_pathas instance variables means validation happens once and safe values are reused everywhere. - The
_VALID_LANG_REregex 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, andself.options.path— potentially derived from document metadata, API parameters, or user-editable configuration. - Sink:
subprocess.run(cmd, ...)at lines 127, 158, and 195 ofdocling/models/stages/ocr/tesseract_ocr_cli_model.py, where thecmdlist 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 atTesseractOcrCliModel.__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
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- OWASP OS Command Injection Defense Cheat Sheet
- OWASP Injection — A03:2021
- Python subprocess documentation — Security Considerations
- Semgrep rules for subprocess injection
- fix: sanitize subprocess call in tesseract_ocr_cli_model.py