Skip to content

Commit f972526

Browse files
fix(mcp): validate HTTP origins and hosts (CTX7-2533)
1 parent 0ff958c commit f972526

8 files changed

Lines changed: 324 additions & 33 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@upstash/context7-mcp": patch
3+
---
4+
5+
Validate HTTP Origin and Host headers and bind the local HTTP transport to loopback by default.

docs/resources/developer.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ node packages/mcp/dist/index.js
3535
|------|-------------|---------|
3636
| `--transport <stdio\|http>` | Transport to use. Use `http` for remote HTTP server or `stdio` for local integration. | `stdio` |
3737
| `--port <number>` | Port to listen on when using `http` transport. | `3000` |
38+
| `--host <host>` | Interface to bind when using `http` transport. | `127.0.0.1` |
3839
| `--api-key <key>` | API key for authentication (or set `CONTEXT7_API_KEY` env var). | - |
3940

4041
<Note>
@@ -74,6 +75,12 @@ You can use the `CONTEXT7_API_KEY` environment variable instead of passing the `
7475
CONTEXT7_API_KEY=your_api_key_here
7576
```
7677

78+
HTTP deployments can set `CONTEXT7_MCP_HOST` to choose the bind interface and
79+
`CONTEXT7_MCP_ALLOWED_ORIGINS` to add exact, comma-separated browser origins.
80+
The `--host` flag takes precedence over `CONTEXT7_MCP_HOST`. Keep the default
81+
loopback bind for local use; set `--host 0.0.0.0` only behind a trusted network
82+
boundary.
83+
7784
### MCP Configuration with Environment Variable
7885

7986
```json

packages/mcp/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,4 @@ COPY --from=builder /app/packages/mcp/dist ./packages/mcp/dist
2626

2727
WORKDIR /app/packages/mcp
2828
EXPOSE 8080
29-
CMD ["node", "dist/index.js", "--transport", "http", "--port", "8080"]
29+
CMD ["node", "dist/index.js", "--transport", "http", "--port", "8080", "--host", "0.0.0.0"]

packages/mcp/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1443,6 +1443,7 @@ bun run dist/index.js
14431443

14441444
- `--transport <stdio|http>` – Transport to use (`stdio` by default). Use `http` for remote HTTP server or `stdio` for local integration.
14451445
- `--port <number>` – Port to listen on when using `http` transport (default `3000`).
1446+
- `--host <host>` – Interface to bind when using `http` transport (default `127.0.0.1`). Set this explicitly, for example to `0.0.0.0`, only when deploying behind a trusted network boundary.
14461447
- `--api-key <key>` – API key for authentication (or set `CONTEXT7_API_KEY` env var). You can get your API key by creating an account at [context7.com/dashboard](https://context7.com/dashboard).
14471448

14481449
Example with HTTP transport and port 8080:
@@ -1474,6 +1475,13 @@ You can use the `CONTEXT7_API_KEY` environment variable instead of passing the `
14741475
CONTEXT7_API_KEY=your_api_key_here
14751476
```
14761477

1478+
HTTP deployments can also set these security options:
1479+
1480+
- `CONTEXT7_MCP_HOST` – Interface to bind; the CLI `--host` value takes precedence.
1481+
- `CONTEXT7_MCP_ALLOWED_ORIGINS` – Comma-separated additional browser origins allowed to call the server. Values must be exact origins, such as `https://docs.example.com`.
1482+
1483+
Requests without an `Origin` header, such as normal server-to-server MCP clients, continue to work. When bound to loopback, browser requests must use a loopback origin and the Host header is validated against DNS rebinding. With a non-loopback bind, Context7's production web origins and any explicitly configured origins are allowed.
1484+
14771485
**Example MCP configuration using environment variable:**
14781486

14791487
```json

packages/mcp/src/index.ts

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from "./lib/constants.js";
2626
import { maybeElicitAuthSignIn } from "./lib/auth/auth-prompt.js";
2727
import { getClientIp } from "./lib/client-ip.js";
28+
import { createHttpSecurityMiddleware } from "./lib/http-security.js";
2829

2930
/** Default HTTP server port */
3031
const DEFAULT_PORT = 3000;
@@ -34,13 +35,19 @@ const program = new Command()
3435
.version(SERVER_VERSION, "-v, --version", "output the current version")
3536
.option("--transport <stdio|http>", "transport type", "stdio")
3637
.option("--port <number>", "port for HTTP transport", DEFAULT_PORT.toString())
38+
.option(
39+
"--host <host>",
40+
"host interface for HTTP transport",
41+
process.env.CONTEXT7_MCP_HOST || "127.0.0.1"
42+
)
3743
.option("--api-key <key>", "API key for authentication (or set CONTEXT7_API_KEY env var)")
3844
.allowUnknownOption() // let MCP Inspector / other wrappers pass through extra flags
3945
.parse(process.argv);
4046

4147
const cliOptions = program.opts<{
4248
transport: string;
4349
port: string;
50+
host: string;
4451
apiKey?: string;
4552
}>();
4653

@@ -58,6 +65,7 @@ const TRANSPORT_TYPE = (cliOptions.transport || "stdio") as "stdio" | "http";
5865

5966
// Disallow incompatible flags based on transport
6067
const passedPortFlag = process.argv.includes("--port");
68+
const passedHostFlag = process.argv.includes("--host");
6169
const passedApiKeyFlag = process.argv.includes("--api-key");
6270

6371
if (TRANSPORT_TYPE === "http" && passedApiKeyFlag) {
@@ -67,8 +75,8 @@ if (TRANSPORT_TYPE === "http" && passedApiKeyFlag) {
6775
process.exit(1);
6876
}
6977

70-
if (TRANSPORT_TYPE === "stdio" && passedPortFlag) {
71-
console.error("The --port flag is not allowed when using --transport stdio.");
78+
if (TRANSPORT_TYPE === "stdio" && (passedPortFlag || passedHostFlag)) {
79+
console.error("The --port and --host flags are not allowed when using --transport stdio.");
7280
process.exit(1);
7381
}
7482

@@ -78,6 +86,8 @@ const CLI_PORT = (() => {
7886
return isNaN(parsed) ? undefined : parsed;
7987
})();
8088

89+
const HTTP_HOST = cliOptions.host;
90+
8191
const requestContext = new AsyncLocalStorage<ClientContext>();
8292

8393
// Global state for stdio mode only
@@ -316,26 +326,9 @@ async function main() {
316326
const initialPort = CLI_PORT ?? DEFAULT_PORT;
317327

318328
const app = express();
329+
app.use(createHttpSecurityMiddleware(HTTP_HOST, process.env.CONTEXT7_MCP_ALLOWED_ORIGINS));
319330
app.use(express.json());
320331

321-
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
322-
res.setHeader("Access-Control-Allow-Origin", "*");
323-
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,DELETE");
324-
// Mcp-Method / Mcp-Name are the SEP-2243 standard headers 2026-07-28
325-
// clients send on every request; without them here, browser-based modern
326-
// clients fail the CORS preflight. (Mcp-Param-* mirroring is skipped by
327-
// browser clients, so those are not needed.)
328-
res.setHeader(
329-
"Access-Control-Allow-Headers",
330-
"Content-Type, MCP-Session-Id, MCP-Protocol-Version, Mcp-Method, Mcp-Name, X-Context7-API-Key, Context7-API-Key, X-API-Key, Authorization"
331-
);
332-
if (req.method === "OPTIONS") {
333-
res.sendStatus(200);
334-
return;
335-
}
336-
next();
337-
});
338-
339332
const extractHeaderValue = (value: string | string[] | undefined): string | undefined => {
340333
if (!value) return undefined;
341334
return typeof value === "string" ? value : value[0];
@@ -533,7 +526,7 @@ async function main() {
533526
});
534527

535528
const startServer = (port: number, maxAttempts = 10) => {
536-
const httpServer = app.listen(port);
529+
const httpServer = app.listen(port, HTTP_HOST);
537530

538531
httpServer.once("error", (err: NodeJS.ErrnoException) => {
539532
if (err.code === "EADDRINUSE" && port < initialPort + maxAttempts) {
@@ -546,8 +539,9 @@ async function main() {
546539
});
547540

548541
httpServer.once("listening", () => {
542+
const displayHost = HTTP_HOST.includes(":") ? `[${HTTP_HOST}]` : HTTP_HOST;
549543
console.error(
550-
`Context7 Documentation MCP Server v${SERVER_VERSION} running on HTTP at http://localhost:${port}/mcp`
544+
`Context7 Documentation MCP Server v${SERVER_VERSION} running on HTTP at http://${displayHost}:${port}/mcp`
551545
);
552546
});
553547
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, test } from "vitest";
2+
import { isLoopbackHost, isLoopbackHostname, isLoopbackOrigin } from "./http-security.js";
3+
4+
describe("loopback request validation", () => {
5+
test("recognizes supported bind-host spellings", () => {
6+
expect(isLoopbackHostname("localhost")).toBe(true);
7+
expect(isLoopbackHostname("localhost.")).toBe(true);
8+
expect(isLoopbackHostname("127.0.0.2")).toBe(true);
9+
expect(isLoopbackHostname("::1")).toBe(true);
10+
expect(isLoopbackHostname("[0:0:0:0:0:0:0:1]")).toBe(true);
11+
expect(isLoopbackHostname("0.0.0.0")).toBe(false);
12+
});
13+
14+
test("accepts literal IPv4, IPv6, and localhost origins", () => {
15+
expect(isLoopbackOrigin("http://localhost:5173")).toBe(true);
16+
expect(isLoopbackOrigin("https://localhost")).toBe(true);
17+
expect(isLoopbackOrigin("http://127.0.0.1:8080")).toBe(true);
18+
expect(isLoopbackOrigin("http://[::1]:3000")).toBe(true);
19+
});
20+
21+
test("rejects null, malformed, userinfo, and lookalike origins", () => {
22+
expect(isLoopbackOrigin("null")).toBe(false);
23+
expect(isLoopbackOrigin("not a URL")).toBe(false);
24+
expect(isLoopbackOrigin("http://user@localhost:3000")).toBe(false);
25+
expect(isLoopbackOrigin("http://localhost.attacker.example")).toBe(false);
26+
});
27+
28+
test("accepts literal loopback Host values with any port", () => {
29+
expect(isLoopbackHost("localhost:3000")).toBe(true);
30+
expect(isLoopbackHost("127.0.0.1:43117")).toBe(true);
31+
expect(isLoopbackHost("[::1]:3000")).toBe(true);
32+
});
33+
34+
test("rejects missing, malformed, userinfo, and lookalike Host values", () => {
35+
expect(isLoopbackHost(undefined)).toBe(false);
36+
expect(isLoopbackHost("user@localhost")).toBe(false);
37+
expect(isLoopbackHost("localhost.attacker.example")).toBe(false);
38+
expect(isLoopbackHost("127.0.0.1/path")).toBe(false);
39+
});
40+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { RequestHandler } from "express";
2+
import { isIP } from "node:net";
3+
4+
const DEFAULT_HOSTED_ORIGINS = ["https://context7.com", "https://www.context7.com"];
5+
const ALLOWED_METHODS = "GET,POST,OPTIONS,DELETE";
6+
// Mcp-Method and Mcp-Name are SEP-2243 headers sent by modern browser clients.
7+
const ALLOWED_HEADERS =
8+
"Content-Type, MCP-Session-Id, MCP-Protocol-Version, Mcp-Method, Mcp-Name, X-Context7-API-Key, Context7-API-Key, X-API-Key, Authorization";
9+
10+
function parseOrigin(value: string): URL | undefined {
11+
try {
12+
const url = new URL(value);
13+
if (
14+
url.origin !== value ||
15+
!["http:", "https:"].includes(url.protocol) ||
16+
url.username ||
17+
url.password
18+
) {
19+
return undefined;
20+
}
21+
return url;
22+
} catch {
23+
return undefined;
24+
}
25+
}
26+
27+
function normalizeConfiguredOrigins(value: string | undefined): Set<string> {
28+
const origins = (value ?? "")
29+
.split(",")
30+
.map((origin) => origin.trim())
31+
.filter(Boolean);
32+
33+
return new Set(
34+
[...DEFAULT_HOSTED_ORIGINS, ...origins].map((origin) => {
35+
const parsed = parseOrigin(origin);
36+
if (!parsed) throw new Error(`Invalid allowed origin: '${origin}'`);
37+
return parsed.origin;
38+
})
39+
);
40+
}
41+
42+
export function isLoopbackHostname(hostname: string): boolean {
43+
const unwrapped = hostname
44+
.toLowerCase()
45+
.replace(/^\[|\]$/g, "")
46+
.replace(/\.$/, "");
47+
if (unwrapped === "localhost") return true;
48+
if (isIP(unwrapped) === 4) return unwrapped.startsWith("127.");
49+
if (isIP(unwrapped) === 6) return new URL(`http://[${unwrapped}]`).hostname === "[::1]";
50+
return false;
51+
}
52+
53+
export function isLoopbackOrigin(origin: string): boolean {
54+
const url = parseOrigin(origin);
55+
return url !== undefined && isLoopbackHostname(url.hostname);
56+
}
57+
58+
export function isLoopbackHost(hostHeader: string | undefined): boolean {
59+
if (!hostHeader) return false;
60+
61+
try {
62+
const url = new URL(`http://${hostHeader}`);
63+
return (
64+
!url.username &&
65+
!url.password &&
66+
url.pathname === "/" &&
67+
!url.search &&
68+
!url.hash &&
69+
isLoopbackHostname(url.hostname)
70+
);
71+
} catch {
72+
return false;
73+
}
74+
}
75+
76+
export function createHttpSecurityMiddleware(
77+
bindHost: string,
78+
additionalHostedOrigins?: string
79+
): RequestHandler {
80+
const isLocal = isLoopbackHostname(bindHost);
81+
const hostedOrigins = isLocal ? undefined : normalizeConfiguredOrigins(additionalHostedOrigins);
82+
const isOriginAllowed = isLocal
83+
? isLoopbackOrigin
84+
: (origin: string) => hostedOrigins?.has(origin) === true;
85+
86+
return (req, res, next) => {
87+
if (isLocal && !isLoopbackHost(req.headers.host)) {
88+
res.status(403).json({ error: "forbidden", message: "Untrusted Host header." });
89+
return;
90+
}
91+
92+
const origin = req.headers.origin;
93+
if (origin && !isOriginAllowed(origin)) {
94+
res.status(403).json({ error: "forbidden", message: "Untrusted Origin header." });
95+
return;
96+
}
97+
98+
res.vary("Origin");
99+
if (origin) {
100+
res.setHeader("Access-Control-Allow-Origin", origin);
101+
res.setHeader("Access-Control-Allow-Methods", ALLOWED_METHODS);
102+
res.setHeader("Access-Control-Allow-Headers", ALLOWED_HEADERS);
103+
}
104+
105+
if (req.method === "OPTIONS") {
106+
res.vary("Access-Control-Request-Method");
107+
res.vary("Access-Control-Request-Headers");
108+
res.sendStatus(204);
109+
return;
110+
}
111+
112+
next();
113+
};
114+
}

0 commit comments

Comments
 (0)