Skip to content

Security: missing guardrails in MCP server — path traversal, unvalidated profile_url and cdp_url #4

Description

@huangruoqi

Summary

Four security gaps found in tools/server.py. The most severe is a path traversal reachable through any MCP caller (Claude, cron sweep, or prompt-injected content). No existing guardrail protects against these in the live server.


1. Path traversal in prospect/conversation file tools (High)

_normalize_prospect_id_slug() exists (line 984) and is correctly called in save_connection and schedule_meeting, but is never called before constructing file paths in these tools:

Tool Line Raw path
get_prospect 1194 prospects/{prospect_id}.json
get_conversation 1207 conversations/{prospect_id}.json
upsert_prospect 1573 prospects/{prospect_id}.json
upsert_conversation 1525 conversations/{prospect_id}.json
save_outreach_report 1665 storage/reports/{prospect_id}.md
_sync_prospect_outreach_stage 1070 prospects/{prospect_id}.json

A malicious or prompt-injected prospect_id value of ../../config/persona or ../../config/conversation_planner would read or overwrite core config files outside the prospects/ / conversations/ directories.

Fix: Call _normalize_prospect_id_slug(prospect_id) at the top of each tool and return an error if it returns None. The normalizer already enforces ^[a-z0-9_]+$ and a 200-char cap. Optionally add a path.resolve().is_relative_to(expected_dir) check as a defense-in-depth backstop.


2. No LinkedIn domain validation on profile_url (Medium-High)

All 7 browser-driving tools (scrape_profile, parse_profile, is_first_degree_connection, send_connection_request, send_message, fetch_chat_history, download_profile_pdf) accept any profile_url string and pass it directly to LinkedInBrowser, which navigates the operator's real Chrome session.

Attack vector: Prompt injection via scraped content (e.g., a target's "About" text, a DM message, or a post body) supplies a non-LinkedIn URL. The browser then fetches that URL — leaking cookies, triggering LinkedIn bot-detection on foreign domains, or exfiltrating session state.

Fix: Validate profile_url before each tool calls the browser:

def _require_linkedin_url(url: str) -> str | None:
    parsed = urlparse(url.strip())
    if parsed.scheme not in ("https", "http"):
        return "error: profile_url must be an https LinkedIn URL"
    host = (parsed.hostname or "").lower()
    if not (host == "www.linkedin.com" or host.endswith(".linkedin.com")):
        return "error: profile_url must be a linkedin.com URL"
    return None

3. cdp_url parameter is fully user-controlled (Medium)

Every browser tool exposes cdp_url: str = "http://localhost:9222" as a callable parameter with no validation. The Chrome DevTools Protocol endpoint is passed directly to Playwright.

Attack vector: A prompt-injected or LLM-hallucinated value like cdp_url="http://attacker.com:9222" would connect Playwright to a remote CDP server — exposing the browser session externally (SSRF / session hijack).

Fix: Validate cdp_url is a localhost/loopback address before use:

def _require_local_cdp(cdp_url: str) -> str | None:
    parsed = urlparse(cdp_url)
    host = (parsed.hostname or "").lower()
    if host not in ("localhost", "127.0.0.1", "::1"):
        return f"error: cdp_url must be a localhost address, got {host!r}"
    return None

4. browse_forever reaction type not validated in code (Low)

The browse_forever tool docstring lists six valid reaction values (Like, Celebrate, Support, Funny, Love, Insightful), but the reaction parameter is passed to the browser without any code-level check. An out-of-range value causes undefined browser behavior.

Fix: Add a guard at the top of the tool:

_VALID_REACTIONS = frozenset({"Like", "Celebrate", "Support", "Funny", "Love", "Insightful"})
if reaction not in _VALID_REACTIONS:
    return f"error: invalid reaction {reaction!r}; must be one of {sorted(_VALID_REACTIONS)}"

No existing guardrail

There is no middleware, decorator, or input-sanitization layer in tools/server.py today — each tool is responsible for its own validation. Issues 1–3 represent cases where that per-tool responsibility was missed. A shared validation helper (similar to the existing _normalize_prospect_id_slug and _EMAIL_RE) would prevent the same class of bugs from recurring.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions