Skip to content

Commit 43936c7

Browse files
committed
Add HackMD API URL allowlist to prevent SSRF attacks
1 parent 35af63c commit 43936c7

File tree

3 files changed

+100
-48
lines changed

3 files changed

+100
-48
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ When using the STDIO transport or hosting the HTTP transport server, you can pas
6161
- `HACKMD_API_TOKEN`: HackMD API Token (Required for all operations)
6262
- `HACKMD_API_URL`: (Optional) HackMD API URL (Defaults to https://api.hackmd.io/v1)
6363

64+
Environment variables applied only for the HTTP transport server:
65+
- `ALLOWED_HACKMD_API_URLS`: (Optional) A comma-separated list of allowed HackMD API URLs. The server will reject requests if the provide HackMD API URL is not in this list. If not set, only the default URL (https://api.hackmd.io/v1) is allowed.
66+
6467
> [!CAUTION]
6568
> If you are hosting the HTTP transport server with token pre-configured, you should protect your endpoint and implement authentication before allowing users to access it. Otherwise, anyone can access your MCP server while using your HackMD token.
6669

env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ HACKMD_API_TOKEN=your_api_token
66
# HackMD API Endpoint URL (defaults to https://api.hackmd.io/v1)
77
# HACKMD_API_URL=https://api.hackmd.io/v1
88

9+
## -----------------------------------------------------
10+
## Optional settings for Streamable HTTP transport mode
11+
## -----------------------------------------------------
12+
13+
# Allowed HackMD API URLs (comma-separated list for security)
14+
# If not set, defaults to the official HackMD API URL
15+
# ALLOWED_HACKMD_API_URLS=https://api.hackmd.io/v1,https://your-hackmd-instance.com/api/v1
16+
917
# Use TRANSPORT=http for Streamable HTTP transport mode
1018
# TRANSPORT=http
1119

index.ts

Lines changed: 89 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,40 @@ app.use(
4141

4242
app.use(express.json());
4343

44+
// Helper function to create JSON-RPC error responses
45+
function createJsonRpcError(code: number, message: string) {
46+
return {
47+
jsonrpc: "2.0" as const,
48+
error: { code, message },
49+
id: null,
50+
};
51+
}
52+
53+
// Get allowed HackMD API URLs from environment
54+
function getAllowedApiUrls(): string[] {
55+
const allowedUrls = process.env.ALLOWED_HACKMD_API_URLS;
56+
if (!allowedUrls || allowedUrls.trim().length === 0) {
57+
return [DEFAULT_HACKMD_API_URL];
58+
}
59+
return allowedUrls
60+
.split(",")
61+
.map((url) => url.trim())
62+
.filter((url) => url.length > 0);
63+
}
64+
65+
// Validate if the provided API URL is allowed
66+
function isAllowedApiUrl(url: string): boolean {
67+
const allowedUrls = getAllowedApiUrls();
68+
return allowedUrls.includes(url);
69+
}
70+
4471
// Parse configuration from header or query parameters (for Smithery)
45-
function parseConfig(req: Request) {
72+
function parseConfig(req: Request): { config?: any; error?: any } {
4673
const hackmdApiTokenHeader =
4774
req.headers[HACKMD_API_TOKEN_HEADER.toLowerCase()];
4875
const hackmdApiUrlHeader = req.headers[HACKMD_API_URL_HEADER.toLowerCase()];
4976

50-
let config: any = {};
77+
const config: any = {};
5178

5279
if (
5380
typeof hackmdApiTokenHeader === "string" &&
@@ -65,20 +92,30 @@ function parseConfig(req: Request) {
6592

6693
// If any config found in headers, return it
6794
if (Object.keys(config).length > 0) {
68-
return config;
95+
return { config };
6996
}
7097

7198
// Smithery passes config as base64-encoded JSON in query parameters
7299
const configParam = req.query.config;
73100
if (typeof configParam === "string" && configParam.trim().length > 0) {
74-
const smitheryConfig = JSON.parse(
75-
Buffer.from(configParam, "base64").toString(),
76-
);
77-
return smitheryConfig;
101+
try {
102+
const smitheryConfig = JSON.parse(
103+
Buffer.from(configParam, "base64").toString(),
104+
);
105+
106+
return { config: smitheryConfig };
107+
} catch (error) {
108+
return {
109+
error: createJsonRpcError(
110+
-32000,
111+
"Bad Request: Invalid base64-encoded config parameter",
112+
),
113+
};
114+
}
78115
}
79116

80117
// Return empty config if nothing found
81-
return config;
118+
return { config };
82119
}
83120

84121
// Create MCP server with HackMD integration
@@ -100,31 +137,53 @@ function createServer({ config }: { config: z.infer<typeof ConfigSchema> }) {
100137
// Handle MCP requests at /mcp endpoint
101138
app.post("/mcp", async (req: Request, res: Response) => {
102139
try {
103-
// Parse configuration
104-
const rawConfig = parseConfig(req);
140+
// Parse configuration with URL validation
141+
const parseResult = parseConfig(req);
142+
143+
// Check for parsing errors
144+
if (parseResult.error) {
145+
return res.status(400).json(parseResult.error);
146+
}
147+
148+
const rawConfig = parseResult.config || {};
105149

106150
// Check if API token is available (from header, query param, or env var)
107151
const hackmdApiToken =
108152
rawConfig.hackmdApiToken || process.env.HACKMD_API_TOKEN;
109153

110154
if (!hackmdApiToken || hackmdApiToken.trim().length === 0) {
111-
return res.status(400).json({
112-
jsonrpc: "2.0",
113-
error: {
114-
code: -32000,
115-
message: `Bad Request: Please provide a HackMD API token via header '${HACKMD_API_TOKEN_HEADER}'.`,
116-
},
117-
id: null,
118-
});
155+
return res
156+
.status(400)
157+
.json(
158+
createJsonRpcError(
159+
-32000,
160+
`Bad Request: Please provide a HackMD API token via header '${HACKMD_API_TOKEN_HEADER}'.`,
161+
),
162+
);
163+
}
164+
165+
// Extract API URL from config or use default
166+
const hackmdApiUrl =
167+
rawConfig.hackmdApiUrl ||
168+
process.env.HACKMD_API_URL ||
169+
DEFAULT_HACKMD_API_URL;
170+
171+
// Validation of the API URL
172+
if (!isAllowedApiUrl(hackmdApiUrl)) {
173+
return res
174+
.status(400)
175+
.json(
176+
createJsonRpcError(
177+
-32000,
178+
`Bad Request: HackMD API URL "${hackmdApiUrl}" is not in the allowed list`,
179+
),
180+
);
119181
}
120182

121-
// Validate and parse configuration with fallbacks to environment variables
183+
// Validate and parse configuration
122184
const config = ConfigSchema.parse({
123185
hackmdApiToken,
124-
hackmdApiUrl:
125-
rawConfig.hackmdApiUrl ||
126-
process.env.HACKMD_API_URL ||
127-
DEFAULT_HACKMD_API_URL,
186+
hackmdApiUrl,
128187
});
129188

130189
const server = createServer({ config });
@@ -143,41 +202,23 @@ app.post("/mcp", async (req: Request, res: Response) => {
143202
} catch (error) {
144203
console.error("Error handling MCP request:", error);
145204
if (!res.headersSent) {
146-
res.status(500).json({
147-
jsonrpc: "2.0",
148-
error: { code: -32603, message: "Internal server error" },
149-
id: null,
150-
});
205+
res.status(500).json(createJsonRpcError(-32603, "Internal server error"));
151206
}
152207
}
153208
});
154209

155210
// SSE notifications not supported in stateless mode
156211
app.get("/mcp", async (req: Request, res: Response) => {
157-
res.writeHead(405).end(
158-
JSON.stringify({
159-
jsonrpc: "2.0",
160-
error: {
161-
code: -32000,
162-
message: "Method not allowed.",
163-
},
164-
id: null,
165-
}),
166-
);
212+
res
213+
.writeHead(405)
214+
.end(JSON.stringify(createJsonRpcError(-32000, "Method not allowed.")));
167215
});
168216

169217
// Session termination not needed in stateless mode
170218
app.delete("/mcp", async (req: Request, res: Response) => {
171-
res.writeHead(405).end(
172-
JSON.stringify({
173-
jsonrpc: "2.0",
174-
error: {
175-
code: -32000,
176-
message: "Method not allowed.",
177-
},
178-
id: null,
179-
}),
180-
);
219+
res
220+
.writeHead(405)
221+
.end(JSON.stringify(createJsonRpcError(-32000, "Method not allowed.")));
181222
});
182223

183224
// Main function to start the server in the appropriate mode

0 commit comments

Comments
 (0)