Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 3 additions & 2 deletions cli/generators/cover_letter_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions cli/utils/template_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions tests/test_cover_letter_security.py
Original file line number Diff line number Diff line change
@@ -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