Skip to content

fix(security): harden template path read against path traversal#977

Merged
ajcasagrande merged 10 commits into
mainfrom
fdinatale/security-fix
May 22, 2026
Merged

fix(security): harden template path read against path traversal#977
ajcasagrande merged 10 commits into
mainfrom
fdinatale/security-fix

Conversation

@FrankD412

@FrankD412 FrankD412 commented May 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Resolves a SAST CWE-22 path-injection finding on src/aiperf/config/flags/_converter_endpoint.py::_endpoint_template_fallback (and the identical pattern in _endpoint_template_from_extra, which would otherwise re-flag on a re-scan).
  • Adds a private _safe_read_template_path helper that rejects symlinks pre-resolution, resolves via Path.resolve(strict=True) (the canonical sanitizer SAST tools recognize), requires the resolved target to be a regular file, and reads with explicit UTF-8 encoding. Both call sites are rewired through it.
  • Preserves existing semantics for the two legitimate input shapes: regular file path → file contents become the template body; inline template string (no such file) → string is the template body.

Behavior change to note

Users who passed a symlink as --extra-inputs payload_template=<path> previously had the symlink target's contents read in as the template body. After this change the symlink path string is treated as a literal template body (no symlink following). Inline strings and direct file paths are unchanged.

Test plan

  • New tests in tests/unit/config/test_converter_endpoint_template_paths.py (10 cases) cover: regular file read, explicit UTF-8 decode, symlink rejection (the regression), missing path → literal fallback, directory path → literal fallback, and non-TEMPLATE endpoints skip the fallback. Verified RED first — the 2 symlink tests failed against pre-fix code (they read the target contents instead of the literal path).
  • uv run pytest tests/unit/config/ tests/unit/endpoints/ -n auto → 2245 passed, 55 skipped, 0 failed.
  • uv run pytest tests/unit/property/ -n auto → 59 passed.
  • pre-commit run on staged files → all 30 hooks pass (incl. check-ergonomics, check-ruff-baselined, ruff format, test-imports).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a safe filesystem-template read helper and integrated it into template loading to prefer validated file contents.
  • Bug Fixes

    • Hardened template loading so symlinks, directories, missing/unreadable paths, and other unsafe inputs are rejected and treated as literal template text with explicit UTF-8 handling.
  • Tests

    • Added comprehensive unit tests covering path-safety, symlink/parent-symlink rejection, invalid path inputs, read failures, and fallback behavior.
  • Documentation

    • Added guidance and coding-standard notes describing the safe-filesystem-read pattern and expected fallback semantics.

Review Change Stack

Resolves the SAST CWE-22 finding on _converter_endpoint.py's
_endpoint_template_fallback by introducing _safe_read_template_path,
which: (a) rejects symlinks before resolution, (b) resolves via
Path.resolve(strict=True) so non-existent paths fall back to the
literal-template-body branch, (c) requires the resolved target to be
a regular file, and (d) reads with explicit UTF-8 encoding.

The identical pattern in _endpoint_template_from_extra (line 33) is
rewired through the same helper so a re-scan does not re-flag it.

Behavior change to note: --extra-inputs payload_template=<symlink>
now treats the path string as a literal template body instead of
following the symlink. Inline template strings and regular file
paths are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Di Natale <3429989+FrankD412@users.noreply.github.com>
@copy-pr-bot

copy-pr-bot Bot commented May 21, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions github-actions Bot added the fix label May 21, 2026
@github-actions

github-actions Bot commented May 21, 2026

Copy link
Copy Markdown

Try out this PR

Quick install:

pip install --upgrade --force-reinstall git+https://github.com/ai-dynamo/aiperf.git@1f26187f37ea4301b0fd2a6a7c91745104e98a4f

Recommended with virtual environment (using uv):

uv venv --python 3.12 && source .venv/bin/activate
uv pip install --upgrade --force-reinstall git+https://github.com/ai-dynamo/aiperf.git@1f26187f37ea4301b0fd2a6a7c91745104e98a4f

Last updated for commit: 1f26187Browse code

@FrankD412 FrankD412 marked this pull request as ready for review May 21, 2026 21:53
@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a hardened safe_read_template_path helper that safely reads UTF-8 template files (rejecting symlinks, symlinked parents, non-regular targets, and failed resolves) and uses it in converter and endpoint template-loading paths; extensive unit tests and docs/guidance cover successful reads and all failure/fallback cases.

Changes

Template Path Safety

Layer / File(s) Summary
Safe template path helper and integration
src/aiperf/common/path_safety.py, src/aiperf/config/flags/_converter_endpoint.py, src/aiperf/endpoints/template_endpoint.py
Adds safe_read_template_path(ts) implementing safe expansion, strict resolution, symlink rejection, regular-file enforcement, and UTF-8 reading; replaces prior Path.is_file()/read_text() usage in _endpoint_template_from_extra, _endpoint_template_fallback, and TemplateEndpoint to use the helper and fall back to the original string when it returns None.
Path safety test suite
tests/unit/config/test_converter_endpoint_template_paths.py, tests/unit/endpoints/test_template_endpoint.py
Adds tests verifying regular-file reads populate template.body, symlink/missing-path/directory inputs fall back to literal template strings, non-EndpointType.TEMPLATE endpoints don't get a template field, read failures and Path-construction failures return None, symlinked parent directories are rejected, unresolvable ~user is handled safely, and TemplateEndpoint treats symlink inputs as literal sources.
Docs & coding-standard guidance
.cursor/rules/python.mdc, .github/copilot-instructions.md, AGENTS.md, CLAUDE.md, docs/dev/patterns.md
Adds a “Safe Filesystem Reads Pattern” and coding-standard bullets directing callers to use safe_read_template_path and to treat None as a signal to fallback to literal template strings.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hop the paths both near and far,
Sniffing out symlinks, leaving them ajar,
I read UTF‑8 when the file is true,
Else I keep your text just as you knew,
Hop, hop — safe templates through and through.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'fix(security): harden template path read against path traversal' directly and clearly describes the main security fix being implemented across the codebase.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/unit/config/test_converter_endpoint_template_paths.py (1)

34-34: ⚡ Quick win

Rename these tests to include the function under test.

Names like test_regular_file_is_read_as_body are duplicated across both classes and hide whether the case exercises _endpoint_template_from_extra or _endpoint_template_fallback. Please align them to test_<function>_<scenario>_<expected>.

As per coding guidelines tests/**/*.py: Test naming convention: test_<function>_<scenario>_<expected>.

Also applies to: 44-44, 54-56, 68-68, 77-77, 89-89, 101-103, 117-117, 135-137

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/config/test_converter_endpoint_template_paths.py` at line 34,
Rename the duplicated test methods to follow the convention
test_<function>_<scenario>_<expected> and explicitly indicate which function is
under test: replace ambiguous names like test_regular_file_is_read_as_body with
names such as test__endpoint_template_from_extra_regular_file_read_as_body or
test__endpoint_template_fallback_regular_file_read_as_body depending on which
helper the test exercises; do the same for the other listed tests (lines
referenced) so each test name includes either _endpoint_template_from_extra or
_endpoint_template_fallback plus a brief scenario and expected outcome, keeping
names consistent across both test classes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/aiperf/config/flags/_converter_endpoint.py`:
- Around line 39-45: The code in _safe_read_template_path currently only checks
path.is_symlink() on the leaf and then resolves, which allows symlinked parent
directories to be followed; update _safe_read_template_path to iterate over path
and path.parents and return None (or raise as the function convention) if any
component .is_symlink() is True before calling path.resolve(strict=True), so
that symlinked parent components like link_dir/template.json are rejected;
locate the checks around the existing path.is_symlink() and path.resolve(...)
calls and add the parents iteration there to enforce the hardening.

In `@tests/unit/config/test_converter_endpoint_template_paths.py`:
- Around line 128-134: Replace usages of pytest.param with param in the
`@pytest.mark.parametrize` block for the ep_type cases in
tests/unit/config/test_converter_endpoint_template_paths.py by adding an import
"from pytest import param" at the top of the test file and updating the two
entries to use param(id="chat") and param(id="completions"); also add a "# fmt:
skip" comment on the closing ")" of the multiline `@pytest.mark.parametrize`(...)
decorator to prevent formatter reflow. Ensure you update the decorator that
defines ep_type (the pytest.mark.parametrize block) and the file-level imports
accordingly.

---

Nitpick comments:
In `@tests/unit/config/test_converter_endpoint_template_paths.py`:
- Line 34: Rename the duplicated test methods to follow the convention
test_<function>_<scenario>_<expected> and explicitly indicate which function is
under test: replace ambiguous names like test_regular_file_is_read_as_body with
names such as test__endpoint_template_from_extra_regular_file_read_as_body or
test__endpoint_template_fallback_regular_file_read_as_body depending on which
helper the test exercises; do the same for the other listed tests (lines
referenced) so each test name includes either _endpoint_template_from_extra or
_endpoint_template_fallback plus a brief scenario and expected outcome, keeping
names consistent across both test classes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 89da1b90-3d36-4d04-b06a-3fc22aba5531

📥 Commits

Reviewing files that changed from the base of the PR and between fa0171e and 8c60dd1.

📒 Files selected for processing (2)
  • src/aiperf/config/flags/_converter_endpoint.py
  • tests/unit/config/test_converter_endpoint_template_paths.py
Comment thread src/aiperf/config/flags/_converter_endpoint.py Outdated
Comment thread tests/unit/config/test_converter_endpoint_template_paths.py Outdated
@codecov

codecov Bot commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Comment thread src/aiperf/config/flags/_converter_endpoint.py Outdated
Comment thread src/aiperf/config/flags/_converter_endpoint.py Outdated
FrankD412 and others added 2 commits May 21, 2026 16:06
Adds a focused regression for the read-step OSError handler:
chmod 0o000 on a real file passes is_file() but fails read_text()
with PermissionError, which the helper must swallow and return
None so the caller falls back to the literal-template-body branch.

Backfills the codecov gap reported on #977. Skipped on Windows
(ACL semantics differ) and when running as root (mode bits
bypassed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Di Natale <3429989+FrankD412@users.noreply.github.com>
…path

Parametrized over int, None, list, dict to exercise the
``(TypeError, ValueError)`` catch on ``Path(ts).expanduser()``.
Defensive coverage against an upstream parser smuggling a non-str
value through ``dict.get("payload_template")``.

Brings the helper to 100% line coverage; closes the remaining
codecov gap on #977.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Di Natale <3429989+FrankD412@users.noreply.github.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
tests/unit/config/test_converter_endpoint_template_paths.py (2)

189-197: ⚡ Quick win

Align parametrized style with repo test conventions.

At Line 192 and Line 197, switch to from pytest import param + param(...), and add # fmt: skip on the decorator closing ) line.

As per coding guidelines: “Use from pytest import param and put # fmt: skip on the ) line for parametrized tests.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/config/test_converter_endpoint_template_paths.py` around lines 189
- 197, Update the pytest param style in the pytest.mark.parametrize call for the
bad_input cases: replace pytest.param(...) entries with param(...) by adding
"from pytest import param" to the imports, and convert each pytest.param(42,
id="int") etc. to param(42, id="int") for the bad_input list used with the
pytest.mark.parametrize decorator; also add a trailing comment "# fmt: skip" on
the closing ")" of that decorator to preserve formatting. Locate the decorator
and the bad_input arguments around the test name (the pytest.mark.parametrize
block in tests/unit/config/test_converter_endpoint_template_paths.py) and apply
these changes.

198-198: ⚡ Quick win

Rename test to include the target function in the pattern.

At Line 198, prefer a name like test_safe_read_template_path_non_string_input_returns_none to match test_<function>_<scenario>_<expected>.

As per coding guidelines: “Test naming: test_<function>_<scenario>_<expected>.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/config/test_converter_endpoint_template_paths.py` at line 198,
Rename the test method to follow the project's naming convention by including
the target function name and scenario; change the method currently named
test_non_string_input_returns_none to
test_safe_read_template_path_non_string_input_returns_none so it follows the
test_<function>_<scenario>_<expected> pattern and clearly indicates it covers
safe_read_template_path handling non-string inputs returning None.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/unit/config/test_converter_endpoint_template_paths.py`:
- Around line 189-197: Update the pytest param style in the
pytest.mark.parametrize call for the bad_input cases: replace pytest.param(...)
entries with param(...) by adding "from pytest import param" to the imports, and
convert each pytest.param(42, id="int") etc. to param(42, id="int") for the
bad_input list used with the pytest.mark.parametrize decorator; also add a
trailing comment "# fmt: skip" on the closing ")" of that decorator to preserve
formatting. Locate the decorator and the bad_input arguments around the test
name (the pytest.mark.parametrize block in
tests/unit/config/test_converter_endpoint_template_paths.py) and apply these
changes.
- Line 198: Rename the test method to follow the project's naming convention by
including the target function name and scenario; change the method currently
named test_non_string_input_returns_none to
test_safe_read_template_path_non_string_input_returns_none so it follows the
test_<function>_<scenario>_<expected> pattern and clearly indicates it covers
safe_read_template_path handling non-string inputs returning None.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f646cd15-f646-4c17-be60-908031fab46d

📥 Commits

Reviewing files that changed from the base of the PR and between 193ffcb and b04302a.

📒 Files selected for processing (1)
  • tests/unit/config/test_converter_endpoint_template_paths.py
Folds in four review findings from PR #977:

1. Symlinked parent directories no longer bypass the leaf-only
   ``is_symlink()`` check. The helper now iterates ``(path,
   *path.parents)`` and rejects any symlinked component, so
   ``link_dir/template.json`` is treated as a literal template body
   instead of having its target read via ``resolve()`` following the
   parent symlink.

2. ``Path(ts).expanduser()`` raising ``RuntimeError`` (e.g. for
   ``~unknownuser/...`` prefixes) is now caught — previously such
   inputs crashed config conversion instead of falling back to the
   literal-template-body branch.

3. ``TemplateEndpoint.__init__`` shared the same unhardened
   ``Path(...).is_file() + read_text()`` pattern when AIPerfConfig
   is consumed via YAML/direct (bypassing the CLI converter). The
   helper now lives in ``src/aiperf/common/path_safety.py`` and is
   reused by both ``_converter_endpoint`` and ``template_endpoint``,
   closing the second SAST-flaggable site.

4. Parametrized tests now use ``param(...)`` (via ``from pytest
   import param``) with ``# fmt: skip`` on the closing ``)`` line,
   per CLAUDE.md style.

New tests cover (a) symlinked-parent rejection, (b) RuntimeError
expanduser fallback, and (c) TemplateEndpoint symlink rejection
through ``payload_template``. Watched each fail RED first; all GREEN
after the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Di Natale <3429989+FrankD412@users.noreply.github.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/unit/endpoints/test_template_endpoint.py`:
- Line 470: The test function test_symlinked_payload_template_treated_as_literal
is missing type annotations; add parameter and return type hints by annotating
tmp_path as pathlib.Path (or Path after adding "from pathlib import Path") and
specify a return type of None, and ensure the appropriate import (from pathlib
import Path) is present at the top of the test module so the signature becomes
def test_symlinked_payload_template_treated_as_literal(tmp_path: Path) -> None.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6cd44ad8-ce1c-4ef7-a84c-8fa3e04e29d2

📥 Commits

Reviewing files that changed from the base of the PR and between b04302a and 5ce3ef0.

📒 Files selected for processing (5)
  • src/aiperf/common/path_safety.py
  • src/aiperf/config/flags/_converter_endpoint.py
  • src/aiperf/endpoints/template_endpoint.py
  • tests/unit/config/test_converter_endpoint_template_paths.py
  • tests/unit/endpoints/test_template_endpoint.py
Comment thread tests/unit/endpoints/test_template_endpoint.py Outdated
@FrankD412 FrankD412 self-assigned this May 22, 2026
…ad pattern

Adds a Coding Standards bullet to the four synced agent files (AGENTS.md,
CLAUDE.md, .github/copilot-instructions.md, .cursor/rules/python.mdc)
directing contributors and AI agents to use
aiperf.common.path_safety.safe_read_template_path instead of inline
Path().read_text() / open().read(), so the CWE-22 hardening landed in
this PR is preserved across future code changes.

Adds a "Safe Filesystem Reads Pattern" section to docs/dev/patterns.md
with the sanitizer chain explained (the order SAST engines walk it) and
an explicit "does NOT apply to" list (trusted-string path joining,
binary reads, hard-fail semantics) so the rule doesn't generate
false-positive review pushback on legitimate internal reads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Di Natale <3429989+FrankD412@users.noreply.github.com>
Comment thread src/aiperf/common/path_safety.py
@github-actions

github-actions Bot commented May 22, 2026

Copy link
Copy Markdown
Round 2 of PR #977 review feedback:

1. dynamo-ops (MEMBER): a non-UTF-8 file passing every prior sanitizer
   check still crashed at ``read_text(encoding="utf-8")`` with
   ``UnicodeDecodeError``, which is a ``ValueError`` subclass — NOT
   caught by the existing ``except OSError``. Widened the read-step
   catch to ``except (OSError, UnicodeError)`` so non-UTF-8 inputs
   collapse to ``None`` and the caller falls back to the literal
   template body.

2. CodeRabbit (MINOR): added type hints to
   ``test_symlinked_payload_template_treated_as_literal`` (CLAUDE.md:
   "Type hints on ALL functions") and pulled in the ``Path`` import.

3. codecov: lines 60-61 of ``template_endpoint.py`` (the "loaded from
   file" branch I introduced) were unexercised — only the symlink
   rejection path was tested. Added
   ``test_regular_file_payload_template_is_loaded`` to load a real
   template via ``payload_template=<path>`` through ``TemplateEndpoint``.

New tests (RED-verified for the behavior change):
- ``TestSafeReadTemplatePathDecodeFailures::test_non_utf8_file_returns_none``
- ``TestTemplateEndpointPathSafety::test_regular_file_payload_template_is_loaded``

``safe_read_template_path`` is now at 100% line coverage; the
TemplateEndpoint lines this PR introduced (58-61) are fully exercised.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Di Natale <3429989+FrankD412@users.noreply.github.com>

@dynamo-ops dynamo-ops left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous review comments have been addressed. Approving.

@ajcasagrande ajcasagrande enabled auto-merge (squash) May 22, 2026 17:40
@ajcasagrande ajcasagrande merged commit bd5467f into main May 22, 2026
24 checks passed
@ajcasagrande ajcasagrande deleted the fdinatale/security-fix branch May 22, 2026 17:42
saturley-hall pushed a commit that referenced this pull request May 23, 2026
…ry-pick #977 to release/0.9.0) (#983)

Signed-off-by: Frank Di Natale <3429989+FrankD412@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4 participants