Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions src/mcp-server/cli/serve/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
36 changes: 31 additions & 5 deletions src/mcp-server/cli/serve/impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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][];
}
Expand All @@ -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;
Expand Down Expand Up @@ -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 = () => {
Expand Down
6 changes: 6 additions & 0 deletions src/mcp-server/cli/start/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion src/mcp-server/cli/start/impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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][];
}
Expand Down Expand Up @@ -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;
Expand Down
42 changes: 41 additions & 1 deletion src/mcp-server/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
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,
Expand Down