Skip to content

fix: [SNOW-3417278] contain read_file_content / procedure_from_js_file Jinja filters to project root#2983

Draft
sfc-gh-olorek wants to merge 1 commit into
mainfrom
proactive/SNOW-3417278-jinja-file-read-containment
Draft

fix: [SNOW-3417278] contain read_file_content / procedure_from_js_file Jinja filters to project root#2983
sfc-gh-olorek wants to merge 1 commit into
mainfrom
proactive/SNOW-3417278-jinja-file-read-containment

Conversation

@sfc-gh-olorek
Copy link
Copy Markdown
Contributor

@sfc-gh-olorek sfc-gh-olorek commented May 7, 2026

Pre-review checklist

  • I've confirmed that instructions included in README.md are still correct after my changes in the codebase.
  • I've added or updated automated unit tests to verify correctness of my new code.
  • I've added or updated integration tests to verify correctness of my new code.
  • I've confirmed that my changes are working by executing CLI's commands manually on MacOS.
  • I've confirmed that my changes are working by executing CLI's commands manually on Windows.
  • I've confirmed that my changes are up-to-date with the target branch.
  • I've described my changes in the release notes.
  • I've described my changes in the section below.
  • I've described my changes in the documentation.

Changes description

Jira: SNOW-3417278 — SAST MEDIUM (CWE-73: External Control of File Name or Path).

General behaviour change. The read_file_content and procedure_from_js_file Jinja filters, which are registered on every SQL-rendering Jinja environment via env_bootstrap(), now require the file they are asked to read to resolve inside the active project root. Paths that escape the root (absolute paths elsewhere on disk, or ../-traversing relative paths) raise a ClickException before any I/O happens. The UNLIMITED size bypass is also removed — these filters now read with the same DEFAULT_SIZE_LIMIT_MB (128 MB) used elsewhere in the codebase.

Motivating scenario. A .sql.jinja committed to a project repository could previously use {{ '~/.ssh/id_rsa' | read_file_content }}, {{ '/etc/passwd' | read_file_content }} or {{ '../../.aws/credentials' | read_file_content }} to pull arbitrary files readable by the developer process into the rendered SQL. That SQL is subsequently executed against Snowflake and appears verbatim in INFORMATION_SCHEMA.QUERY_HISTORY, allowing an attacker with access to the victim's account to exfiltrate local developer secrets.

Implementation.

  • New private helper _resolve_within_project_root(file_name, filter_name) in src/snowflake/cli/api/rendering/jinja.py:
    • Path(file_name).expanduser().resolve() normalises the target.
    • Looks up get_cli_context().project_root via a lazy import (the cli_global_context module already imports CONTEXT_KEY from jinja.py, so the lazy import avoids a circular dependency).
    • Uses Path.relative_to(...) to enforce containment; on failure raises ClickException(f"{filter_name}: path '{file_name}' is outside the project root.").
    • If no project root is available (e.g. snow sql -q run outside a project), the containment check is skipped. The attack model requires a committed .sql.jinja in a project, which requires a project root, so this fallback preserves ad-hoc CLI usage without reopening the vulnerability.
  • Both read_file_content and procedure_from_js_file route through the helper and then use DEFAULT_SIZE_LIMIT_MB instead of UNLIMITED.

Tests (tests/api/test_rendering.py) cover:

  • File inside project root reads successfully (absolute and relative paths).
  • File outside project root is rejected with a ClickException mentioning the filter name and "outside the project root".
  • ../-traversal back out of the project root is rejected.
  • Same containment rule applies to procedure_from_js_file, which still wraps the JS body correctly when the file is inside the root.
  • UNLIMITED removal — a file that would trip the size limit now raises FileTooLargeError.
  • No-project-root fallback preserves legacy snow sql -q behaviour.

References.

  • Sink: src/snowflake/cli/api/rendering/jinja.py (read_file_content, procedure_from_js_file)
  • Filter registration: src/snowflake/cli/api/rendering/jinja.py:env_bootstrap
  • Render entry point: src/snowflake/cli/api/rendering/sql_templates.py:snowflake_sql_jinja_render
  • CWE-73: External Control of File Name or Path / OWASP A01:2021 — Broken Access Control

…e to project root

The Jinja filters `read_file_content` and `procedure_from_js_file` are
registered on every Jinja environment bootstrapped via `env_bootstrap()`,
so they are always available during SQL template rendering. Both filters
previously resolved any file path the template author requested and called
`SecurePath(...).read_text(file_size_limit_mb=UNLIMITED)`, with no path
containment and no size limit.

A SQL template committed to a project repository could therefore embed
arbitrary local files (e.g. `~/.ssh/id_rsa`, `~/.aws/credentials`,
`~/.snowflake/config.toml`, `/etc/passwd`) into the rendered SQL, which is
then executed against Snowflake and appears verbatim in QUERY_HISTORY.

This change adds `_resolve_within_project_root()`, which expands `~` and
resolves the requested path, then requires it to be a descendant of the
active project root (`get_cli_context().project_root`). Paths that escape
via `../` or absolute paths outside the project fail fast with a
`ClickException` before any I/O happens. The `UNLIMITED` bypass is also
replaced with `DEFAULT_SIZE_LIMIT_MB` (128 MB), aligning with the rest of
the codebase. When the command runs outside a project (no project root
available), the filter keeps its legacy behaviour to avoid breaking
ad-hoc `snow sql -q` invocations — the attack requires a committed
template, which requires a project root.

Tests cover the containment rule in both directions, the UNLIMITED
removal, parent-directory traversal, and the no-project-root fallback.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant