diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index d489b3249..b13569294 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -232,7 +232,38 @@ function isApiPostPathAllowed(path: string, allowlist: readonly string[]): boole return false; } -async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown): Promise<{ ok: boolean; status: number; data: unknown }> { +interface PluginHttpPostOptions { + headers?: Record; +} + +/** + * Namespace a plugin must use for its own upload metadata headers. Anything + * outside it (and `Content-Type`) is refused, so a plugin can never reach the + * credential headers the host attaches to the request. + */ +const PLUGIN_HEADER_PREFIX = 'x-plugin-'; + +function applyPluginUploadHeaders( + provided: Record | undefined, + target: Record, +): void { + for (const [name, value] of Object.entries(provided ?? {})) { + const lower = name.toLowerCase(); + if (lower !== 'content-type' && !lower.startsWith(PLUGIN_HEADER_PREFIX)) { + throw new Error( + `Header ${name} is not allowed on a binary plugin upload (use Content-Type or an X-Plugin-* header)`, + ); + } + target[name] = String(value); + } +} + +async function doHttpPost( + plugin: InstalledPlugin, + path: string, + body: unknown, + options?: PluginHttpPostOptions, +): Promise<{ ok: boolean; status: number; data: unknown }> { if (typeof path !== 'string' || !path.startsWith('/api/')) { throw new Error('path must start with /api/'); } @@ -250,7 +281,25 @@ async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown): throw new Error(`Path ${url.pathname} not in plugin apiPostPaths allowlist`); } const { client } = useAuthStore.getState(); - const headers: Record = { 'Content-Type': 'application/json' }; + const headers: Record = {}; + let requestBody: BodyInit; + + if (body instanceof Blob) { + // Binary upload: the plugin owns Content-Type and any X-Plugin-* metadata + // the receiving route needs. Stock behaviour for every other body type is + // unchanged - it is still serialized as JSON. + applyPluginUploadHeaders(options?.headers, headers); + const hasContentType = Object.keys(headers).some(h => h.toLowerCase() === 'content-type'); + if (!hasContentType && body.type) { + headers['Content-Type'] = body.type; + } + requestBody = body; + } else { + headers['Content-Type'] = 'application/json'; + requestBody = JSON.stringify(body); + } + + // Applied last so plugin-supplied headers can never override credentials. if (client) { headers['Authorization'] = client.getAuthHeader(); headers['X-JMAP-Username'] = client.getUsername(); @@ -258,7 +307,7 @@ async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown): const res = await apiFetch(url.pathname + url.search, { method: 'POST', headers, - body: JSON.stringify(body), + body: requestBody, }); const data = await res.json().catch(() => null); return { ok: res.ok, status: res.status, data }; @@ -795,7 +844,7 @@ export async function dispatchApiCall( case 'toast.info': appToast.info(String(args[0] ?? '')); return undefined; case 'toast.warning': appToast.warning(String(args[0] ?? '')); return undefined; - case 'http.post': return doHttpPost(plugin, args[0] as string, args[1]); + case 'http.post': return doHttpPost(plugin, args[0] as string, args[1], args[2] as PluginHttpPostOptions | undefined); case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined); case 'jmap.fetchBlob': return doJmapFetchBlob(args[0] as string, args[1] as { name?: string; type?: string } | undefined); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index bb8c2b3e1..9b57b2284 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -187,7 +187,11 @@ function buildPluginApi(manifest: PluginManifest) { logout: () => callApi('user.logout', []), }, http: { - post: (path: string, body: Record) => callApi('http.post', [path, body]), + // A Blob/File body is sent as a binary request; options.headers may then + // carry Content-Type and X-Plugin-* metadata for the receiving route. + // Any other body keeps the stock JSON behaviour. + post: (path: string, body: Record | Blob, options?: { headers?: Record }) => + callApi('http.post', [path, body, options]), fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init]), }, // Privileged-tier only (same-origin plugins). Calls throw for untrusted @@ -225,6 +229,7 @@ function buildPluginApi(manifest: PluginManifest) { /** * Used to alterate files before they are uploaded to server. * Edited files are saved on indexedDB and remove once the upload to server begins. + * `get` needs the email:blob-read permission, `save` needs email:blob-write. */ upfiles: { save: (formerFileId:string, file:File) =>