@upstash/context7-mcp 4.0.3 → 4.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -12,7 +12,6 @@ import { AsyncLocalStorage } from "async_hooks";
12
12
  import { randomUUID } from "node:crypto";
13
13
  import { SERVER_VERSION, RESOURCE_URL, OAUTH_AUTH_SERVER_URL, EMA_ISSUER, OPENAI_APPS_CHALLENGE_TOKEN, } from "./lib/constants.js";
14
14
  import { maybeElicitAuthSignIn } from "./lib/auth/auth-prompt.js";
15
- import { getClientIp } from "./lib/client-ip.js";
16
15
  /** Default HTTP server port */
17
16
  const DEFAULT_PORT = 3000;
18
17
  // Parse CLI arguments using commander
@@ -242,6 +241,9 @@ async function main() {
242
241
  if (TRANSPORT_TYPE === "http") {
243
242
  const initialPort = CLI_PORT ?? DEFAULT_PORT;
244
243
  const app = express();
244
+ // Only private/local infrastructure may supply forwarding headers. Express
245
+ // then walks the chain right-to-left and ignores attacker-added prefixes.
246
+ app.set("trust proxy", ["loopback", "linklocal", "uniquelocal", "100.64.0.0/10"]);
245
247
  app.use(express.json());
246
248
  app.use((req, res, next) => {
247
249
  res.setHeader("Access-Control-Allow-Origin", "*");
@@ -273,6 +275,7 @@ async function main() {
273
275
  };
274
276
  const extractApiKey = (req) => {
275
277
  return (extractBearerToken(req.headers.authorization) ||
278
+ extractHeaderValue(req.headers["x-context7-api-key"]) ||
276
279
  extractHeaderValue(req.headers["context7-api-key"]) ||
277
280
  extractHeaderValue(req.headers["x-api-key"]) ||
278
281
  extractHeaderValue(req.headers["context7_api_key"]) ||
@@ -336,7 +339,7 @@ async function main() {
336
339
  }
337
340
  }
338
341
  const context = {
339
- clientIp: getClientIp(req),
342
+ clientIp: req.ip,
340
343
  apiKey: apiKey,
341
344
  clientInfo: extractClientInfoFromUserAgent(req.headers["user-agent"]),
342
345
  transport: "http",
@@ -1,27 +1,59 @@
1
1
  import { createCipheriv, randomBytes } from "crypto";
2
+ import { isIP } from "node:net";
2
3
  import { SERVER_VERSION } from "./constants.js";
3
- const DEFAULT_ENCRYPTION_KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
4
- const ENCRYPTION_KEY = process.env.CLIENT_IP_ENCRYPTION_KEY || DEFAULT_ENCRYPTION_KEY;
5
- const ALGORITHM = "aes-256-cbc";
4
+ const LEGACY_ALGORITHM = "aes-256-cbc";
5
+ const ASSERTION_ALGORITHM = "aes-256-gcm";
6
+ const ASSERTION_VERSION = "v1";
7
+ let reportedInvalidAssertionKey = false;
6
8
  function validateEncryptionKey(key) {
7
9
  // Must be exactly 64 hex characters (32 bytes)
8
10
  return /^[0-9a-fA-F]{64}$/.test(key);
9
11
  }
10
- function encryptClientIp(clientIp) {
11
- if (!validateEncryptionKey(ENCRYPTION_KEY)) {
12
- console.error("Invalid encryption key format. Must be 64 hex characters.");
13
- return clientIp; // Fallback to unencrypted
14
- }
12
+ function encryptionKey(name) {
13
+ const key = process.env[name];
14
+ return key && validateEncryptionKey(key) ? Buffer.from(key, "hex") : null;
15
+ }
16
+ /**
17
+ * Temporary compatibility header for API deployments that predate authenticated assertions.
18
+ * This header is ignored by patched API deployments. Removal is tracked by CTX7-2536.
19
+ */
20
+ function encryptLegacyClientIp(clientIp, key) {
15
21
  try {
16
22
  const iv = randomBytes(16);
17
- const cipher = createCipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, "hex"), iv);
18
- let encrypted = cipher.update(clientIp, "utf8", "hex");
19
- encrypted += cipher.final("hex");
20
- return iv.toString("hex") + ":" + encrypted;
23
+ const cipher = createCipheriv(LEGACY_ALGORITHM, key, iv);
24
+ const encrypted = Buffer.concat([cipher.update(clientIp, "utf8"), cipher.final()]);
25
+ return `${iv.toString("hex")}:${encrypted.toString("hex")}`;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /**
32
+ * Create a short-lived, authenticated client-IP assertion.
33
+ * Format: v1:<unix timestamp seconds>:<12-byte nonce hex>:<ciphertext + tag hex>
34
+ */
35
+ export function createClientIpAssertion(clientIp, nowMs = Date.now(), nonce = randomBytes(12)) {
36
+ const key = encryptionKey("MCP_CLIENT_IP_ASSERTION_KEY");
37
+ if (!key) {
38
+ if (!reportedInvalidAssertionKey) {
39
+ reportedInvalidAssertionKey = true;
40
+ console.error("MCP_CLIENT_IP_ASSERTION_KEY is missing or invalid; client IP assertions are disabled.");
41
+ }
42
+ return null;
43
+ }
44
+ if (nonce.length !== 12 || isIP(clientIp) === 0)
45
+ return null;
46
+ try {
47
+ const timestamp = Math.floor(nowMs / 1000).toString();
48
+ const aad = `${ASSERTION_VERSION}:${timestamp}`;
49
+ const cipher = createCipheriv(ASSERTION_ALGORITHM, key, nonce);
50
+ cipher.setAAD(Buffer.from(aad, "utf8"));
51
+ const ciphertext = Buffer.concat([cipher.update(clientIp, "utf8"), cipher.final()]);
52
+ const ciphertextAndTag = Buffer.concat([ciphertext, cipher.getAuthTag()]);
53
+ return `${aad}:${nonce.toString("hex")}:${ciphertextAndTag.toString("hex")}`;
21
54
  }
22
- catch (error) {
23
- console.error("Error encrypting client IP:", error);
24
- return clientIp; // Fallback to unencrypted
55
+ catch {
56
+ return null;
25
57
  }
26
58
  }
27
59
  /**
@@ -34,7 +66,15 @@ export function generateHeaders(context) {
34
66
  "X-Context7-Server-Version": SERVER_VERSION,
35
67
  };
36
68
  if (context.clientIp) {
37
- headers["mcp-client-ip"] = encryptClientIp(context.clientIp);
69
+ const assertion = createClientIpAssertion(context.clientIp);
70
+ if (assertion) {
71
+ headers["mcp-client-ip-assertion"] = assertion;
72
+ // Producer-first rollout compatibility. Removal is tracked by CTX7-2536.
73
+ const key = encryptionKey("CLIENT_IP_ENCRYPTION_KEY");
74
+ const legacyValue = key ? encryptLegacyClientIp(context.clientIp, key) : null;
75
+ if (legacyValue)
76
+ headers["mcp-client-ip"] = legacyValue;
77
+ }
38
78
  }
39
79
  if (context.sessionId) {
40
80
  headers["mcp-session-id"] = context.sessionId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upstash/context7-mcp",
3
- "version": "4.0.3",
3
+ "version": "4.0.4",
4
4
  "mcpName": "io.github.upstash/context7",
5
5
  "description": "MCP server for Context7",
6
6
  "repository": {
@@ -1,60 +0,0 @@
1
- function stripIpv4MappedPrefix(ip) {
2
- return ip.replace(/^::ffff:/i, "");
3
- }
4
- /**
5
- * Returns true for RFC1918, CGNAT, loopback, link-local, and IPv6 private ranges.
6
- */
7
- export function isPrivateOrLocalIp(ip) {
8
- const plainIp = stripIpv4MappedPrefix(ip).toLowerCase();
9
- if (plainIp.includes(".")) {
10
- return (plainIp.startsWith("10.") ||
11
- plainIp.startsWith("192.168.") ||
12
- /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(plainIp) ||
13
- /^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./.test(plainIp) ||
14
- plainIp.startsWith("127.") ||
15
- plainIp.startsWith("169.254."));
16
- }
17
- // ::1 loopback in any textual form (e.g. "0::1", "0:0:0:0:0:0:0:1")
18
- if (/^[0:]+1$/.test(plainIp)) {
19
- return true;
20
- }
21
- // First hextets in fe80::/10 and fc00::/7 start with a non-zero digit, so a
22
- // valid textual form always spells out all 4 digits.
23
- // fe80::/10 link-local
24
- if (/^fe[89ab][0-9a-f]:/.test(plainIp)) {
25
- return true;
26
- }
27
- // fc00::/7 unique local
28
- if (/^f[cd][0-9a-f]{2}:/.test(plainIp)) {
29
- return true;
30
- }
31
- return false;
32
- }
33
- function pickClientIpFromForwardedList(ipList) {
34
- for (const ip of ipList) {
35
- const plainIp = stripIpv4MappedPrefix(ip);
36
- if (!isPrivateOrLocalIp(plainIp)) {
37
- return plainIp;
38
- }
39
- }
40
- if (ipList.length === 0) {
41
- return undefined;
42
- }
43
- return stripIpv4MappedPrefix(ipList[0]);
44
- }
45
- /**
46
- * Extract client IP address from request headers.
47
- * Handles X-Forwarded-For header for proxied requests.
48
- */
49
- export function getClientIp(req) {
50
- const forwardedFor = req.headers["x-forwarded-for"] || req.headers["X-Forwarded-For"];
51
- if (forwardedFor) {
52
- const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
53
- const ipList = ips.split(",").map((ip) => ip.trim());
54
- return pickClientIpFromForwardedList(ipList);
55
- }
56
- if (req.socket?.remoteAddress) {
57
- return stripIpv4MappedPrefix(req.socket.remoteAddress);
58
- }
59
- return undefined;
60
- }