From 42ace5c35dd9746c0549790b18db85e8ee071e87 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:35:09 +0200 Subject: [PATCH 1/2] feat(mcp): add media_upload tool for programmatic media management Adds a media_upload MCP tool that accepts base64-encoded file data or a public URL, runs the same pipeline as the REST upload route (global MIME allowlist, size limit, content-hash dedupe, storage upload, image metadata enrichment), and returns the media item ready to reference from content fields. URL fetches go through ssrfSafeFetch so redirects and private hosts are rejected. Closes #620, closes #1825. --- .changeset/mcp-media-upload.md | 5 + docs/src/content/docs/guides/ai-tools.mdx | 2 +- .../src/content/docs/reference/mcp-server.mdx | 27 ++- .../core/src/api/handlers/media-upload.ts | 201 +++++++++++++++++ packages/core/src/mcp/server.ts | 67 +++++- packages/core/src/utils/base64.ts | 9 +- .../unit/api/handlers/media-upload.test.ts | 212 ++++++++++++++++++ .../core/tests/unit/mcp/authorization.test.ts | 59 +++++ 8 files changed, 573 insertions(+), 9 deletions(-) create mode 100644 .changeset/mcp-media-upload.md create mode 100644 packages/core/src/api/handlers/media-upload.ts create mode 100644 packages/core/tests/unit/api/handlers/media-upload.test.ts diff --git a/.changeset/mcp-media-upload.md b/.changeset/mcp-media-upload.md new file mode 100644 index 0000000000..333642f8e3 --- /dev/null +++ b/.changeset/mcp-media-upload.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds a `media_upload` MCP tool that uploads a file from base64-encoded data or a public URL and registers it in the media library, so agent workflows can create media without dropping to the CLI or raw API. Uploads are deduplicated by content hash and respect the global MIME allowlist and maximum upload size. diff --git a/docs/src/content/docs/guides/ai-tools.mdx b/docs/src/content/docs/guides/ai-tools.mdx index eb177017c4..fbf9e970dd 100644 --- a/docs/src/content/docs/guides/ai-tools.mdx +++ b/docs/src/content/docs/guides/ai-tools.mdx @@ -131,7 +131,7 @@ Once connected, you can ask the AI assistant to perform any of these operations - **Inspect** -- "What's the current site title?" or "Show me the social links" - **Update identity** -- "Set the site title to 'Acme Blog' and tagline to 'Stories from the team'" -- **Set logo / favicon** -- "Use this image as the site logo" (after registering it with `media_create`) +- **Set logo / favicon** -- "Use this image as the site logo" (after uploading it with `media_upload`) - **SEO defaults** -- "Set the default OG image to the new banner" or "Update the title separator to a vertical bar" - **Social handles** -- "Add our Mastodon and YouTube links to the social settings" diff --git a/docs/src/content/docs/reference/mcp-server.mdx b/docs/src/content/docs/reference/mcp-server.mdx index f8963a0b8c..3171b13840 100644 --- a/docs/src/content/docs/reference/mcp-server.mdx +++ b/docs/src/content/docs/reference/mcp-server.mdx @@ -63,7 +63,8 @@ In addition to scopes, some tools require a minimum RBAC role. Both must be sati | Menus manage | Editor (40) | | Settings read | Editor (40) | | Settings manage | Admin (50) | -| Media upload (`media_create`) | Author (30) | +| Media upload (`media_upload`) | Contributor (20) | +| Media register (`media_create`) | Author (30) | See the [Authentication guide](/guides/authentication#user-roles) for role definitions. @@ -79,7 +80,7 @@ Responses follow the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) forma ## Tools -The server exposes 45 tools across eight domains: content, schema, media, search, taxonomies, menus, revisions, and settings. Each tool returns results as JSON text content, or an error message with `isError: true` on failure. +The server exposes 46 tools across eight domains: content, schema, media, search, taxonomies, menus, revisions, and settings. Each tool returns results as JSON text content, or an error message with `isError: true` on failure. ### Content Tools @@ -378,12 +379,32 @@ List uploaded media files with optional MIME type filtering and pagination. **Scope:** `media:read` | **Read-only:** Yes +#### `media_upload` + +Upload a media file from base64-encoded data or an external URL and register it in the media library. Returns the media item with `id`, `storageKey`, and `url` -- ready to reference from content fields (e.g. `featured_image`) via `content_create` / `content_update`. + +Uploads are deduplicated by content hash: re-uploading identical bytes returns the existing item with `deduplicated: true`. Image uploads are enriched automatically with dimensions, a blurhash placeholder, and the dominant color. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `filename` | `string` | Yes | Filename including extension (e.g. `cover.png`) | +| `base64` | `string` | One of `base64` / `url` | Base64-encoded file contents | +| `url` | `string` | One of `base64` / `url` | Public http(s) URL to fetch the file from | +| `contentType` | `string` | With `base64` | MIME type (e.g. `image/png`). With `url` it defaults to the response's `Content-Type` header. | +| `alt` | `string` | No | Alt text for accessibility | + + + +**Scope:** `media:write` | **Minimum role:** Contributor + #### `media_create` Register a media file that has already been uploaded to storage. The caller is responsible for placing the file at `storageKey` (typically using a signed upload URL from the admin UI or a separate API). This tool persists the metadata record so the file is discoverable via `media_list` / `media_get` and can be referenced by content. | Parameter | Type | Required | Description | diff --git a/packages/core/src/api/handlers/media-upload.ts b/packages/core/src/api/handlers/media-upload.ts new file mode 100644 index 0000000000..99c5a0e3c7 --- /dev/null +++ b/packages/core/src/api/handlers/media-upload.ts @@ -0,0 +1,201 @@ +/** + * Programmatic media upload handler (MCP `media_upload` tool). + * + * Accepts file bytes as base64 or fetches them from an external URL + * (SSRF-guarded), then runs the same pipeline as the multipart REST + * upload route: allowlist + size validation, content-hash deduplication, + * storage upload, image metadata enrichment, and record creation. + */ + +import * as path from "node:path"; + +import type { Kysely } from "kysely"; +import { ulid } from "ulidx"; + +import { MediaRepository, type MediaItem } from "../../database/repositories/media.js"; +import type { Database } from "../../database/types.js"; +import { enrichImageMetadata } from "../../media/enrich.js"; +import { matchesMimeAllowlist, normalizeMime } from "../../media/mime.js"; +import { SsrfError, ssrfSafeFetch } from "../../security/ssrf.js"; +import type { Storage } from "../../storage/types.js"; +import { decodeBase64Bytes } from "../../utils/base64.js"; +import { computeContentHash } from "../../utils/hash.js"; +import { DEFAULT_MAX_UPLOAD_SIZE, formatFileSize } from "../schemas/media.js"; +import type { ApiResult } from "../types.js"; +import { GLOBAL_UPLOAD_ALLOWLIST } from "./media-allowlist.js"; + +export interface MediaUploadInput { + /** Original filename (e.g. 'logo.png'); the extension is kept on the storage key. */ + filename: string; + /** Base64-encoded file contents. Exactly one of `base64` / `url` must be set. */ + base64?: string; + /** External http(s) URL to fetch the file from. Exactly one of `base64` / `url` must be set. */ + url?: string; + /** + * MIME type. Required with `base64`; optional with `url` (falls back to + * the response's Content-Type header). + */ + contentType?: string; + /** Alt text stored on the media record. */ + alt?: string; + authorId?: string; + /** Upload size limit in bytes (defaults to DEFAULT_MAX_UPLOAD_SIZE). */ + maxUploadSize?: number; +} + +export type MediaUploadResult = ApiResult<{ + item: MediaItem & { url: string }; + deduplicated?: boolean; +}>; + +function fail(code: string, message: string): MediaUploadResult { + return { success: false, error: { code, message } }; +} + +/** Same relative-URL shape the REST media routes return. */ +function withUrl(item: MediaItem): MediaItem & { url: string } { + return { ...item, url: `/_emdash/api/media/file/${item.storageKey}` }; +} + +/** Strip parameters from a Content-Type header value (e.g. '; charset=...'). */ +function bareMime(headerValue: string): string { + return (headerValue.split(";")[0] ?? "").trim(); +} + +/** + * Acquire the file bytes and MIME type from either the base64 payload or + * the external URL. Returns an error result on any validation failure. + */ +async function acquireBytes( + input: MediaUploadInput, + maxUploadSize: number, +): Promise<{ bytes: Uint8Array; mimeType: string } | MediaUploadResult> { + if (input.base64) { + if (!input.contentType) { + return fail("VALIDATION_ERROR", "contentType is required when uploading base64 data"); + } + // Cheap size precheck on the encoded string (decoded size is ~3/4 of + // the base64 length) before allocating the decoded buffer. + if ((input.base64.length * 3) / 4 > maxUploadSize) { + return fail( + "PAYLOAD_TOO_LARGE", + `File exceeds maximum size of ${formatFileSize(maxUploadSize)}`, + ); + } + try { + return { bytes: decodeBase64Bytes(input.base64), mimeType: input.contentType }; + } catch { + return fail("VALIDATION_ERROR", "Invalid base64 data"); + } + } + + // url mode — the caller guarantees exactly one source, so url is set here + const url = input.url; + if (!url) { + return fail("VALIDATION_ERROR", "Provide exactly one of 'base64' or 'url'"); + } + let response: Response; + try { + response = await ssrfSafeFetch(url, { headers: { accept: "*/*" } }); + } catch (error) { + if (error instanceof SsrfError) { + return fail("VALIDATION_ERROR", `URL not allowed: ${error.message}`); + } + return fail("FETCH_ERROR", "Failed to fetch file from URL"); + } + if (!response.ok) { + return fail("FETCH_ERROR", `Failed to fetch file from URL (HTTP ${response.status})`); + } + + const contentLength = response.headers.get("Content-Length"); + if (contentLength && parseInt(contentLength, 10) > maxUploadSize) { + return fail( + "PAYLOAD_TOO_LARGE", + `File exceeds maximum size of ${formatFileSize(maxUploadSize)}`, + ); + } + + const mimeType = input.contentType ?? bareMime(response.headers.get("Content-Type") ?? ""); + if (!mimeType) { + return fail("VALIDATION_ERROR", "Could not determine MIME type — pass contentType explicitly"); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + return { bytes, mimeType }; +} + +/** + * Upload a media file from base64 data or an external URL. + * + * Mirrors the REST `POST /_emdash/api/media` route: global MIME allowlist, + * size limit, content-hash dedupe (returns the existing item with + * `deduplicated: true`), storage upload with cleanup on failure, and + * image metadata enrichment (dimensions, blurhash, dominant color). + */ +export async function handleMediaUpload( + db: Kysely, + storage: Storage, + input: MediaUploadInput, +): Promise { + if (!input.base64 === !input.url) { + return fail("VALIDATION_ERROR", "Provide exactly one of 'base64' or 'url'"); + } + + const rawMax = input.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE; + if (!Number.isFinite(rawMax) || rawMax <= 0) { + return fail("CONFIGURATION_ERROR", "Invalid maxUploadSize configuration"); + } + + const acquired = await acquireBytes(input, rawMax); + if ("success" in acquired) return acquired; + const { bytes } = acquired; + const mimeType = normalizeMime(acquired.mimeType); + + if (!matchesMimeAllowlist(mimeType, GLOBAL_UPLOAD_ALLOWLIST)) { + return fail("INVALID_TYPE", "File type not allowed"); + } + if (bytes.byteLength > rawMax) { + return fail("PAYLOAD_TOO_LARGE", `File exceeds maximum size of ${formatFileSize(rawMax)}`); + } + + try { + const contentHash = await computeContentHash(bytes); + const repo = new MediaRepository(db); + + const existing = await repo.findByContentHash(contentHash); + if (existing) { + return { success: true, data: { item: withUrl(existing), deduplicated: true } }; + } + + const storageKey = `${ulid()}${path.extname(input.filename)}`; + await storage.upload({ key: storageKey, body: bytes, contentType: mimeType }); + + try { + const enriched = await enrichImageMetadata(bytes, mimeType); + const item = await repo.create({ + filename: input.filename, + mimeType, + size: bytes.byteLength, + width: enriched.width, + height: enriched.height, + alt: input.alt, + storageKey, + contentHash, + blurhash: enriched.blurhash, + dominantColor: enriched.dominantColor, + authorId: input.authorId, + }); + return { success: true, data: { item: withUrl(item) } }; + } catch (error) { + // Don't leave an orphaned object in storage when record creation fails + try { + await storage.delete(storageKey); + } catch { + // Ignore cleanup errors + } + throw error; + } + } catch { + return fail("UPLOAD_ERROR", "Upload failed"); + } +} diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index 9dd2ca9965..cb63691540 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -1794,9 +1794,8 @@ export function createMcpServer(): McpServer { "caller is responsible for placing the file at `storageKey` (typically " + "using a signed upload URL obtained from the admin UI or a separate API). " + "This tool persists the metadata record so the file is discoverable via " + - "media_list / media_get and can be referenced by content. For binary " + - "uploads the MCP transport is not appropriate — use the signed-upload " + - "flow instead.", + "media_list / media_get and can be referenced by content. To upload the " + + "file itself, use media_upload (base64 data or a public URL) instead.", inputSchema: z.object({ filename: z.string().describe("Original filename (e.g. 'logo.png')"), mimeType: z.string().describe("MIME type (e.g. 'image/png')"), @@ -1833,6 +1832,68 @@ export function createMcpServer(): McpServer { }, ); + server.registerTool( + "media_upload", + { + title: "Upload Media", + description: + "Upload a media file from base64-encoded data or an external URL and " + + "register it in the media library. Returns the media item with id, " + + "storageKey, and url — ready to reference from content fields (e.g. " + + "featured_image) via content_create / content_update. Uploads are " + + "deduplicated by content hash: re-uploading identical bytes returns " + + "the existing item with deduplicated: true. URL fetches must resolve " + + "to a public http(s) host (SSRF-guarded). Subject to the global " + + "upload MIME allowlist and the configured maximum upload size.", + inputSchema: z.object({ + filename: z.string().min(1).describe("Filename including extension (e.g. 'cover.png')"), + base64: z + .string() + .optional() + .describe("Base64-encoded file contents. Provide exactly one of base64 / url."), + url: z + .string() + .optional() + .describe( + "Public http(s) URL to fetch the file from. Provide exactly one of base64 / url.", + ), + contentType: z + .string() + .optional() + .describe( + "MIME type (e.g. 'image/png'). Required with base64; with url it " + + "defaults to the response's Content-Type header.", + ), + alt: z.string().optional().describe("Alt text for accessibility"), + }), + annotations: { destructiveHint: false }, + }, + async (args, extra) => { + requireScope(extra, "media:write"); + requireRole(extra, Role.CONTRIBUTOR); + const { emdash, userId } = getExtra(extra); + if (!emdash.storage) { + return respondError("NO_STORAGE", "Storage not configured"); + } + try { + const { handleMediaUpload } = await import("../api/handlers/media-upload.js"); + return unwrap( + await handleMediaUpload(emdash.db, emdash.storage, { + filename: args.filename, + base64: args.base64, + url: args.url, + contentType: args.contentType, + alt: args.alt, + authorId: userId, + maxUploadSize: emdash.config.maxUploadSize, + }), + ); + } catch (error) { + return respondHandlerError(error, "UPLOAD_ERROR"); + } + }, + ); + server.registerTool( "media_get", { diff --git a/packages/core/src/utils/base64.ts b/packages/core/src/utils/base64.ts index 0ea8c7a1f1..9dae6dade2 100644 --- a/packages/core/src/utils/base64.ts +++ b/packages/core/src/utils/base64.ts @@ -37,11 +37,16 @@ export function encodeBase64(str: string): string { /** Decode a standard base64 string to a UTF-8 string. */ export function decodeBase64(base64: string): string { - if (hasNative) return new TextDecoder().decode(Uint8Array.fromBase64(base64)); + return new TextDecoder().decode(decodeBase64Bytes(base64)); +} + +/** Decode a standard base64 string to raw bytes (for binary payloads). */ +export function decodeBase64Bytes(base64: string): Uint8Array { + if (hasNative) return Uint8Array.fromBase64(base64); const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new TextDecoder().decode(bytes); + return bytes; } // --------------------------------------------------------------------------- diff --git a/packages/core/tests/unit/api/handlers/media-upload.test.ts b/packages/core/tests/unit/api/handlers/media-upload.test.ts new file mode 100644 index 0000000000..a2bcbd9b10 --- /dev/null +++ b/packages/core/tests/unit/api/handlers/media-upload.test.ts @@ -0,0 +1,212 @@ +/** + * Tests for the programmatic media upload handler backing the + * `media_upload` MCP tool (#620). + * + * Covers base64 and URL modes, input validation, the global MIME + * allowlist, size limits, SSRF rejection, and content-hash dedupe. + */ + +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { handleMediaUpload } from "../../../../src/api/handlers/media-upload.js"; +import type { Database } from "../../../../src/database/types.js"; +import { setDefaultDnsResolver } from "../../../../src/security/ssrf.js"; +import type { Storage } from "../../../../src/storage/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../../utils/test-db.js"; + +// 1x1 transparent PNG +const PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; +const PNG_BYTES = Uint8Array.from(atob(PNG_BASE64), (c) => c.charCodeAt(0)); + +function createFakeStorage() { + const uploads = new Map(); + const storage = { + uploads, + async upload(options: { key: string; body: Uint8Array; contentType: string }) { + uploads.set(options.key, options.body); + return { key: options.key, url: `/${options.key}`, size: options.body.byteLength }; + }, + async download(): Promise { + throw new Error("not implemented"); + }, + async delete(key: string) { + uploads.delete(key); + }, + async exists(key: string) { + return uploads.has(key); + }, + async list() { + return { items: [] }; + }, + async getSignedUploadUrl(): Promise { + throw new Error("not implemented"); + }, + }; + // ponytail: structural stand-in covers the Storage surface this handler uses + return storage as unknown as Storage & { uploads: Map }; +} + +describe("handleMediaUpload (#620)", () => { + let db: Kysely; + let storage: ReturnType; + let previousResolver: ReturnType; + + beforeEach(async () => { + db = await setupTestDatabase(); + storage = createFakeStorage(); + // Resolve every hostname to a public IP so ssrfSafeFetch doesn't hit DNS + previousResolver = setDefaultDnsResolver(async () => ["93.184.216.34"]); + }); + + afterEach(async () => { + setDefaultDnsResolver(previousResolver ?? null); + vi.unstubAllGlobals(); + await teardownTestDatabase(db); + }); + + it("uploads base64 data and creates a media record", async () => { + const result = await handleMediaUpload(db, storage, { + filename: "pixel.png", + base64: PNG_BASE64, + contentType: "image/png", + alt: "a pixel", + authorId: "user_1", + }); + + expect(result.success).toBe(true); + if (!result.success) return; + const { item } = result.data; + expect(item.filename).toBe("pixel.png"); + expect(item.mimeType).toBe("image/png"); + expect(item.alt).toBe("a pixel"); + expect(item.authorId).toBe("user_1"); + expect(item.width).toBe(1); + expect(item.height).toBe(1); + expect(item.storageKey).toMatch(/\.png$/); + expect(item.url).toBe(`/_emdash/api/media/file/${item.storageKey}`); + expect(storage.uploads.get(item.storageKey)).toEqual(PNG_BYTES); + }); + + it("deduplicates identical bytes by content hash", async () => { + const first = await handleMediaUpload(db, storage, { + filename: "a.png", + base64: PNG_BASE64, + contentType: "image/png", + }); + const second = await handleMediaUpload(db, storage, { + filename: "b.png", + base64: PNG_BASE64, + contentType: "image/png", + }); + + expect(first.success && second.success).toBe(true); + if (!first.success || !second.success) return; + expect(second.data.deduplicated).toBe(true); + expect(second.data.item.id).toBe(first.data.item.id); + expect(storage.uploads.size).toBe(1); + }); + + it("rejects when neither or both of base64/url are provided", async () => { + const neither = await handleMediaUpload(db, storage, { filename: "x.png" }); + const both = await handleMediaUpload(db, storage, { + filename: "x.png", + base64: PNG_BASE64, + url: "https://example.com/x.png", + contentType: "image/png", + }); + for (const result of [neither, both]) { + expect(result.success).toBe(false); + if (!result.success) expect(result.error.code).toBe("VALIDATION_ERROR"); + } + }); + + it("requires contentType with base64 data", async () => { + const result = await handleMediaUpload(db, storage, { + filename: "x.png", + base64: PNG_BASE64, + }); + expect(result.success).toBe(false); + if (!result.success) expect(result.error.code).toBe("VALIDATION_ERROR"); + }); + + it("rejects invalid base64 data", async () => { + const result = await handleMediaUpload(db, storage, { + filename: "x.png", + base64: "!!!not-base64!!!", + contentType: "image/png", + }); + expect(result.success).toBe(false); + if (!result.success) expect(result.error.code).toBe("VALIDATION_ERROR"); + }); + + it("rejects MIME types outside the global allowlist", async () => { + const result = await handleMediaUpload(db, storage, { + filename: "evil.exe", + base64: PNG_BASE64, + contentType: "application/x-msdownload", + }); + expect(result.success).toBe(false); + if (!result.success) expect(result.error.code).toBe("INVALID_TYPE"); + expect(storage.uploads.size).toBe(0); + }); + + it("rejects payloads over the size limit", async () => { + const result = await handleMediaUpload(db, storage, { + filename: "big.png", + base64: PNG_BASE64, + contentType: "image/png", + maxUploadSize: 8, + }); + expect(result.success).toBe(false); + if (!result.success) expect(result.error.code).toBe("PAYLOAD_TOO_LARGE"); + }); + + it("fetches from a URL, using the response Content-Type", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(PNG_BYTES, { headers: { "Content-Type": "image/png" } })), + ); + + const result = await handleMediaUpload(db, storage, { + filename: "remote.png", + url: "https://example.com/remote.png", + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.item.mimeType).toBe("image/png"); + expect(storage.uploads.get(result.data.item.storageKey)).toEqual(PNG_BYTES); + }); + + it("surfaces HTTP errors from the remote host", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("nope", { status: 404 })), + ); + + const result = await handleMediaUpload(db, storage, { + filename: "missing.png", + url: "https://example.com/missing.png", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("FETCH_ERROR"); + expect(result.error.message).toContain("404"); + } + }); + + it("rejects URLs that resolve to private addresses (SSRF)", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + + const result = await handleMediaUpload(db, storage, { + filename: "metadata.json", + url: "http://169.254.169.254/latest/meta-data", + }); + expect(result.success).toBe(false); + if (!result.success) expect(result.error.code).toBe("VALIDATION_ERROR"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/tests/unit/mcp/authorization.test.ts b/packages/core/tests/unit/mcp/authorization.test.ts index 179864abad..294edc23af 100644 --- a/packages/core/tests/unit/mcp/authorization.test.ts +++ b/packages/core/tests/unit/mcp/authorization.test.ts @@ -628,6 +628,65 @@ describe("MCP Authorization", () => { }); }); + // ----------------------------------------------------------------------- + // media_upload guards + // ----------------------------------------------------------------------- + + describe("media_upload guards", () => { + const uploadArgs = { + filename: "x.png", + base64: "aGVsbG8=", + contentType: "image/png", + }; + + it("SUBSCRIBER cannot upload media", async () => { + const handlers = createMockHandlers(AUTHOR_USER_ID); + ({ client, cleanup } = await setupMcpPair({ + userId: OTHER_USER_ID, + userRole: Role.SUBSCRIBER, + handlers, + })); + + const result = await client.callTool({ name: "media_upload", arguments: uploadArgs }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ text: string }>)[0]?.text ?? ""; + expect(text).toMatch(INSUFFICIENT_PERMISSIONS_RE); + }); + + it("rejects media_upload without media:write scope", async () => { + const handlers = createMockHandlers(AUTHOR_USER_ID); + ({ client, cleanup } = await setupMcpPair({ + userId: AUTHOR_USER_ID, + userRole: Role.ADMIN, + handlers, + tokenScopes: ["media:read"], + })); + + const result = await client.callTool({ name: "media_upload", arguments: uploadArgs }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ text: string }>)[0]?.text ?? ""; + expect(text).toMatch(INSUFFICIENT_SCOPE_RE); + }); + + it("returns NO_STORAGE when storage is not configured", async () => { + // createMockHandlers has no storage adapter attached + const handlers = createMockHandlers(AUTHOR_USER_ID); + ({ client, cleanup } = await setupMcpPair({ + userId: AUTHOR_USER_ID, + userRole: Role.CONTRIBUTOR, + handlers, + })); + + const result = await client.callTool({ name: "media_upload", arguments: uploadArgs }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ text: string }>)[0]?.text ?? ""; + expect(text).toContain("NO_STORAGE"); + }); + }); + // ----------------------------------------------------------------------- // Token scope enforcement // ----------------------------------------------------------------------- From f91f8c72723daa517d7c7f3b9d937384229942bf Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:35:46 +0200 Subject: [PATCH 2/2] fix(mcp): validate MIME string before allowlist and storage upload The prefix-based allowlist check let a crafted contentType like 'image/png\r\nX-Evil: 1' through to the storage backend's ContentType header (echoed by the media file route). Validate against the existing CONTENT_TYPE_RE first, and tighten the MCP input schema (regex on contentType, .url() on url) so malformed input is rejected before any bytes are buffered or fetched. --- .../core/src/api/handlers/media-upload.ts | 11 ++++++- packages/core/src/api/schemas/media.ts | 2 +- packages/core/src/mcp/server.ts | 3 ++ .../unit/api/handlers/media-upload.test.ts | 33 +++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/core/src/api/handlers/media-upload.ts b/packages/core/src/api/handlers/media-upload.ts index 99c5a0e3c7..8d128eb300 100644 --- a/packages/core/src/api/handlers/media-upload.ts +++ b/packages/core/src/api/handlers/media-upload.ts @@ -20,7 +20,7 @@ import { SsrfError, ssrfSafeFetch } from "../../security/ssrf.js"; import type { Storage } from "../../storage/types.js"; import { decodeBase64Bytes } from "../../utils/base64.js"; import { computeContentHash } from "../../utils/hash.js"; -import { DEFAULT_MAX_UPLOAD_SIZE, formatFileSize } from "../schemas/media.js"; +import { CONTENT_TYPE_RE, DEFAULT_MAX_UPLOAD_SIZE, formatFileSize } from "../schemas/media.js"; import type { ApiResult } from "../types.js"; import { GLOBAL_UPLOAD_ALLOWLIST } from "./media-allowlist.js"; @@ -149,6 +149,15 @@ export async function handleMediaUpload( const acquired = await acquireBytes(input, rawMax); if ("success" in acquired) return acquired; const { bytes } = acquired; + + // Validate the raw MIME string before normalize/allowlist: normalizeMime + // only strips parameters and matchesMimeAllowlist only checks startsWith, + // so without this a crafted value like "image/png\r\nX-Evil: 1" would + // reach the storage backend's ContentType header and be echoed by the + // media file serving route. + if (!CONTENT_TYPE_RE.test(acquired.mimeType)) { + return fail("VALIDATION_ERROR", "Invalid content type"); + } const mimeType = normalizeMime(acquired.mimeType); if (!matchesMimeAllowlist(mimeType, GLOBAL_UPLOAD_ALLOWLIST)) { diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts index 3b2519e10c..5f22091b45 100644 --- a/packages/core/src/api/schemas/media.ts +++ b/packages/core/src/api/schemas/media.ts @@ -46,7 +46,7 @@ export function formatFileSize(bytes: number): string { // Matches a full MIME type (type/subtype) with an optional semicolon-delimited // parameter section. Forbids CR/LF to prevent header injection. -const CONTENT_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]+(\s*;[^\r\n]*)?$/i; +export const CONTENT_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]+(\s*;[^\r\n]*)?$/i; export function mediaUploadUrlBody(maxSize: number) { if (!Number.isFinite(maxSize) || maxSize <= 0) { diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index cb63691540..abc984fa68 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -17,6 +17,7 @@ import { z } from "zod"; import { bylineCreateBody, bylineUpdateBody, + CONTENT_TYPE_RE, contentBylineInputSchema, contentSeoInput, } from "#api/schemas.js"; @@ -1853,12 +1854,14 @@ export function createMcpServer(): McpServer { .describe("Base64-encoded file contents. Provide exactly one of base64 / url."), url: z .string() + .url() .optional() .describe( "Public http(s) URL to fetch the file from. Provide exactly one of base64 / url.", ), contentType: z .string() + .regex(CONTENT_TYPE_RE, "Invalid content type") .optional() .describe( "MIME type (e.g. 'image/png'). Required with base64; with url it " + diff --git a/packages/core/tests/unit/api/handlers/media-upload.test.ts b/packages/core/tests/unit/api/handlers/media-upload.test.ts index a2bcbd9b10..4bca1afd3e 100644 --- a/packages/core/tests/unit/api/handlers/media-upload.test.ts +++ b/packages/core/tests/unit/api/handlers/media-upload.test.ts @@ -141,6 +141,39 @@ describe("handleMediaUpload (#620)", () => { if (!result.success) expect(result.error.code).toBe("VALIDATION_ERROR"); }); + it("rejects malformed MIME strings that would pass the prefix allowlist", async () => { + // "image/png\r\nX-Evil: 1" starts with "image/" but must never reach + // the storage ContentType header or the file-serving response. + const crafted = await handleMediaUpload(db, storage, { + filename: "x.png", + base64: PNG_BASE64, + contentType: "image/png\r\nX-Evil: 1", + }); + expect(crafted.success).toBe(false); + if (!crafted.success) expect(crafted.error.code).toBe("VALIDATION_ERROR"); + expect(storage.uploads.size).toBe(0); + }); + + it("rejects a malformed Content-Type header from a remote host", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + const response = new Response(PNG_BYTES); + // Response normalizes header values, so inject the raw string + vi.spyOn(response.headers, "get").mockReturnValue("image/png\r\nX-Evil: 1"); + return response; + }), + ); + + const result = await handleMediaUpload(db, storage, { + filename: "remote.png", + url: "https://example.com/remote.png", + }); + expect(result.success).toBe(false); + if (!result.success) expect(result.error.code).toBe("VALIDATION_ERROR"); + expect(storage.uploads.size).toBe(0); + }); + it("rejects MIME types outside the global allowlist", async () => { const result = await handleMediaUpload(db, storage, { filename: "evil.exe",