diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f2ede32 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,32 @@ +# Security Considerations + +The Cloudinary Account Provisioning MCP server handles **organization-level credentials** (`CLOUDINARY_PROVISIONING_API_KEY` and `CLOUDINARY_PROVISIONING_API_SECRET`) that can manage sub-accounts, users, roles, and access keys across your entire Cloudinary organization. Take the following precautions: + +## Network Binding + +By default, the HTTP transports (`serve` and `start --transport sse`) bind to **`127.0.0.1`** (localhost only). This prevents other devices on the network from connecting to the server. + +If you need to expose the server to the network (e.g., in a Docker container), pass `--host 0.0.0.0` explicitly. When doing so, ensure you have appropriate network-level access controls (firewall rules, VPN, etc.) in place. + +## CORS / Cross-Origin Requests + +The `serve` command does **not** set permissive CORS headers by default. If you need to allow specific web origins to connect (e.g., a browser-based MCP client), use the `--allowed-origins` flag: + +```sh +npx @cloudinary/account-provisioning-mcp serve --allowed-origins http://localhost:3000 +``` + +Without explicit allowed origins, cross-origin requests will be rejected by the browser's same-origin policy. + +## Secret Redaction + +Tool results from `access-keys-list` and `access-keys-generate` may contain `api_secret` fields in the Cloudinary API response. These secrets are **automatically redacted** before being returned to the LLM context, preventing credential exposure to the model and any downstream logging. + +To view the actual secret values, use the [Cloudinary Console](https://console.cloudinary.com/) directly. + +## Recommendations + +1. **Never expose this server to the public internet** without authentication. The MCP protocol does not include built-in authentication for HTTP transports. +2. **Use stdio transport** when running locally with a single MCP client (most secure — no network exposure at all). +3. **Rotate provisioning API credentials** if you suspect they may have been exposed. +4. **Use scopes** (`--scope`) to limit which tools are available, following the principle of least privilege. diff --git a/src/mcp-server/cli/serve/command.ts b/src/mcp-server/cli/serve/command.ts index 6a7c0d1..4155da9 100644 --- a/src/mcp-server/cli/serve/command.ts +++ b/src/mcp-server/cli/serve/command.ts @@ -24,6 +24,19 @@ export const serveCommand = buildCommand({ parse: (val: string) => z.coerce.number().int().gte(0).lt(65536).parse(val), }, + host: { + kind: "parsed", + brief: "The host/IP to bind to (default: 127.0.0.1). Use 0.0.0.0 to listen on all interfaces.", + default: "127.0.0.1", + parse: (value: string) => value, + }, + "allowed-origins": { + kind: "parsed", + brief: "Allowed CORS origins (repeatable). If omitted, no CORS headers are sent.", + optional: true, + variadic: true, + parse: (value: string) => value, + }, "disable-static-auth": { kind: "boolean", brief: diff --git a/src/mcp-server/cli/serve/impl.ts b/src/mcp-server/cli/serve/impl.ts index a9719be..4bf5518 100644 --- a/src/mcp-server/cli/serve/impl.ts +++ b/src/mcp-server/cli/serve/impl.ts @@ -20,7 +20,9 @@ import { landingPageExpress } from "../../../landing-page.js"; interface ServeCommandFlags extends MCPServerFlags { readonly port: number; + readonly host: string; readonly "disable-static-auth": boolean; + readonly "allowed-origins"?: string[]; readonly "log-level": ConsoleLoggerLevel; readonly env?: [string, string][]; } @@ -37,11 +39,29 @@ async function startStreamableHTTP(cliFlags: ServeCommandFlags) { const logger = createConsoleLogger(cliFlags["log-level"]); const app = express(); - // Enable CORS for cross-origin requests + const allowedOrigins = cliFlags["allowed-origins"]; + + // Origin validation middleware (replaces wildcard CORS) app.use((req, res, next) => { - res.header("Access-Control-Allow-Origin", "*"); - res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.header("Access-Control-Allow-Headers", "*"); + const origin = req.headers.origin; + + if (allowedOrigins && allowedOrigins.length > 0 && origin) { + if (allowedOrigins.includes(origin)) { + res.header("Access-Control-Allow-Origin", origin); + res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.header("Access-Control-Allow-Headers", "Content-Type, Accept, Mcp-Session-Id, Last-Event-Id"); + res.header("Vary", "Origin"); + } else { + // Origin not allowed — reject preflight, omit CORS headers on actual requests + if (req.method === "OPTIONS") { + res.sendStatus(403); + return; + } + } + } + // When no allowed-origins configured and binding to localhost, no CORS headers + // are needed (same-origin by default). This is the secure default. + if (req.method === "OPTIONS") { res.sendStatus(204); return; @@ -91,10 +111,16 @@ async function startStreamableHTTP(cliFlags: ServeCommandFlags) { app.get("/", landingPageExpress); - const httpServer = app.listen(cliFlags.port, "0.0.0.0", () => { + const httpServer = app.listen(cliFlags.port, cliFlags.host, () => { const ha = httpServer.address(); const host = typeof ha === "string" ? ha : `${ha?.address}:${ha?.port}`; logger.info("MCP Streamable HTTP server started", { host }); + if (cliFlags.host === "0.0.0.0") { + logger.warning( + "Server is listening on all interfaces (0.0.0.0). " + + "This exposes the server to the network. Use --host 127.0.0.1 for localhost-only access." + ); + } }); const shutdown = () => { diff --git a/src/mcp-server/cli/start/command.ts b/src/mcp-server/cli/start/command.ts index fffd9c4..b4399de 100644 --- a/src/mcp-server/cli/start/command.ts +++ b/src/mcp-server/cli/start/command.ts @@ -30,6 +30,12 @@ export const startCommand = buildCommand({ parse: (val: string) => z.coerce.number().int().gte(0).lt(65536).parse(val), }, + host: { + kind: "parsed", + brief: "The host/IP to bind to (default: 127.0.0.1). Use 0.0.0.0 to listen on all interfaces.", + default: "127.0.0.1", + parse: (value: string) => value, + }, tool: { kind: "parsed", brief: "Specify tools to mount on the server", diff --git a/src/mcp-server/cli/start/impl.ts b/src/mcp-server/cli/start/impl.ts index 0312967..aad6e2f 100644 --- a/src/mcp-server/cli/start/impl.ts +++ b/src/mcp-server/cli/start/impl.ts @@ -20,6 +20,7 @@ import { landingPageExpress } from "../../../landing-page.js"; interface StartCommandFlags extends MCPServerFlags { readonly transport: "stdio" | "sse"; readonly port: number; + readonly host: string; readonly "log-level": ConsoleLoggerLevel; readonly env?: [string, string][]; } @@ -188,10 +189,16 @@ async function startSSE(cliFlags: StartCommandFlags) { app.get("/", landingPageExpress); - const httpServer = app.listen(cliFlags.port, "0.0.0.0", () => { + const httpServer = app.listen(cliFlags.port, cliFlags.host, () => { const ha = httpServer.address(); const host = typeof ha === "string" ? ha : `${ha?.address}:${ha?.port}`; logger.info("MCP HTTP server started", { host }); + if (cliFlags.host === "0.0.0.0") { + logger.warning( + "Server is listening on all interfaces (0.0.0.0). " + + "This exposes the server to the network. Use --host 127.0.0.1 for localhost-only access." + ); + } }); let closing = false; diff --git a/src/mcp-server/tools.ts b/src/mcp-server/tools.ts index f0be0a3..1033c8c 100644 --- a/src/mcp-server/tools.ts +++ b/src/mcp-server/tools.ts @@ -123,12 +123,52 @@ export async function formatResult( : [{ type: "audio", data, mimeType: contentType }]; } else { const text = await response.text(); - content = [{ type: "text", text }]; + content = [{ type: "text", text: redactSecrets(text) }]; } return response.ok ? { content } : { content, isError: true }; } +/** + * Redact sensitive credential fields from JSON text before it enters the LLM + * context. This prevents API secrets from being exposed to the model (and + * potentially logged by LLM providers). + */ +function redactSecrets(text: string): string { + try { + const parsed = JSON.parse(text); + const redacted = redactObject(parsed); + return JSON.stringify(redacted, null, 2); + } catch { + // Not valid JSON — return as-is (non-JSON responses don't contain secrets) + return text; + } +} + +const SECRET_FIELD_PATTERN = /^(api_secret|secret|.*_secret)$/i; +const REDACTED_VALUE = "[REDACTED — use the Cloudinary dashboard to view secrets]"; + +function redactObject(obj: unknown): unknown { + if (obj === null || obj === undefined) { + return obj; + } + if (Array.isArray(obj)) { + return obj.map(redactObject); + } + if (typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + if (SECRET_FIELD_PATTERN.test(key) && typeof value === "string") { + result[key] = REDACTED_VALUE; + } else { + result[key] = redactObject(value); + } + } + return result; + } + return obj; +} + export function createRegisterTool( logger: ConsoleLogger, server: McpServer,