diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 3a1d237..2da22c6 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. + +## 2025-02-24 - [High] LaTeX Injection via Optional Arguments +**Vulnerability:** The `latex_escape` utility function did not escape square brackets `[` and `]`. In LaTeX, these characters are used to denote optional arguments (e.g., `\item[label]`). If user input containing unescaped brackets is placed inside an optional argument context, it allows attackers to break out of the intended structure and inject arbitrary LaTeX commands. +**Learning:** Standard LaTeX escaping often focuses on special characters like `\`, `{`, `}`, `$`, etc., but overlooks context-specific delimiters like `[` and `]`. Security functions must consider the syntactic role of characters in the target language, not just the "obvious" special characters. +**Prevention:** Escape `[` as `{[}` and `]` as `{]}` in all LaTeX sanitization logic. This neutralizes their syntactic meaning while preserving their visual rendering, preventing injection attacks in optional argument contexts. diff --git a/cli/utils/template_filters.py b/cli/utils/template_filters.py index 5fd3852..70ade2e 100644 --- a/cli/utils/template_filters.py +++ b/cli/utils/template_filters.py @@ -74,6 +74,8 @@ def latex_escape(text): replacements["\\"] = r"\textbackslash{}" replacements["{"] = r"\{" replacements["}"] = r"\}" + replacements["["] = r"{[}" + replacements["]"] = r"{]}" # 3. Build regex pattern (keys sorted by length descending to match longest first) # Escape keys to handle regex special characters in the keys themselves diff --git a/resume_pdf_lib/generator.py b/resume_pdf_lib/generator.py index b196bc8..80b2431 100644 --- a/resume_pdf_lib/generator.py +++ b/resume_pdf_lib/generator.py @@ -490,7 +490,7 @@ def latex_escape(text: Any) -> Markup: if char == "\\": result.append(r"\textbackslash{}") - elif char in "&%$#_{}~^<>": + elif char in "&%$#_{}~^<>[]": escaped_map = { "&": r"\&", "%": r"\%", @@ -499,6 +499,8 @@ def latex_escape(text: Any) -> Markup: "_": r"\_", "{": r"\{", "}": r"\}", + "[": r"{[}", + "]": r"{]}", "~": r"\textasciitilde{}", "^": r"\^{}", "<": r"\textless{}", diff --git a/tests/test_bracket_escape.py b/tests/test_bracket_escape.py new file mode 100644 index 0000000..3c28279 --- /dev/null +++ b/tests/test_bracket_escape.py @@ -0,0 +1,24 @@ +from cli.utils.template_filters import latex_escape as cli_latex_escape +from resume_pdf_lib.generator import latex_escape as lib_latex_escape + + +def test_cli_bracket_escape(): + """Test that cli.utils.template_filters.latex_escape escapes [ and ].""" + input_str = "[Optional]" + expected = r"{[}Optional{]}" + assert str(cli_latex_escape(input_str)) == expected + + input_str = r"\item[Optional]" + expected = r"\textbackslash{}item{[}Optional{]}" + assert str(cli_latex_escape(input_str)) == expected + + +def test_lib_bracket_escape(): + """Test that resume_pdf_lib.generator.latex_escape escapes [ and ].""" + input_str = "[Optional]" + expected = r"{[}Optional{]}" + assert str(lib_latex_escape(input_str)) == expected + + input_str = r"\item[Optional]" + expected = r"\textbackslash{}item{[}Optional{]}" + assert str(lib_latex_escape(input_str)) == expected diff --git a/tests/test_template_generator.py b/tests/test_template_generator.py index 44f7bf0..69bcd2b 100644 --- a/tests/test_template_generator.py +++ b/tests/test_template_generator.py @@ -56,7 +56,7 @@ def test_latex_escape_special_chars(self): assert filter_func("# section") == r"\# section" assert filter_func("text_var") == r"text\_var" assert filter_func("{item}") == r"\{item\}" - assert filter_func("[key]") == r"[key]" # Brackets not escaped + assert filter_func("[key]") == r"{[}key{]}" def test_latex_escape_copyright_symbols(self): """Test latex_escape escapes copyright symbols."""