Skip to content
Draft
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
1 change: 1 addition & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
* Fixed `SELECT *` output being corrupted when joined tables share column names. Duplicate column names are now disambiguated by appending a numeric suffix (e.g. `NAME`, `NAME_2`).
* Fixed `snow connection generate-jwt` and `snow connection generate-workload-identity-token` failing with `Connection None is not configured` when used with `--temporary-connection`.
* The internal connection cache now remembers failed connect attempts and re-raises the original exception on subsequent accesses within the same process, instead of re-dialing Snowflake every time a command accesses the shared connection. This fixes, among other cases, the customer-visible duplicate `LOGIN_HISTORY` events (and `OVERFLOW_FAILURE_EVENTS_ELIDED`) previously emitted when a `snow` invocation was rejected by an authentication policy.
* The `read_file_content` and `procedure_from_js_file` Jinja filters used during SQL template rendering now require the referenced path to resolve inside the active project root, and read with the standard `DEFAULT_SIZE_LIMIT_MB` (128 MB) size limit instead of the previous `UNLIMITED` bypass. Templates that referenced files outside the project (including via `../` traversal or absolute paths to the user's home directory) will now fail fast with a clear `ClickException` rather than embedding the file's contents into the rendered SQL.


# v3.17.0
Expand Down
41 changes: 37 additions & 4 deletions src/snowflake/cli/api/rendering/jinja.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,52 @@
from typing import Any, Dict, Optional

import jinja2
from click import ClickException
from jinja2 import Environment, StrictUndefined, loaders
from snowflake.cli.api.secure_path import UNLIMITED, SecurePath
from snowflake.cli.api.constants import DEFAULT_SIZE_LIMIT_MB
from snowflake.cli.api.secure_path import SecurePath

CONTEXT_KEY = "ctx"
FUNCTION_KEY = "fn"


def read_file_content(file_name: str):
return SecurePath(file_name).read_text(file_size_limit_mb=UNLIMITED)
def _resolve_within_project_root(file_name: str, filter_name: str) -> Path:
"""Resolve ``file_name`` and ensure it is contained within the project root.

When a project root is configured (i.e. the command is executed inside a
Snowflake CLI project), the resolved target must be a descendant of that
root. Otherwise, a :class:`ClickException` is raised to prevent arbitrary
file reads outside of the project's scope.
"""
# Lazy import to avoid circular dependency with ``cli_global_context``.
from snowflake.cli.api.cli_global_context import get_cli_context

target = Path(file_name).expanduser().resolve()

try:
project_root: Optional[Path] = get_cli_context().project_root
except Exception:
project_root = None

if project_root is not None:
root = Path(project_root).resolve()
try:
target.relative_to(root)
except ValueError:
raise ClickException(
f"{filter_name}: path '{file_name}' is outside the project root."
)
return target


def read_file_content(file_name: str) -> str:
target = _resolve_within_project_root(file_name, "read_file_content")
return SecurePath(target).read_text(file_size_limit_mb=DEFAULT_SIZE_LIMIT_MB)


@jinja2.pass_environment # type: ignore
def procedure_from_js_file(env: jinja2.Environment, file_name: str):
target = _resolve_within_project_root(file_name, "procedure_from_js_file")
template = env.from_string(
dedent(
"""\
Expand All @@ -47,7 +80,7 @@ def procedure_from_js_file(env: jinja2.Environment, file_name: str):
)
)
return template.render(
code=SecurePath(file_name).read_text(file_size_limit_mb=UNLIMITED)
code=SecurePath(target).read_text(file_size_limit_mb=DEFAULT_SIZE_LIMIT_MB)
)


Expand Down
144 changes: 144 additions & 0 deletions tests/api/test_rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,147 @@ def test_has_client_side_templates():
assert not has_client_side_templates("<test>")
assert not has_client_side_templates("{<est}")
assert not has_client_side_templates("")


# --- read_file_content / procedure_from_js_file containment tests -----------


@pytest.fixture
def jinja_cli_context():
"""Patches get_cli_context wherever jinja.py imports it, for filter tests."""
with mock.patch(
"snowflake.cli.api.cli_global_context.get_cli_context"
) as ctx, mock.patch(
"snowflake.cli.api.rendering.sql_templates.get_cli_context"
) as sql_ctx:
sql_ctx().template_context = {
"ctx": {"env": ProjectEnvironment(default_env={}, override_env={})}
}
yield ctx()


def _render(content: str) -> str:
return snowflake_sql_jinja_render(
content,
template_syntax_config=SQLTemplateSyntaxConfig(enable_jinja_syntax=True),
)


def test_read_file_content_allows_file_inside_project_root(tmp_path, jinja_cli_context):
jinja_cli_context.project_root = tmp_path
target = tmp_path / "inside.txt"
target.write_text("hello from project")
assert _render(f"{{{{ '{target}' | read_file_content }}}}") == "hello from project"


def test_read_file_content_allows_relative_path_inside_project_root(
tmp_path, jinja_cli_context, monkeypatch
):
jinja_cli_context.project_root = tmp_path
target = tmp_path / "relative.txt"
target.write_text("relative content")
monkeypatch.chdir(tmp_path)
assert _render("{{ 'relative.txt' | read_file_content }}") == "relative content"


def test_read_file_content_rejects_path_outside_project_root(
tmp_path, jinja_cli_context
):
project_root = tmp_path / "project"
project_root.mkdir()
jinja_cli_context.project_root = project_root

outside = tmp_path / "outside.secret"
outside.write_text("SECRET")

with pytest.raises(ClickException) as err:
_render(f"{{{{ '{outside}' | read_file_content }}}}")
assert "outside the project root" in err.value.message
assert "read_file_content" in err.value.message


def test_read_file_content_rejects_parent_traversal(tmp_path, jinja_cli_context):
project_root = tmp_path / "project"
project_root.mkdir()
jinja_cli_context.project_root = project_root

outside = tmp_path / "outside.secret"
outside.write_text("SECRET")

traversal = project_root / ".." / "outside.secret"
with pytest.raises(ClickException) as err:
_render(f"{{{{ '{traversal}' | read_file_content }}}}")
assert "outside the project root" in err.value.message


def test_procedure_from_js_file_rejects_path_outside_project_root(
tmp_path, jinja_cli_context
):
project_root = tmp_path / "project"
project_root.mkdir()
jinja_cli_context.project_root = project_root

outside_js = tmp_path / "evil.js"
outside_js.write_text("return 42;")

with pytest.raises(ClickException) as err:
_render(f"{{{{ '{outside_js}' | procedure_from_js_file }}}}")
assert "outside the project root" in err.value.message
assert "procedure_from_js_file" in err.value.message


def test_procedure_from_js_file_allows_file_inside_project_root(
tmp_path, jinja_cli_context
):
jinja_cli_context.project_root = tmp_path
js = tmp_path / "proc.js"
js.write_text("return arguments[0];")
rendered = _render(f"{{{{ '{js}' | procedure_from_js_file }}}}")
assert "return arguments[0];" in rendered
assert "module.exports = exports;" in rendered


def test_read_file_content_enforces_default_size_limit(tmp_path, jinja_cli_context):
"""A file over DEFAULT_SIZE_LIMIT_MB (128 MB) must be rejected rather than
read unbounded (the UNLIMITED bypass is removed)."""
from snowflake.cli.api.exceptions import FileTooLargeError

jinja_cli_context.project_root = tmp_path
target = tmp_path / "big.txt"
target.write_text("x")

with mock.patch(
"snowflake.cli.api.secure_path.SecurePath._assert_file_size_limit"
) as assert_size:
assert_size.side_effect = FileTooLargeError(target, 128)
with pytest.raises(FileTooLargeError):
_render(f"{{{{ '{target}' | read_file_content }}}}")


def test_read_file_content_without_project_root_is_permissive(tmp_path):
"""If there is no active project (get_cli_context().project_root raises),
the filter falls back to reading the file without containment — this matches
the behaviour of ad-hoc ``snow sql -q`` invocations outside a project."""
with mock.patch(
"snowflake.cli.api.cli_global_context.get_cli_context"
) as ctx, mock.patch(
"snowflake.cli.api.rendering.sql_templates.get_cli_context"
) as sql_ctx:
sql_ctx().template_context = {
"ctx": {"env": ProjectEnvironment(default_env={}, override_env={})}
}
type(ctx.return_value).project_root = mock.PropertyMock(
side_effect=RuntimeError("no project")
)

target = tmp_path / "file.txt"
target.write_text("standalone")
assert (
snowflake_sql_jinja_render(
f"{{{{ '{target}' | read_file_content }}}}",
template_syntax_config=SQLTemplateSyntaxConfig(
enable_jinja_syntax=True
),
)
== "standalone"
)
Loading