diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 3a1d237..c859083 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -7,3 +7,8 @@ **Vulnerability:** The `CoverLetterGenerator` used a standard Jinja2 environment (intended for HTML/XML or plain text) to render LaTeX templates. This allowed malicious user input (or AI hallucinations) containing LaTeX control characters (e.g., `\input{...}`) to be injected directly into the LaTeX source, leading to potential Local File Inclusion (LFI) or other exploits. **Learning:** Jinja2's default `autoescape` is context-aware based on file extensions, but usually only for HTML/XML. It does NOT automatically escape LaTeX special characters. Relying on manual filters (like `| latex_escape`) in templates is error-prone and brittle, as developers might forget to apply them to every variable. **Prevention:** Always use a dedicated Jinja2 environment for LaTeX generation that enforces auto-escaping via a `finalize` hook (e.g., `tex_env.finalize = latex_escape`). This ensures *all* variable output is sanitized by default, providing defense-in-depth even if the template author forgets explicit filters. + +## 2026-04-29 - [Critical] LaTeX Injection RCE in PDF Compilation +**Vulnerability:** The `cli/pdf/converter.py` and `cli/generators/cover_letter_generator.py` modules used `subprocess.Popen` to call `pdflatex` and `pandoc` without the `-no-shell-escape` flags. Additionally, these subprocess calls lacked a timeout, making them susceptible to Remote Code Execution (RCE) and infinite hang vulnerabilities if untrusted input (e.g., from an AI hallucination or malicious user input in a template) contained LaTeX control characters or external commands. +**Learning:** External compilation tools like `pdflatex` have unsafe defaults that allow them to execute shell commands during rendering. Without explicitly restricting these permissions, untrusted input passed to these tools can easily lead to a full system compromise. Missing timeouts also expose the system to Denial of Service via compilation loops. +**Prevention:** Always append `-no-shell-escape` to `pdflatex` and `--pdf-engine-opt=-no-shell-escape` to `pandoc` commands to enforce sandboxing. Furthermore, always specify a robust timeout when calling external executables via `subprocess` (e.g., `communicate(timeout=30)`) and explicitly kill the process if the timeout is reached. diff --git a/cli/generators/cover_letter_generator.py b/cli/generators/cover_letter_generator.py index aaf0b61..56bd0ec 100644 --- a/cli/generators/cover_letter_generator.py +++ b/cli/generators/cover_letter_generator.py @@ -771,14 +771,19 @@ def _compile_pdf(self, output_path: Path, tex_content: str) -> bool: try: # Use Popen with explicit cleanup to avoid double-free issues process = subprocess.Popen( - ["pdflatex", "-interaction=nonstopmode", tex_path.name], + ["pdflatex", "-interaction=nonstopmode", "-no-shell-escape", tex_path.name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=tex_path.parent, ) - stdout, stderr = process.communicate() - if process.returncode == 0 or output_path.exists(): - pdf_created = True + try: + stdout, stderr = process.communicate(timeout=30) + if process.returncode == 0 or output_path.exists(): + pdf_created = True + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + pdf_created = False except (subprocess.CalledProcessError, FileNotFoundError): # Check if PDF was created anyway if output_path.exists(): @@ -787,13 +792,25 @@ def _compile_pdf(self, output_path: Path, tex_content: str) -> bool: # Fallback to pandoc try: process = subprocess.Popen( - ["pandoc", str(tex_path), "-o", str(output_path), "--pdf-engine=xelatex"], + [ + "pandoc", + str(tex_path), + "-o", + str(output_path), + "--pdf-engine=xelatex", + "--pdf-engine-opt=-no-shell-escape", + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - stdout, stderr = process.communicate() - if process.returncode == 0 or output_path.exists(): - pdf_created = True + try: + stdout, stderr = process.communicate(timeout=30) + if process.returncode == 0 or output_path.exists(): + pdf_created = True + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + pdf_created = False except (subprocess.CalledProcessError, FileNotFoundError): pass diff --git a/cli/pdf/converter.py b/cli/pdf/converter.py index 0b0a200..763b2ee 100644 --- a/cli/pdf/converter.py +++ b/cli/pdf/converter.py @@ -86,12 +86,17 @@ def _compile_pdflatex( """ try: process = subprocess.Popen( - ["pdflatex", "-interaction=nonstopmode", tex_path.name], + ["pdflatex", "-interaction=nonstopmode", "-no-shell-escape", tex_path.name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=working_dir, ) - stdout, stderr = process.communicate() + try: + stdout, stderr = process.communicate(timeout=30) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + return False if process.returncode == 0 or output_path.exists(): return True @@ -121,12 +126,24 @@ def _compile_pandoc( """ try: process = subprocess.Popen( - ["pandoc", str(tex_path), "-o", str(output_path), "--pdf-engine=xelatex"], + [ + "pandoc", + str(tex_path), + "-o", + str(output_path), + "--pdf-engine=xelatex", + "--pdf-engine-opt=-no-shell-escape", + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=working_dir, ) - stdout, stderr = process.communicate() + try: + stdout, stderr = process.communicate(timeout=30) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + return False if process.returncode == 0 or output_path.exists(): return True