From f0d87d594a25c2a2d4ffc0555f1cf9328ec6fcc8 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 29 May 2026 12:00:44 +0200 Subject: [PATCH] Fix: honour basePath in plugin sandbox, http.post proxy, and branding Upstream 1.7.2 prefixes most hand-written URLs with basePath via apiFetch / withBasePath, but four subpath-relevant spots were missed: - host-bridge: the sandbox iframe src was a bare "/plugin-sandbox" -> 404 under NEXT_PUBLIC_BASE_PATH, breaking all plugins. Wrap in withBasePath. - host-api doHttpPost: the same-origin /api/* plugin proxy used raw fetch on url.pathname -> 404 under a subpath. Route it through apiFetch. - admin branding preview : unprefixed src -> broken thumbnail. - (sandbox) layout: drop the Geist font + globals.css imports. The sandbox runs with an opaque origin, so those assets are CORS-blocked; the plugin bundle and all host API calls travel over the postMessage bridge, so no same-origin asset fetch happens there. --- app/(main)/admin/_tabs/branding.tsx | 6 +++--- app/(sandbox)/layout.tsx | 24 +++++++++--------------- lib/plugin-sandbox/host-api.ts | 2 +- lib/plugin-sandbox/host-bridge.ts | 6 +++++- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx index 09bd2ce7..99e2249a 100644 --- a/app/(main)/admin/_tabs/branding.tsx +++ b/app/(main)/admin/_tabs/branding.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react'; -import { apiFetch } from '@/lib/browser-navigation'; +import { apiFetch, withBasePath } from '@/lib/browser-navigation'; import { BRANDING_OVERRIDE_KEYS, parseDomainBranding, @@ -528,7 +528,7 @@ export function BrandingTab() {
{field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} @@ -606,7 +606,7 @@ export function BrandingTab() {
{field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} diff --git a/app/(sandbox)/layout.tsx b/app/(sandbox)/layout.tsx index 9813c79d..45dfe938 100644 --- a/app/(sandbox)/layout.tsx +++ b/app/(sandbox)/layout.tsx @@ -1,17 +1,14 @@ import type { Metadata } from 'next'; import type { ReactNode } from 'react'; -import { Geist, Geist_Mono } from 'next/font/google'; -import '../globals.css'; -const geistSans = Geist({ - variable: '--font-geist-sans', - subsets: ['latin'], -}); - -const geistMono = Geist_Mono({ - variable: '--font-geist-mono', - subsets: ['latin'], -}); +// The plugin sandbox iframe runs with an opaque origin (the `sandbox` +// attribute in production excludes `allow-same-origin` for isolation). Any +// asset request from this layout - bundled fonts, globals.css, etc. - is then +// cross-origin from the "null" origin to the host origin and gets blocked +// (fonts in particular require CORS). So this layout is intentionally minimal: +// no font imports, no CSS imports. Plugins ship their own styles, and both the +// plugin bundle and all host API calls travel over the postMessage RPC bridge, +// so the sandbox never fetches same-origin assets itself. export const metadata: Metadata = { title: 'Plugin sandbox', @@ -21,10 +18,7 @@ export const metadata: Metadata = { export default function PluginSandboxLayout({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 856650d0..168c65ac 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -140,7 +140,7 @@ async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown): headers['Authorization'] = client.getAuthHeader(); headers['X-JMAP-Username'] = client.getUsername(); } - const res = await fetch(url.pathname + url.search, { + const res = await apiFetch(url.pathname + url.search, { method: 'POST', headers, body: JSON.stringify(body), diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 90b0c6df..a0235ec8 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -10,6 +10,7 @@ import type { InstalledPlugin, SlotName } from '../plugin-types'; import { dispatchApiCall } from './host-api'; import { SANDBOX_PATH } from './protocol'; +import { withBasePath } from '../browser-navigation'; import type { SandboxToHost, HostToSandbox, InitMsg, InitPayload, } from './protocol'; @@ -143,7 +144,10 @@ export class SandboxInstance { this.iframe.style.width = '100%'; this.iframe.style.height = '0px'; } - this.iframe.src = SANDBOX_PATH; + // Prefix with the mount path so the sandbox route resolves under a + // subpath deployment (NEXT_PUBLIC_BASE_PATH=/webmail). A bare + // "/plugin-sandbox" would hit the origin root and 404, breaking plugins. + this.iframe.src = withBasePath(SANDBOX_PATH); this.listener = (ev) => this.onMessage(ev); window.addEventListener('message', this.listener);