diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 31b72d4..3a1d237 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -2,3 +2,8 @@ **Vulnerability:** The API authentication mechanism (`api/auth.py`) defaulted to allowing access if the `RESUME_API_KEY` environment variable was not set ("dev mode"). Additionally, it used a timing-vulnerable string comparison for the API key check. **Learning:** "Dev mode" defaults that bypass security controls are dangerous because they can easily be deployed to production by accident, leaving the system wide open. **Prevention:** Implement a "fail-closed" strategy. If a security configuration (like an API key) is missing, the application should refuse to start or deny all requests, rather than failing open. Always use `secrets.compare_digest` for sensitive string comparisons. + +## 2025-02-19 - [Critical] LaTeX Injection in Cover Letter Generator +**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. diff --git a/cli/generators/cover_letter_generator.py b/cli/generators/cover_letter_generator.py index 51cfbcd..c448c15 100644 --- a/cli/generators/cover_letter_generator.py +++ b/cli/generators/cover_letter_generator.py @@ -40,7 +40,7 @@ OPENAI_AVAILABLE = False from ..utils.config import Config -from ..utils.template_utils import get_jinja_env +from ..utils.template_utils import get_jinja_env, get_jinja_tex_env from ..utils.yaml_parser import ResumeYAML from .ai_judge import create_ai_judge @@ -65,6 +65,7 @@ def __init__(self, yaml_path: Optional[Path] = None, config: Optional[Config] = # Set up Jinja2 environment (cached via template_utils) self.env = get_jinja_env(template_dir) + self.tex_env = get_jinja_tex_env(template_dir) # Initialize AI client (same as AIGenerator) provider = self.config.ai_provider @@ -678,7 +679,7 @@ def _render_latex(self, content: Dict[str, Any], job_details: Dict[str, Any]) -> """Render LaTeX cover letter template.""" contact = self.yaml_handler.get_contact() - template = self.env.get_template("cover_letter_tex.j2") + template = self.tex_env.get_template("cover_letter_tex.j2") context = { "contact": contact, diff --git a/cli/utils/template_utils.py b/cli/utils/template_utils.py index 2d1a504..8524589 100644 --- a/cli/utils/template_utils.py +++ b/cli/utils/template_utils.py @@ -79,6 +79,9 @@ def get_jinja_tex_env(template_dir: Path) -> Environment: tex_env.filters["latex_escape"] = latex_escape tex_env.filters["proper_title"] = proper_title + # Add globals + tex_env.globals["now"] = datetime.now + # Auto-escape all variables for LaTeX to prevent injection tex_env.finalize = lambda x: ( latex_escape(x) if isinstance(x, str) and not isinstance(x, Markup) else x diff --git a/tests/test_cover_letter_security.py b/tests/test_cover_letter_security.py new file mode 100644 index 0000000..371fd5b --- /dev/null +++ b/tests/test_cover_letter_security.py @@ -0,0 +1,65 @@ +import os +from unittest.mock import MagicMock, patch + +import pytest + +from cli.generators.cover_letter_generator import CoverLetterGenerator +from cli.utils.config import Config + + +@pytest.fixture +def mock_config(): + config = MagicMock(spec=Config) + config.ai_provider = "openai" # or anthropic + config.get.return_value = "dummy" + return config + + +@pytest.fixture +def generator(mock_config, tmp_path): + # Mock resume.yaml + resume_path = tmp_path / "resume.yaml" + with open(resume_path, "w") as f: + f.write( + "contact:\n name: Test User\n email: test@example.com\n urls:\n linkedin: https://linkedin.com/in/test\n github: https://github.com/test\n" + ) + + # Mock API keys to allow initialization + with patch.dict(os.environ, {"OPENAI_API_KEY": "dummy", "ANTHROPIC_API_KEY": "dummy"}): + gen = CoverLetterGenerator(yaml_path=resume_path, config=mock_config) + yield gen + + +def test_latex_injection_prevention(generator): + """Test that LaTeX special characters in user input are escaped.""" + + # Malicious input + malicious_input = "\\input{/etc/passwd}" + malicious_company = f"BadCorp {malicious_input}" + + # Content dict + content = { + "opening_hook": "Hello", + "professional_summary": "Summary", + "key_achievements": [], + "skills_highlight": [], + "company_alignment": None, + "connection": None, + } + + job_details = {"company": malicious_company, "position": "Hacker"} + + # Render + output = generator._render_latex(content, job_details) + + # Check for escaped input + # Expected: \textbackslash{}input\{/etc/passwd\} + escaped_input = "\\textbackslash{}input\\{/etc/passwd\\}" + + assert malicious_input not in output, "Unescaped malicious input found!" + assert ( + escaped_input in output or "\\textbackslash{}input{" in output + ), "Input was not correctly escaped!" + + # Check specifically in metadata (common injection point) + assert f"pdftitle={{ Test User - Cover Letter - {malicious_company} }}" not in output