diff --git a/plugins/sdk/dev-host/README.md b/plugins/sdk/dev-host/README.md index 1376dada80..8e97cbe555 100644 --- a/plugins/sdk/dev-host/README.md +++ b/plugins/sdk/dev-host/README.md @@ -69,6 +69,7 @@ Field names come from the plugin manifest, not the runtime. Imported records get - Host API 1.0 subset: `ready`, `contributionId` (readable after `ready`), `context`, `locale`, `theme`, `request`, `invoke`, `notify`, `onInit`, `onContext`, `onEvent`, `onBinary`, `sendBinary`, resource reads and `openWorkbench`. - Backend transports: default `stdio-jsonl` and explicit `stdio-framed`, protocol version 1. Initialization verifies plugin identity and version. Binary channels require framed transport. - Permissions: event, binary and workbench navigation permissions are enforced. Unimplemented methods, such as `host.openFilesystem`, return errors. Native connection actions, query-result contributions and the DBX component kit are not emulated. +- UI entrypoints and individual asset files: 32 MiB, matching the installed DBX host. Larger files and directories are rejected; path and symlink isolation remains enforced. - JSON bridge parameters: 2 MiB. UI binary messages: 8 MiB. Sidecar JSON: 8 MiB. Sidecar binary: 64 MiB. Explicit bridge timeouts are clamped to 1–120000 ms, matching the host baseline. - UI resources must be inline or loaded through `readAssetUrl`; relative module URLs are not a replacement for the declared asset bridge. The iframe uses `sandbox="allow-scripts"`, restrictive CSP and pure-data context snapshots. Direct networking is not enabled by the development runtime. diff --git a/plugins/sdk/dev-host/assets.mjs b/plugins/sdk/dev-host/assets.mjs index 19b05eebda..d6407dc1e8 100644 --- a/plugins/sdk/dev-host/assets.mjs +++ b/plugins/sdk/dev-host/assets.mjs @@ -1,5 +1,9 @@ import { readFile, realpath, stat } from "node:fs/promises"; import { resolve, relative, isAbsolute, extname } from "node:path"; +// Keep UI entrypoints and assets within the same bound as the installed host's +// MAX_PLUGIN_UI_ASSET_BYTES (crates/dbx-plugin-runtime/src/plugins/assets.rs). +const MAX_PLUGIN_UI_ASSET_BYTES = 32 * 1024 * 1024; + export async function readAsset(root, path) { if (typeof path !== "string" || !path || path.includes("\0") || isAbsolute(path)) throw new Error("Invalid asset path"); const base = await realpath(root), @@ -7,7 +11,8 @@ export async function readAsset(root, path) { const rel = relative(base, file); if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Asset is outside UI root"); const info = await stat(file); - if (!info.isFile() || info.size > 8 * 1024 * 1024) throw new Error("Asset exceeds limit or is not a file"); + if (!info.isFile()) throw new Error("Asset is not a file"); + if (info.size > MAX_PLUGIN_UI_ASSET_BYTES) throw new Error(`Asset exceeds ${MAX_PLUGIN_UI_ASSET_BYTES} byte limit`); const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".woff2": "font/woff2" }; return { dataBase64: (await readFile(file)).toString("base64"), contentType: types[extname(file)] || "application/octet-stream" }; } diff --git a/plugins/sdk/dev-host/tests/server.test.mjs b/plugins/sdk/dev-host/tests/server.test.mjs index 7224774453..a93ed53934 100644 --- a/plugins/sdk/dev-host/tests/server.test.mjs +++ b/plugins/sdk/dev-host/tests/server.test.mjs @@ -168,10 +168,10 @@ test("refresh cleanup does not disconnect a connection still used by another pag assert.equal(closed.value.connections[0].connected, false); }); async function fixture(t, frontend = false, overrides = {}) { - const { pluginManifest = manifest, ...hostOverrides } = overrides; + const { pluginManifest = manifest, uiHtml = "
Test", ...hostOverrides } = overrides; const root = await mkdtemp(join(tmpdir(), "dbx-mock-test-")); await mkdir(join(root, "ui")); - await writeFile(join(root, "ui/index.html"), "Test"); + await writeFile(join(root, "ui/index.html"), uiHtml); const actual = frontend ? { ...pluginManifest, entrypoints: { ui: pluginManifest.entrypoints.ui }, contributions: pluginManifest.contributions.filter((c) => c.type === "workbench") } : pluginManifest; await writeFile(join(root, "manifest.json"), JSON.stringify(actual)); const host = await createMockHost({ @@ -439,6 +439,32 @@ test("loopback endpoint rejects cross-origin, forged Host, CSRF and unowned fram const boot = await other.json(); assert.equal((await request("frame-document", { frameId: f.id }, { ...headers, Cookie: other.headers.get("set-cookie").split(";")[0], "X-Mock-Csrf": boot.csrf })).status, 400); }); +test("development host opens a self-contained UI larger than 8 MiB (#11286)", async (t) => { + const payload = "x".repeat(12 * 1024 * 1024); + const uiHtml = `Complete UI
`; + const { request } = await fixture(t, true, { uiHtml }); + const opened = await request("workbenches/open", { contributionId: "example.main" }); + assert.equal(opened.status, 200); + const document = await request("frame-document", { frameId: opened.value.frame.id }); + assert.equal(document.status, 200); + assert.ok(document.value.html.includes(payload), "the UI payload must not be truncated"); + assert.ok(document.value.html.includes("Complete UI
")); +}); + +test("asset reader accepts 32 MiB and rejects larger files and directories", async (t) => { + const root = await mkdtemp(join(tmpdir(), "dbx-asset-size-")); + t.after(() => rm(root, { recursive: true, force: true })); + const bytes = Buffer.alloc(32 * 1024 * 1024, 0x61); + await writeFile(join(root, "at-limit.bin"), bytes); + const asset = await readAsset(root, "at-limit.bin"); + assert.equal(asset.contentType, "application/octet-stream"); + assert.ok(Buffer.from(asset.dataBase64, "base64").equals(bytes)); + await writeFile(join(root, "too-large.bin"), Buffer.alloc(bytes.length + 1)); + await assert.rejects(readAsset(root, "too-large.bin"), /Asset exceeds 33554432 byte limit/); + await mkdir(join(root, "directory")); + await assert.rejects(readAsset(root, "directory"), /Asset is not a file/); +}); + test("asset reader rejects traversal and symlinks outside the UI root", async (t) => { const { root } = await fixture(t); await writeFile(join(root, "secret"), "test-secret"); @@ -499,7 +525,7 @@ test("icon reads reject unsafe paths, missing files, non-images, symlink escapes await mkdir(join(root, "assets")); await writeFile(join(outside, "private.svg"), "private-icon-content"); await symlink(join(outside, "private.svg"), join(root, "assets/link.svg")); - await writeFile(join(root, "assets/large.svg"), Buffer.alloc(8 * 1024 * 1024 + 1)); + await writeFile(join(root, "assets/large.svg"), Buffer.alloc(32 * 1024 * 1024 + 1)); for (const contribution of pluginManifest.contributions) { const result = await request("icon", { contributionId: contribution.id }); assert.equal(result.status, 400, contribution.icon);