Skip to content

Fixed remote code execution vulnerability in Composio server API #1589

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

geckosecurity
Copy link

Version: 0.7.16-rc1

Remote Code Execution in Composio Server API

Description

A remote code execution (RCE) vulnerability has been identified in Composio API Server version 0.7.16-rc1 and potentially earlier versions. The vulnerability exists in the /api/tools endpoint, which allows attackers to upload and execute arbitrary Python code on the server without proper validation or sandboxing.

The vulnerability is particularly severe because:

  1. The server binds to all network interfaces (0.0.0.0) in the default Docker deployment, making it remotely accessible
  2. Authentication is completely bypassed if the ACCESS_TOKEN environment variable is not set
  3. Even with authentication enabled, any authenticated user can exploit the vulnerability
  4. No code validation or sandboxing is implemented

This vulnerability allows remote attackers to execute arbitrary code with the privileges of the server process, potentially leading to complete system compromise.

Source-Sink Analysis

Source: User-controlled input via the content field in the ToolUploadRequest object sent to the /api/tools endpoint.

Sink: The code path where the user input is written to a file and executed via importlib.import_module().

Vulnerable Code Path:

The vulnerability is located in python/composio/server/api.py in the _upload_workspace_tools function:

@app.post("/api/tools", response_model=APIResponse[t.List[str]])
@with_exception_handling
def _upload_workspace_tools(request: ToolUploadRequest) -> t.List[str]:
    """Get list of available developer tools."""
    if len(request.dependencies) > 0:
        process = subprocess.run(
            args=["pip", "install", *request.dependencies],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )
        if process.returncode != 0:
            raise RuntimeError(
                f"Error installing dependencies: {process.stderr.decode()}"
            )

    filename = md5(request.content.encode(encoding="utf-8")).hexdigest()
    tempfile = Path(tooldir.name, f"{filename}.py")
    if tempfile.exists():
        raise ValueError("Tools from this module already exits!")

    tempfile.write_text(request.content)
    importlib.import_module(filename)
    return get_runtime_actions()

The ToolUploadRequest class is defined as:

class ToolUploadRequest(BaseModel):
    """Tool upload request."""
    content: str = Field(
        ...,
        description="Content from the tool description file.",
    )
    filename: str = Field(
        ...,
        description="Name of the file.",
    )
    dependencies: t.List[str] = Field(
        ...,
        description="List of dependencies.",
    )

Authentication Bypass:

The authentication middleware in api.py contains a critical flaw that bypasses authentication when ACCESS_TOKEN is not set:

@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
    if access_token is None:
        return await call_next(request)
    
    if "x-api-key" in request.headers and request.headers["x-api-key"]:
        return await call_next(request)
    
    return Response(
        content=APIResponse[None](
            data=None,
            error="Unauthorised request",
        ).model_dump_json(),
        status_code=401,
    )

Default Exposure:

The Docker deployment in python/dockerfiles/entrypoint.sh exposes the server to all network interfaces:

# Start tooling server
composio serve -h "0.0.0.0" -p 8000

Proof of Concept

This proof-of-concept demonstrates remote code execution via the vulnerable endpoint:

Step 1: Create a payload file

cat > exploit_payload.json << 'EOF'
{
  "content": "import os; os.system('id > /tmp/python_exploit.txt'); print('Python code execution successful!')",
  "filename": "exploit.py",
  "dependencies": []
}
EOF

Step 2: Send the exploit request

# Without authentication
curl -X POST http://localhost:8000/api/tools \
  -H "Content-Type: application/json" \
  -d @exploit_payload.json

# OR with authentication
curl -X POST http://localhost:8000/api/tools \
  -H "Content-Type: application/json" \
  -H "x-api-key: test_token" \
  -d @exploit_payload.json

Impact

  • Attackers can execute arbitrary Python code on the server with the same privileges as the server process.
  • If ACCESS_TOKEN is not set, the vulnerability can be exploited without any authentication.
  • In multi-user environments, any authenticated user can execute code affecting all users.
  • Attackers can access sensitive files, read configuration settings, and exfiltrate data from the server.
  • The vulnerability allows creating backdoors or establishing persistence on the system.
  • Attackers can use the compromised server to pivot to other systems in the internal network.

Contact

If you have any queries regarding this bug report, send an email to [email protected].

Copy link

vercel bot commented May 7, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
composio ✅ Ready (Inspect) Visit Preview 💬 Add feedback May 7, 2025 9:06pm

tuple: (is_valid: bool, error_message: str)
"""
for dep in dependencies:
if not re.match(r'^[a-zA-Z0-9_\-\.]+[a-zA-Z0-9_\-\.=<>]*$', dep):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: The regex pattern for validating dependencies allows potentially dangerous package names with special characters that could be used for command injection. The pattern should be more restrictive.

📝 Committable Code Suggestion

‼️ Ensure you review the code suggestion before committing it to the branch. Make sure it replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
if not re.match(r'^[a-zA-Z0-9_\-\.]+[a-zA-Z0-9_\-\.=<>]*$', dep):
if not re.match(r'^[a-zA-Z0-9_\-\.]+[a-zA-Z0-9_\-\.=<>]*$', dep) or '..' in dep or '//' in dep or '\\' in dep:

Comment on lines +368 to +373
process = subprocess.run(
args=["pip", "install", "--no-cache-dir", "--disable-pip-version-check", *request.dependencies],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=120,
)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: The subprocess.run() call for installing dependencies lacks the check=True parameter, which means it won't raise an exception for non-zero exit codes and relies on manual checking.

📝 Committable Code Suggestion

‼️ Ensure you review the code suggestion before committing it to the branch. Make sure it replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
process = subprocess.run(
args=["pip", "install", "--no-cache-dir", "--disable-pip-version-check", *request.dependencies],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=120,
)
process = subprocess.run(
args=["pip", "install", "--no-cache-dir", "--disable-pip-version-check", *request.dependencies],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=120,
check=True
)

Comment on lines +154 to +166
import_regex = re.compile(r'^(?:from|import)\s+([a-zA-Z0-9_.]+)', re.MULTILINE)
imports = import_regex.findall(content)

for imp in imports:
module_parts = imp.split('.')[0]
if module_parts in DANGEROUS_IMPORTS:
return False, f"Dangerous import detected: {imp}"

# Check for dangerous patterns
for pattern in DANGEROUS_PATTERNS:
matches = re.search(pattern, content)
if matches:
return False, f"Dangerous code pattern detected: {matches.group(0)}"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: The validate_tool_content() function doesn't check for indirect imports using __import__ or other dynamic import techniques that could bypass the import validation.

📝 Committable Code Suggestion

‼️ Ensure you review the code suggestion before committing it to the branch. Make sure it replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
import_regex = re.compile(r'^(?:from|import)\s+([a-zA-Z0-9_.]+)', re.MULTILINE)
imports = import_regex.findall(content)
for imp in imports:
module_parts = imp.split('.')[0]
if module_parts in DANGEROUS_IMPORTS:
return False, f"Dangerous import detected: {imp}"
# Check for dangerous patterns
for pattern in DANGEROUS_PATTERNS:
matches = re.search(pattern, content)
if matches:
return False, f"Dangerous code pattern detected: {matches.group(0)}"
import_regex = re.compile(r'^(?:from|import)\s+([a-zA-Z0-9_.]+)', re.MULTILINE)
imports = import_regex.findall(content)
for imp in imports:
module_parts = imp.split('.')[0]
if module_parts in DANGEROUS_IMPORTS:
return False, f"Dangerous import detected: {imp}"
# Check for dangerous patterns
for pattern in DANGEROUS_PATTERNS:
matches = re.search(pattern, content)
if matches:
return False, f"Dangerous code pattern detected: {matches.group(0)}"
# Check for any attempt to use __builtins__ to access restricted functionality
if re.search(r'__builtins__', content):
return False, "Dangerous code pattern detected: __builtins__ access"

Comment on lines +207 to +217
if access_token is None:
logger.warning("Access token not set. Using default authentication.")
# Instead of bypassing authentication, use a default token
if "x-api-key" not in request.headers or not request.headers["x-api-key"]:
return Response(
content=APIResponse[None](
data=None,
error="Authentication required. Set ACCESS_TOKEN environment variable.",
).model_dump_json(),
status_code=401,
)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: The auth_middleware function doesn't properly handle the case when access_token is None and no API key is provided, potentially allowing unauthenticated access.

📝 Committable Code Suggestion

‼️ Ensure you review the code suggestion before committing it to the branch. Make sure it replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
if access_token is None:
logger.warning("Access token not set. Using default authentication.")
# Instead of bypassing authentication, use a default token
if "x-api-key" not in request.headers or not request.headers["x-api-key"]:
return Response(
content=APIResponse[None](
data=None,
error="Authentication required. Set ACCESS_TOKEN environment variable.",
).model_dump_json(),
status_code=401,
)
if access_token is None:
logger.warning("Access token not set. Requiring authentication with any non-empty token.")
# Instead of bypassing authentication, require any non-empty token
if "x-api-key" not in request.headers or not request.headers["x-api-key"]:
return Response(
content=APIResponse[None](
data=None,
error="Authentication required. Set ACCESS_TOKEN environment variable.",
).model_dump_json(),
status_code=401,
)

Comment on lines +422 to +444
requested_path = Path(file)

if requested_path.is_absolute():
requested_abs_path = requested_path.resolve()
base_abs_path = base_download_dir

if not str(requested_abs_path).startswith(str(base_abs_path)):
logger.warning(f"Path traversal attempt: {requested_abs_path} is outside {base_abs_path}")
raise HTTPException(
status_code=403,
detail="Access denied: Cannot access files outside the workspace directory"
)

safe_path = requested_abs_path
else:
safe_path = (base_download_dir / requested_path).resolve()

if not str(safe_path).startswith(str(base_download_dir)):
logger.warning(f"Path traversal attempt with relative path: {safe_path} is outside {base_download_dir}")
raise HTTPException(
status_code=403,
detail="Access denied: Cannot access files outside the workspace directory"
)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: The file path validation in the download endpoint doesn't handle symbolic links, which could allow path traversal attacks if a symbolic link points outside the base directory.

📝 Committable Code Suggestion

‼️ Ensure you review the code suggestion before committing it to the branch. Make sure it replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
requested_path = Path(file)
if requested_path.is_absolute():
requested_abs_path = requested_path.resolve()
base_abs_path = base_download_dir
if not str(requested_abs_path).startswith(str(base_abs_path)):
logger.warning(f"Path traversal attempt: {requested_abs_path} is outside {base_abs_path}")
raise HTTPException(
status_code=403,
detail="Access denied: Cannot access files outside the workspace directory"
)
safe_path = requested_abs_path
else:
safe_path = (base_download_dir / requested_path).resolve()
if not str(safe_path).startswith(str(base_download_dir)):
logger.warning(f"Path traversal attempt with relative path: {safe_path} is outside {base_download_dir}")
raise HTTPException(
status_code=403,
detail="Access denied: Cannot access files outside the workspace directory"
)
requested_path = Path(file)
# Reject paths with suspicious patterns
if '..' in str(requested_path) or '//' in str(requested_path) or '\\\\' in str(requested_path):
logger.warning(f"Suspicious path pattern detected: {requested_path}")
raise HTTPException(
status_code=403,
detail="Access denied: Path contains suspicious patterns"
)
if requested_path.is_absolute():
requested_abs_path = requested_path.resolve()
base_abs_path = base_download_dir
if not str(requested_abs_path).startswith(str(base_abs_path)):
logger.warning(f"Path traversal attempt: {requested_abs_path} is outside {base_abs_path}")
raise HTTPException(
status_code=403,
detail="Access denied: Cannot access files outside the workspace directory"
)
safe_path = requested_abs_path
else:
safe_path = (base_download_dir / requested_path).resolve()
if not str(safe_path).startswith(str(base_download_dir)):
logger.warning(f"Path traversal attempt with relative path: {safe_path} is outside {base_download_dir}")
raise HTTPException(
status_code=403,
detail="Access denied: Cannot access files outside the workspace directory"
)

@geckosecurity geckosecurity changed the title [BUG] Remote Code Execution in Composio Server API Fixed remote code execution vulnerability in Composio server API May 13, 2025
@geckosecurity
Copy link
Author

Hi @angrybayblade, @kaavee315 just wanted to check in on this too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants