Skip to content

Commit 8ddb511

Browse files
Merge pull request #57 from IPGeolocation/release/v1.0.19-security-posture
Release v1.0.19 security posture hardening
2 parents 01506db + a02c543 commit 8ddb511

17 files changed

Lines changed: 1058 additions & 50 deletions

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@ RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
2222
COPY --from=build /app/dist ./dist
2323
COPY manifest.json server.json LICENSE README.md ./
2424

25-
ENTRYPOINT ["node", "/app/dist/index.js"]
25+
ENTRYPOINT ["node", "/app/dist/cli.js"]

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Works with Claude Desktop, Cursor, Windsurf, VS Code, Codex, Cline, Glama, and a
1616
| Item | Value |
1717
|------|-------|
1818
| Package | `ipgeolocation-io-mcp` |
19-
| Version | `1.0.17` |
19+
| Version | `1.0.19` |
2020
| Transport | `stdio` |
2121
| Node.js | `>=18` |
2222

@@ -605,21 +605,24 @@ The text answers below show what a client might say. Exact wording depends on th
605605

606606
## Error Codes
607607

608-
All tools return structured errors instead of crashing the server.
608+
All tools return structured errors instead of crashing the server. API errors include the upstream status/message plus a `guidance` field so MCP clients can tell the user what to check next instead of only repeating the upstream response.
609609

610610
| Code | Meaning |
611611
|------|---------|
612612
| `400` | Bad parameters, invalid date/time format, missing coordinate pair, or unsupported input |
613613
| `401` | Missing/invalid API key, free plan calling a paid tool, or non-English `lang` on free plan |
614614
| `404` | Resource not found (e.g., ASN does not exist) |
615615
| `405` | Method or subscription restriction from the upstream API |
616+
| `413` | POST body is larger than the upstream API allows |
617+
| `415` | POST request is missing the required `application/json` content type |
616618
| `423` | Bogon or private IP (`10.x.x.x`, `192.168.x.x`, etc.) |
617619
| `429` | Rate limit, daily credit limit, or account quota exceeded |
618-
| `499` | Upstream validation error or unsupported query |
620+
| `499` | Client-side request or connection timeout was too short |
621+
| `5xx` | Upstream API server-side error |
619622
| `502` | Server could not reach the upstream API |
620623
| `504` | Upstream API timed out |
621624

622-
Exact status codes can vary by endpoint and request mode. If an upstream endpoint returns a different error, the server passes it through with a structured message.
625+
Exact status codes can vary by endpoint and request mode. If an upstream endpoint returns a status outside this table, the server does not guess the cause. It passes the upstream status and message through with `category: "undocumented_api_error"` and adds guidance for the MCP client to explain the response as an undocumented upstream status without inventing a cause.
623626

624627
## How It Works
625628

@@ -640,6 +643,7 @@ At runtime:
640643
Tools that return stable data (not current-time lookups) cache their responses in process memory. Repeated lookups are faster and don't use additional credits. Client retries don't generate duplicate API calls.
641644

642645
- Process-level cache, not client or model memory
646+
- Cache entries are scoped by API key, so separate MCP sessions do not share cached upstream data
643647
- Default TTL: 5 minutes (`300000` ms)
644648
- Cache resets when the server process stops
645649
- Cache misses on TTL expiry, changed parameters, or `force_refresh: true`

SECURITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ If you discover a security vulnerability, please report it responsibly:
2323
- The API key is read from the `IPGEOLOCATION_API_KEY` environment variable or injected per-session via the MCP runtime config.
2424
- The key is never logged, cached to disk, or included in error messages.
2525
- The key is transmitted only to `https://api.ipgeolocation.io` over HTTPS.
26+
- In-memory response cache entries are scoped by an API-key hash so different runtime-config sessions do not share cached upstream data.
2627

2728
### Network Access
2829

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,5 +169,5 @@
169169
"required": true
170170
}
171171
},
172-
"version": "1.0.18"
172+
"version": "1.0.19"
173173
}

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@
2020
"scripts": {
2121
"build": "tsc && printf '%s\\n' '#!/usr/bin/env node' | cat - dist/cli.js > dist/cli.js.tmp && mv dist/cli.js.tmp dist/cli.js && chmod +x dist/cli.js",
2222
"prepare:mpak": "node scripts/prepare-mpak-package.mjs",
23-
"test": "npm run test:unit && npm run test:integration",
23+
"test": "npm run test:unit && npm run test:integration && npm run test:redteam",
2424
"test:unit": "npm run build && node --test --test-concurrency=1 tests/client.test.mjs tests/response.test.mjs tests/validation.test.mjs",
2525
"test:integration": "npm run build && node --test --test-concurrency=1 tests/tools.integration.test.mjs tests/mcp.protocol.test.mjs",
26+
"test:redteam": "npm run build && node --test --test-concurrency=1 tests/redteam.security.test.mjs",
2627
"test:live": "npm run test:live:free && npm run test:live:paid",
2728
"test:live:free": "npm run build && node tests/live.smoke.mjs free",
2829
"test:live:paid": "npm run build && node tests/live.smoke.mjs paid",
@@ -90,5 +91,5 @@
9091
"@types/node": "^22.0.0",
9192
"typescript": "^5.7.0"
9293
},
93-
"version": "1.0.18"
94+
"version": "1.0.19"
9495
}

server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"registryType": "npm",
3030
"registryBaseUrl": "https://registry.npmjs.org",
3131
"identifier": "ipgeolocation-io-mcp",
32-
"version": "1.0.18",
32+
"version": "1.0.19",
3333
"transport": {
3434
"type": "stdio"
3535
},
@@ -45,5 +45,5 @@
4545
]
4646
}
4747
],
48-
"version": "1.0.18"
48+
"version": "1.0.19"
4949
}

src/client.ts

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@ import {
22
getConfiguredApiKey,
33
getRequestTimeoutMs,
44
} from "./config.js";
5+
import { redactSensitiveText } from "./redaction.js";
56

67
const API_BASE = "https://api.ipgeolocation.io";
78
const MAX_UPSTREAM_ERROR_CHARS = 4000;
9+
const UPSTREAM_ERROR_MESSAGE_KEYS = [
10+
"message",
11+
"error",
12+
"detail",
13+
"description",
14+
];
815

916
export function getApiKey(): string {
1017
const apiKey = getConfiguredApiKey();
@@ -46,13 +53,65 @@ async function fetchWithTimeout(
4653

4754
throw new ApiError(
4855
502,
49-
`502: Failed to reach upstream API (${error instanceof Error ? error.message : String(error)})`
56+
`502: Failed to reach upstream API (${redactSensitiveText(
57+
error instanceof Error ? error.message : String(error)
58+
)})`
5059
);
5160
} finally {
5261
clearTimeout(timeout);
5362
}
5463
}
5564

65+
function parseUpstreamErrorMessage(text: string, fallback: string): string {
66+
if (!text) {
67+
return fallback;
68+
}
69+
70+
try {
71+
const parsed = JSON.parse(text) as unknown;
72+
if (typeof parsed === "string") {
73+
return parsed;
74+
}
75+
76+
if (typeof parsed === "object" && parsed !== null) {
77+
for (const key of UPSTREAM_ERROR_MESSAGE_KEYS) {
78+
const value = (parsed as Record<string, unknown>)[key];
79+
if (typeof value === "string" && value.trim()) {
80+
return value;
81+
}
82+
}
83+
}
84+
} catch {
85+
// Keep the raw body below when the upstream error is not JSON.
86+
}
87+
88+
return text;
89+
}
90+
91+
async function throwUpstreamResponseError(
92+
response: Response,
93+
fallback = response.statusText
94+
): Promise<never> {
95+
let message: string;
96+
try {
97+
const text = await response.text();
98+
message = parseUpstreamErrorMessage(text, fallback);
99+
} catch {
100+
message = fallback;
101+
}
102+
103+
message = redactSensitiveText(message);
104+
105+
if (message.length > MAX_UPSTREAM_ERROR_CHARS) {
106+
message = `${message.slice(
107+
0,
108+
MAX_UPSTREAM_ERROR_CHARS
109+
)}... [truncated upstream error body]`;
110+
}
111+
112+
throw new ApiError(response.status, `${response.status}: ${message}`);
113+
}
114+
56115
async function request(
57116
path: string,
58117
params: Record<string, string | undefined> = {},
@@ -88,22 +147,7 @@ async function request(
88147
const response = await fetchWithTimeout(url.toString(), fetchOptions);
89148

90149
if (!response.ok) {
91-
let message: string;
92-
try {
93-
const text = await response.text();
94-
message = text || response.statusText;
95-
} catch {
96-
message = response.statusText;
97-
}
98-
99-
if (message.length > MAX_UPSTREAM_ERROR_CHARS) {
100-
message = `${message.slice(
101-
0,
102-
MAX_UPSTREAM_ERROR_CHARS
103-
)}... [truncated upstream error body]`;
104-
}
105-
106-
throw new ApiError(response.status, `${response.status}: ${message}`);
150+
await throwUpstreamResponseError(response);
107151
}
108152

109153
try {
@@ -132,7 +176,10 @@ export async function getIpGeolocation(params: {
132176
export async function getMyIp(): Promise<string> {
133177
const response = await fetchWithTimeout(`${API_BASE}/v3/getip`);
134178
if (!response.ok) {
135-
throw new ApiError(response.status, "Failed to retrieve IP address");
179+
await throwUpstreamResponseError(
180+
response,
181+
"Failed to retrieve IP address"
182+
);
136183
}
137184
let data: { ip: string };
138185
try {

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const toolSelectionInstructionParts = [
3030
const TOOL_SELECTION_INSTRUCTIONS = toolSelectionInstructionParts.join(" ");
3131

3232
export const configSchema = z.object({
33-
apiKey: z.string().describe("Your IPGeolocation.io API key"),
33+
apiKey: z.string().min(1).describe("Your IPGeolocation.io API key"),
3434
});
3535

3636
type SessionConfig = z.infer<typeof configSchema>;
@@ -63,7 +63,7 @@ export function createMcpServer(
6363
): McpServer {
6464
const server = new McpServer({
6565
name: "ipgeolocation-io-mcp",
66-
version: "1.0.17",
66+
version: "1.0.19",
6767
}, {
6868
instructions: TOOL_SELECTION_INSTRUCTIONS,
6969
});

src/redaction.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { getConfiguredApiKey } from "./config.js";
2+
3+
const REDACTED_API_KEY = "[REDACTED_API_KEY]";
4+
const REDACTED_TOKEN = "[REDACTED_TOKEN]";
5+
6+
function escapeRegExp(value: string): string {
7+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8+
}
9+
10+
function uniqueSecretVariants(secret: string): string[] {
11+
const variants = new Set<string>();
12+
const trimmed = secret.trim();
13+
14+
if (!trimmed) {
15+
return [];
16+
}
17+
18+
variants.add(trimmed);
19+
variants.add(encodeURIComponent(trimmed));
20+
variants.add(encodeURIComponent(encodeURIComponent(trimmed)));
21+
22+
return [...variants].sort((a, b) => b.length - a.length);
23+
}
24+
25+
export function redactSensitiveText(text: string): string {
26+
let redacted = text;
27+
28+
const configuredApiKey = getConfiguredApiKey();
29+
if (configuredApiKey) {
30+
for (const variant of uniqueSecretVariants(configuredApiKey)) {
31+
redacted = redacted.replace(
32+
new RegExp(escapeRegExp(variant), "g"),
33+
REDACTED_API_KEY
34+
);
35+
}
36+
}
37+
38+
redacted = redacted
39+
.replace(
40+
/([?&](?:apiKey|apikey|api_key|api-key)=)[^&#\s"']+/gi,
41+
`$1${REDACTED_API_KEY}`
42+
)
43+
.replace(
44+
/((?:apiKey|apikey|api_key|api-key)%3D)(?:%[0-9A-Fa-f]{2}|[^&#\s"'])+/gi,
45+
`$1${REDACTED_API_KEY}`
46+
)
47+
.replace(
48+
/((?:"(?:apiKey|apikey|api_key|api-key)"|(?:apiKey|apikey|api_key|api-key))\s*[:=]\s*["']?)([^"',\s}]+)(["']?)/gi,
49+
`$1${REDACTED_API_KEY}$3`
50+
)
51+
.replace(
52+
/(authorization\s*[:=]\s*bearer\s+)[^\s,"']+/gi,
53+
`$1${REDACTED_TOKEN}`
54+
)
55+
.replace(/\bbearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED_TOKEN}`);
56+
57+
return redacted;
58+
}

0 commit comments

Comments
 (0)