fix(plugin): align dev host asset limit with runtime

This commit is contained in:
zipg
2026-10-17 17:43:07 +08:00
committed by GitHub
parent ab92129752
commit d662c6a39e
3 changed files with 36 additions and 4 deletions
+1
View File
@@ -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.
+6 -1
View File
@@ -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" };
}
+29 -3
View File
@@ -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 = "<html><head></head><body>Test</body></html>", ...hostOverrides } = overrides;
const root = await mkdtemp(join(tmpdir(), "dbx-mock-test-"));
await mkdir(join(root, "ui"));
await writeFile(join(root, "ui/index.html"), "<html><head></head><body>Test</body></html>");
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 = `<html><head></head><body><script>/*${payload}*/</script><p>Complete UI</p></body></html>`;
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("<p>Complete UI</p>"));
});
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);