diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 609d0e39a9..0287f68e1e 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -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 diff --git a/src/snowflake/cli/api/rendering/jinja.py b/src/snowflake/cli/api/rendering/jinja.py index 2d874dd0ce..b4b1043997 100644 --- a/src/snowflake/cli/api/rendering/jinja.py +++ b/src/snowflake/cli/api/rendering/jinja.py @@ -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( """\ @@ -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) ) diff --git a/tests/api/test_rendering.py b/tests/api/test_rendering.py index 4e5ddff6a4..85a470f614 100644 --- a/tests/api/test_rendering.py +++ b/tests/api/test_rendering.py @@ -180,3 +180,147 @@ def test_has_client_side_templates(): assert not has_client_side_templates("") assert not has_client_side_templates("{ 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" + )