feat(plugin): 支持经宿主确认打开声明站点的外部网址 (#14089)

* feat(plugin): open declared external URLs with host consent

* style(test): format saved SQL sync callback for CI
This commit is contained in:
CSXFanMeng
2026-11-02 23:47:32 +08:00
committed by GitHub
parent f2864af4b2
commit 81ff1dc148
28 changed files with 445 additions and 3 deletions
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, reactive, type App } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it, vi } from "vitest";
import PluginOpenUrlDialog from "./PluginOpenUrlDialog.vue";
const shell = vi.hoisted(() => ({ open: vi.fn(), native: false }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => shell.native }));
vi.mock("@tauri-apps/plugin-shell", () => ({ open: shell.open }));
let app: App;
let host: HTMLElement;
afterEach(() => {
app?.unmount();
host?.remove();
vi.clearAllMocks();
shell.native = false;
});
async function mount() {
const props = reactive({ pluginId: "bookmarks", pluginName: "Bookmarks", permissions: ["host.openUrl:https://example.com"] });
let dialog!: InstanceType<typeof PluginOpenUrlDialog>;
host = document.createElement("div");
document.body.append(host);
app = createApp({
render: () =>
h(PluginOpenUrlDialog, {
...props,
ref: (value) => {
dialog = value as typeof dialog;
},
}),
});
app.use(createI18n({ legacy: false, locale: "en", messages: { en: { pluginPlatform: { openUrlTitle: "Open external URL", openUrlPrompt: "Plugin {name} wants to open this URL." }, common: { cancel: "Cancel" }, grid: { openUrl: "Open in browser" } } } }));
app.mount(host);
await nextTick();
return { props, dialog };
}
describe("PluginOpenUrlDialog", () => {
it("shows host-owned identity and full destination; cancel and unmount never navigate", async () => {
const { dialog } = await mount();
const decision = dialog.request("https://example.com/path?q=visible");
await nextTick();
expect(document.body.textContent).toContain("Plugin Bookmarks");
expect(document.body.textContent).toContain("https://example.com/path?q=visible");
const link = document.querySelector<HTMLAnchorElement>('a[target="_blank"]')!;
expect(link.rel).toBe("noopener noreferrer");
expect(link.getAttribute("referrerpolicy")).toBe("no-referrer");
await expect(dialog.request("https://example.com/second")).rejects.toThrow("already awaiting");
[...document.querySelectorAll("button")].find((b) => b.textContent === "Cancel")!.click();
await expect(decision).resolves.toEqual({ opened: false });
expect(shell.open).not.toHaveBeenCalled();
const pending = dialog.request("https://example.com/again");
app.unmount();
await expect(pending).resolves.toEqual({ opened: false });
});
it("does not call the native shell until explicit approval and keeps failures retryable", async () => {
shell.native = true;
shell.open.mockRejectedValueOnce(new Error("Browser unavailable")).mockResolvedValueOnce(undefined);
const { dialog } = await mount();
const result = dialog.request("https://example.com/path");
await nextTick();
expect(shell.open).not.toHaveBeenCalled();
document.querySelector<HTMLAnchorElement>('a[target="_blank"]')!.click();
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toBe("Browser unavailable"));
document.querySelector<HTMLAnchorElement>('a[target="_blank"]')!.click();
await expect(result).resolves.toEqual({ opened: true });
expect(shell.open).toHaveBeenCalledTimes(2);
expect(shell.open).toHaveBeenLastCalledWith("https://example.com/path");
});
it("cancels pending consent when plugin identity or permissions change", async () => {
const { dialog, props } = await mount();
const result = dialog.request("https://example.com/path");
props.permissions = [];
await nextTick();
await expect(result).resolves.toEqual({ opened: false });
expect(() => dialog.request("https://example.com/path")).toThrow("not declared");
});
it("does not let a late native opening settle a subsequent request", async () => {
shell.native = true;
let finish!: () => void;
shell.open.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
const { dialog } = await mount();
const first = dialog.request("https://example.com/first");
await nextTick();
document.querySelector<HTMLAnchorElement>('a[target="_blank"]')!.click();
await vi.waitFor(() => expect(shell.open).toHaveBeenCalled());
dialog.cancel();
await expect(first).resolves.toEqual({ opened: false });
await expect(dialog.request("https://example.com/second")).rejects.toThrow("already awaiting");
finish();
await nextTick();
await nextTick();
const second = dialog.request("https://example.com/second");
await nextTick();
expect(document.body.textContent).toContain("https://example.com/second");
dialog.cancel();
await expect(second).resolves.toEqual({ opened: false });
});
});
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { ExternalLink } from "@lucide/vue";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { permittedPluginUrl } from "@/lib/plugins/pluginOpenUrl";
const props = defineProps<{ pluginId: string; pluginName: string; permissions?: readonly string[] }>();
const { t } = useI18n();
const url = ref("");
const open = ref(false);
const error = ref("");
const opening = ref(false);
let settle: ((result: { opened: boolean }) => void) | undefined;
function cancel() {
open.value = false;
const resolve = settle;
settle = undefined;
resolve?.({ opened: false });
}
function request(value: string): Promise<{ opened: boolean }> {
if (settle || opening.value) return Promise.reject(new Error("A plugin URL request is already awaiting consent or opening"));
url.value = permittedPluginUrl(value, props.permissions);
error.value = "";
open.value = true;
return new Promise((resolve) => {
settle = resolve;
});
}
async function approve(event: MouseEvent) {
const resolve = settle;
try {
const target = permittedPluginUrl(url.value, props.permissions);
if (isTauriRuntime()) {
event.preventDefault();
opening.value = true;
const { open } = await import("@tauri-apps/plugin-shell");
if (!resolve || settle !== resolve) return;
await open(permittedPluginUrl(target, props.permissions));
}
// Web navigation stays on this trusted anchor click, preserving activation.
if (!resolve || settle !== resolve) return;
settle = undefined;
open.value = false;
resolve?.({ opened: true });
} catch (cause) {
event.preventDefault();
if (settle === resolve) error.value = cause instanceof Error ? cause.message : String(cause);
} finally {
opening.value = false;
}
}
watch(() => JSON.stringify([props.pluginId, props.permissions]), cancel);
watch(open, (value) => {
if (!value) cancel();
});
onBeforeUnmount(cancel);
defineExpose({ request, cancel });
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="w-[calc(100vw-2rem)] max-w-lg">
<DialogHeader
><DialogTitle>{{ t("pluginPlatform.openUrlTitle") }}</DialogTitle
><DialogDescription class="break-words">{{ t("pluginPlatform.openUrlPrompt", { name: pluginName }) }}</DialogDescription></DialogHeader
>
<p class="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border bg-muted/30 p-3 font-mono text-sm" dir="ltr">{{ url }}</p>
<p v-if="error" class="break-words text-sm text-destructive" role="alert">{{ error }}</p>
<DialogFooter>
<Button variant="outline" :disabled="opening" @click="cancel">{{ t("common.cancel") }}</Button>
<Button as-child :disabled="opening"
><a :href="url" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" :aria-disabled="opening" @click="opening ? $event.preventDefault() : approve($event)"><ExternalLink class="h-4 w-4" />{{ t("grid.openUrl") }}</a></Button
>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { mysqlDataCharsetEnabled } from "@/lib/connection/mysqlDataCharset";
import PluginOpenUrlDialog from "./PluginOpenUrlDialog.vue";
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { AlertTriangle, Loader2 } from "@lucide/vue";
import * as api from "@/lib/backend/api";
@@ -59,6 +60,7 @@ const emit = defineEmits<{
const { t, locale: appLocale } = useI18n();
const { isDark, themeRevision } = useTheme();
const settingsStore = useSettingsStore();
const urlDialog = ref<InstanceType<typeof PluginOpenUrlDialog>>();
const openAiConversation = inject(OPEN_PLUGIN_AI_CONVERSATION, undefined);
// Per-plugin grant for `script-src 'unsafe-eval'`; flipping it rebuilds the
// sandbox document (and must not reuse a doc cached under the other setting).
@@ -685,6 +687,7 @@ function createBridge() {
saveFile: (_pluginId, request, data) => savePluginFile(request, data),
downloadFile: isTauriRuntime() ? downloadPluginFile : undefined,
cancelDownload: isTauriRuntime() ? cancelPluginDownload : undefined,
openUrl: (url) => (urlDialog.value ? urlDialog.value.request(url) : Promise.reject(new Error("Host URL consent is unavailable"))),
copyText: (_pluginId, text) => copyToClipboard(text),
// Permission-gated in the bridge (host.clipboard:read); the helper
// prefers the Tauri clipboard plugin and falls back to the Web Clipboard.
@@ -786,6 +789,7 @@ async function loadWorkbench() {
// An identity rebuild swaps the plugin this component serves; retire the
// previous plugin's handles first or they would outlive their owner.
disposeLocalFileHandles();
urlDialog.value?.cancel();
bridge?.dispose();
bridge = undefined;
loading.value = true;
@@ -931,6 +935,7 @@ onBeforeUnmount(() => {
<template>
<div class="relative flex size-full overflow-hidden" :class="hostSurfaceClass">
<PluginOpenUrlDialog ref="urlDialog" :plugin-id="plugin.manifest.id" :plugin-name="plugin.manifest.name" :permissions="plugin.manifest.permissions" />
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center text-sm text-muted-foreground" :class="overlayClass">
<Loader2 class="mr-2 size-4 animate-spin" />
{{ t("pluginPlatform.loadingTitle", { title }) }}
+2
View File
@@ -82,6 +82,8 @@ export default withEnglishFallback({
sidebarMenu,
oracleIndex: { mixedDraft: "Oracle indeks dəyişikliklərini digər cədvəl quruluşu dəyişikliklərindən ayrıca tətbiq edin." },
pluginPlatform: {
openUrlTitle: "Xarici URL aç",
openUrlPrompt: "{name} plagini bu URL-ni brauzerinizdə açmaq istəyir.",
dataAccessConsent: '"{name}" "{connection}" bağlantısındakı məlumatları oxumaq istəyir. Bu bağlantı açıq olduqca plagin, Plagin Mərkəzində girişi ləğv edənə qədər orada yalnız oxuma sorğuları icra edə bilər. İcazə verilsin?',
dataAccessConsentTitle: "Məlumat girişi sorğusu",
mcpToolsBadge: "MCP alətləri",
+2
View File
@@ -805,6 +805,8 @@ export default {
docs,
dataDictionary,
pluginPlatform: {
openUrlTitle: "Open external URL",
openUrlPrompt: "Plugin {name} wants to open this URL in your browser.",
dataAccessConsent: '"{name}" wants to read data from the connection "{connection}". While that connection is open, the plugin can run read-only queries on it until you revoke access in the Plugin Center. Allow?',
dataAccessConsentTitle: "Data Access Request",
mcpToolsBadge: "MCP tools",
+2
View File
@@ -610,6 +610,8 @@ export default withEnglishFallback({
docs,
dataDictionary,
pluginPlatform: {
openUrlTitle: "Abrir URL externa",
openUrlPrompt: "El complemento {name} quiere abrir esta URL en tu navegador.",
dataAccessConsent: '"{name}" quiere leer datos de la conexión "{connection}". Mientras esa conexión esté abierta, el plugin podrá ejecutar consultas de solo lectura en ella hasta que revoques el acceso en el Centro de plugins. ¿Permitir?',
dataAccessConsentTitle: "Solicitud de acceso a datos",
mcpToolsBadge: "Herramientas MCP",
+2
View File
@@ -121,6 +121,8 @@ export default withEnglishFallback({
docs,
dataDictionary,
pluginPlatform: {
openUrlTitle: "Buka URL eksternal",
openUrlPrompt: "Plugin {name} ingin membuka URL ini di browser Anda.",
dataAccessConsent: '"{name}" ingin membaca data dari koneksi "{connection}". Selama koneksi tersebut terbuka, plugin dapat menjalankan kueri read-only pada koneksi itu hingga Anda mencabut aksesnya di Plugin Center. Izinkan?',
dataAccessConsentTitle: "Permintaan Akses Data",
mcpToolsBadge: "Alat MCP",
+2
View File
@@ -607,6 +607,8 @@ export default withEnglishFallback({
docs,
dataDictionary,
pluginPlatform: {
openUrlTitle: "Apri URL esterno",
openUrlPrompt: "Il plugin {name} vuole aprire questo URL nel browser.",
dataAccessConsent: '"{name}" vuole leggere i dati della connessione "{connection}". Finché la connessione è aperta, il plugin potrà eseguire query di sola lettura su di essa finché non revochi l\'accesso nel Centro plugin. Consentire?',
dataAccessConsentTitle: "Richiesta di accesso ai dati",
mcpToolsBadge: "Strumenti MCP",
+2
View File
@@ -608,6 +608,8 @@ export default withEnglishFallback({
docs,
dataDictionary,
pluginPlatform: {
openUrlTitle: "外部 URL を開く",
openUrlPrompt: "プラグイン {name} が次の URL をブラウザーで開こうとしています。",
dataAccessConsent: "「{name}」が接続「{connection}」のデータの読み取りを要求しています。許可すると、プラグインセンターで取り消すまで、この接続が開いている間はプラグインが読み取り専用クエリを実行できます。許可しますか?",
dataAccessConsentTitle: "データアクセスの要求",
mcpToolsBadge: "MCP ツール",
+2
View File
@@ -606,6 +606,8 @@ export default withEnglishFallback({
docs,
dataDictionary,
pluginPlatform: {
openUrlTitle: "외부 URL 열기",
openUrlPrompt: "플러그인 {name}이 다음 URL을 브라우저에서 열려고 합니다.",
dataAccessConsent: '"{name}" 플러그인이 "{connection}" 연결의 데이터 읽기를 요청합니다. 허용하면 플러그인 센터에서 취소할 때까지 이 연결이 열려 있는 동안 플러그인이 읽기 전용 쿼리를 실행할 수 있습니다. 허용하시겠습니까?',
dataAccessConsentTitle: "데이터 액세스 요청",
mcpToolsBadge: "MCP 도구",
+2
View File
@@ -211,6 +211,8 @@ export default withEnglishFallback({
docs,
dataDictionary,
pluginPlatform: {
openUrlTitle: "Otwórz zewnętrzny URL",
openUrlPrompt: "Wtyczka {name} chce otworzyć ten URL w przeglądarce.",
mcpToolsBadge: "Narzędzia MCP",
mcpToolsBadgeHint: "Ta wtyczka udostępnia narzędzia MCP. Włącz je i nadaj uprawnienia w Ustawienia → MCP → Możliwości.",
mcpToolsFilter: "Tylko wtyczki z narzędziami MCP ({count})",
+2
View File
@@ -608,6 +608,8 @@ export default withEnglishFallback({
docs,
dataDictionary,
pluginPlatform: {
openUrlTitle: "Abrir URL externa",
openUrlPrompt: "O plugin {name} deseja abrir esta URL no seu navegador.",
dataAccessConsent: '"{name}" quer ler dados da conexão "{connection}". Enquanto essa conexão estiver aberta, o plugin poderá executar consultas somente leitura nela até você revogar o acesso na Central de plugins. Permitir?',
dataAccessConsentTitle: "Solicitação de acesso a dados",
mcpToolsBadge: "Ferramentas MCP",
+2
View File
@@ -576,6 +576,8 @@ export default withEnglishFallback({
},
},
pluginPlatform: {
openUrlTitle: "Открыть внешнюю ссылку",
openUrlPrompt: "Плагин {name} запрашивает открытие этой ссылки в браузере.",
dataAccessConsent: "«{name}» запрашивает чтение данных подключения «{connection}». Пока подключение открыто, плагин сможет выполнять в нём запросы только на чтение, пока вы не отзовёте доступ в Центре плагинов. Разрешить?",
dataAccessConsentTitle: "Запрос доступа к данным",
mcpToolsBadge: "Инструменты MCP",
+2
View File
@@ -664,6 +664,8 @@ export default withEnglishFallback({
previewFailed: "ดูตัวอย่างไม่สำเร็จ: {error}",
},
pluginPlatform: {
openUrlTitle: "เปิด URL ภายนอก",
openUrlPrompt: "ปลั๊กอิน {name} ต้องการเปิด URL นี้ในเบราว์เซอร์ของคุณ",
dataAccessConsent: '"{name}" ต้องการอ่านข้อมูลจากการเชื่อมต่อ "{connection}" ขณะที่การเชื่อมต่อนี้เปิดอยู่ Plugin จะเรียกใช้ Query แบบอ่านอย่างเดียวกับการเชื่อมต่อนี้ได้จนกว่าคุณจะเพิกถอนสิทธิ์ใน Plugin Center อนุญาตหรือไม่?',
dataAccessConsentTitle: "คำขอเข้าถึงข้อมูล",
mcpToolsBadge: "เครื่องมือ MCP",
+2
View File
@@ -82,6 +82,8 @@ export default withEnglishFallback({
sidebarMenu,
oracleIndex: { mixedDraft: "Oracle indeks değişikliklerini diğer tablo yapısı değişikliklerinden ayrı uygulayın." },
pluginPlatform: {
openUrlTitle: "Harici URL aç",
openUrlPrompt: "{name} eklentisi bu URL'yi tarayıcınızda açmak istiyor.",
dataAccessConsent: '"{name}", "{connection}" bağlantısındaki verileri okumak istiyor. Bu bağlantı açık olduğu sürece eklenti, Eklenti Merkezi\'nden erişimi iptal edene kadar bağlantıda salt okunur sorgular çalıştırabilir. İzin verilsin mi?',
dataAccessConsentTitle: "Veri Erişim İsteği",
mcpToolsBadge: "MCP araçları",
+2
View File
@@ -750,6 +750,8 @@ export default withEnglishFallback({
docs,
dataDictionary,
pluginPlatform: {
openUrlTitle: "打开外部网址",
openUrlPrompt: "插件 {name} 请求在浏览器中打开以下网址。",
dataAccessConsent: "「{name}」请求读取连接「{connection}」中的数据。在该连接打开期间,插件可以对它执行只读查询,直到你在插件中心撤销授权。是否允许?",
dataAccessConsentTitle: "数据访问请求",
mcpToolsBadge: "MCP 工具",
+2
View File
@@ -590,6 +590,8 @@ export default withEnglishFallback({
docs,
dataDictionary,
pluginPlatform: {
openUrlTitle: "開啟外部網址",
openUrlPrompt: "外掛程式 {name} 要求在瀏覽器中開啟以下網址。",
dataAccessConsent: "「{name}」請求讀取連線「{connection}」中的資料。在該連線開啟期間,外掛可以對它執行唯讀查詢,直到你在外掛中心撤銷授權。是否允許?",
dataAccessConsentTitle: "資料存取請求",
mcpToolsBadge: "MCP 工具",
@@ -1,4 +1,5 @@
import { PLUGIN_AI_TASKS } from "./pluginAiCompletion";
import { permittedPluginUrl } from "./pluginOpenUrl";
import type { PluginAiProvider, PluginAiModel, PluginAiGenerateRequest, PluginAiStreamRequest, PluginAiStreamChunkEvent, PluginAiTask } from "./pluginAiCompletion";
import type { InstalledPlugin, PluginBinaryEvent, PluginEvent, PluginUiAssetPayload, PluginUiContribution } from "@/types/database";
import { clonePluginData, snapshotPluginWorkbenchContext } from "./pluginData";
@@ -161,6 +162,8 @@ export const MAX_PLUGIN_STORAGE_VALUE_BYTES = 256 * 1024;
export const MAX_PLUGIN_STORAGE_KEY_CHARS = 256;
export interface PluginHostBridgeApi {
/** Must display trusted host consent for every request; never launch directly. */
openUrl?(url: string): Promise<{ opened: boolean }>;
invoke<T = unknown>(pluginId: string, method: string, params?: unknown, timeoutMs?: number): Promise<T>;
notify(pluginId: string, method: string, params?: unknown): Promise<void>;
sendBinary(pluginId: string, channel: string, dataBase64: string): Promise<void>;
@@ -505,6 +508,7 @@ export class PluginHostBridge {
// Additive capability advertisement: an older host omits `planApi`, and a
// plugin must treat the absence as "unsupported" rather than probing.
capabilities: {
openUrl: !!this.api.openUrl,
downloadFile: !!this.api.downloadFile,
planApi: !!this.api.getPlanCapabilities && !!this.api.explainPlan,
[PLUGIN_SCHEMA_METADATA_CAPABILITY]: !!this.api.getTableMetadata,
@@ -646,6 +650,12 @@ export class PluginHostBridge {
}
private async dispatch(method: string, params: unknown, binary?: ArrayBuffer): Promise<unknown> {
if (method === "host.openUrl") {
const input = requireRecord(params, "open URL params");
const url = permittedPluginUrl(input.url, this.plugin.manifest.permissions);
if (!this.api.openUrl) throw new Error("Host URL opening is unavailable");
return this.api.openUrl(url);
}
if (method === "host.downloadFile") {
if (!this.api.downloadFile) throw new Error("Streaming file downloads require the desktop host");
const input = requireRecord(params, "download params");
@@ -1453,6 +1463,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
return request('backend.sendBinary', { channel }, { transfer: bytes });
},
readAsset: (path) => request('ui.readAsset', { path }),
openUrl: (url) => request('host.openUrl', { url }),
readAssetUrl: async (path) => {
const asset = await request('ui.readAsset', { path });
return URL.createObjectURL(new Blob([decode(asset.dataBase64)], { type: asset.contentType }));
+58
View File
@@ -0,0 +1,58 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { permittedPluginUrl, pluginOpenUrlOrigins } from "./pluginOpenUrl";
import { PluginHostBridge } from "./pluginHostBridge";
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
describe("plugin external URL permissions", () => {
const permissions = ["host.openUrl:https://docs.example.com", "host.openUrl:http://localhost:8080", "host.openUrl:https://[::1]:8443"];
it("matches only exact canonical HTTP(S) origins, independently of networking", () => {
expect(permittedPluginUrl("https://docs.example.com/guide?q=one#usage", permissions)).toBe("https://docs.example.com/guide?q=one#usage");
expect(permittedPluginUrl("http://localhost:8080/path", permissions)).toBe("http://localhost:8080/path");
expect(permittedPluginUrl("https://[::1]:8443/path", permissions)).toBe("https://[::1]:8443/path");
expect(pluginOpenUrlOrigins(["host.network:https://docs.example.com", "host.openUrl", "host.openUrl:*", "host.openUrl:https://docs.example.com/", "host.openUrl:https://docs.example.com:443", "host.openUrl:https://user@docs.example.com", "host.openUrl:https://*.example.com"])).toEqual([]);
});
it.each([
"javascript:alert(1)",
"data:text/html,test",
"file:///etc/passwd",
"mailto:user@example.com",
"https://docs.example.com.evil.test",
"https://evil.test/docs.example.com",
"http://docs.example.com",
"https://docs.example.com:8443",
"https://user:pass@docs.example.com/",
"//docs.example.com",
"https://docs.example.com/\nhttps://evil.test",
"https://docs.example.com\\@evil.test",
"https://docs.example.com/" + "a".repeat(4096),
null,
42,
])("rejects disallowed input %s", (value) => expect(() => permittedPluginUrl(value, permissions)).toThrow());
it("bounds declaration lists and ignores malformed origins", () => {
expect(pluginOpenUrlOrigins(Array.from({ length: 10 }, (_, index) => `host.openUrl:https://site${index}.test`))).toHaveLength(8);
});
it("gates bridge calls, trusts neither message-supplied plugin IDs nor alien frames, and returns consent decisions", async () => {
const responses: any[] = [];
const target = { postMessage: (message: unknown) => responses.push(message) } as unknown as Window;
const openUrl = vi.fn().mockResolvedValueOnce({ opened: false }).mockResolvedValueOnce({ opened: true });
const plugin = { manifest: { id: "bookmarks", name: "Bookmarks", version: "1.0.0", permissions, drivers: [], contributions: [] }, compatibility: { compatible: true } } as InstalledPlugin;
const bridge = new PluginHostBridge(plugin, { type: "workbench", id: "bookmarks.main", label: "Bookmarks" } as PluginWorkbenchContribution, {}, () => target, { invoke: vi.fn(), notify: vi.fn(), sendBinary: vi.fn(), readAsset: vi.fn(), openUrl });
const send = (url: string, id: string, source = target) => bridge.handleWindowMessage({ source, data: { source: "dbx-plugin", version: 1, type: "request", id, method: "host.openUrl", params: { url, pluginId: "different-plugin" } } } as MessageEvent);
send("https://evil.test", "1");
await vi.waitFor(() => expect(responses).toHaveLength(1));
expect(openUrl).not.toHaveBeenCalled();
expect(responses[0].error).toContain("not declared");
send("https://docs.example.com/guide", "2", {} as Window);
await Promise.resolve();
expect(openUrl).not.toHaveBeenCalled();
send("https://docs.example.com/guide", "3");
await vi.waitFor(() => expect(responses).toHaveLength(2));
expect(responses[1].result).toEqual({ opened: false });
send("https://docs.example.com/guide", "4");
await vi.waitFor(() => expect(responses).toHaveLength(3));
expect(responses[2].result).toEqual({ opened: true });
expect(openUrl).toHaveBeenLastCalledWith("https://docs.example.com/guide");
bridge.dispose();
});
});
@@ -0,0 +1,32 @@
const PREFIX = "host.openUrl:";
export const MAX_PLUGIN_OPEN_URL_ORIGINS = 8;
export const MAX_PLUGIN_OPEN_URL_CHARS = 4096;
export function pluginOpenUrlOrigins(permissions: readonly string[] = []): string[] {
const origins = new Set<string>();
for (const permission of permissions) {
if (!permission.startsWith(PREFIX)) continue;
const origin = permission.slice(PREFIX.length);
try {
const url = new URL(origin);
if ((url.protocol === "https:" || url.protocol === "http:") && url.origin === origin && !url.username && !url.password && !origin.includes("*") && !/[\s\\]/.test(origin)) origins.add(origin);
} catch {
/* Invalid declarations never grant access. */
}
if (origins.size >= MAX_PLUGIN_OPEN_URL_ORIGINS) break;
}
return [...origins];
}
export function permittedPluginUrl(value: unknown, permissions?: readonly string[]): string {
if (typeof value !== "string" || !value || value.length > MAX_PLUGIN_OPEN_URL_CHARS || [...value].some((char) => char.charCodeAt(0) <= 0x20 || char.charCodeAt(0) === 0x7f || char === "\\")) throw new Error("Invalid plugin URL");
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error("Invalid plugin URL");
}
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) throw new Error("Only HTTP(S) URLs without credentials are allowed");
if (!pluginOpenUrlOrigins(permissions).includes(url.origin)) throw new Error("Plugin URL origin is not declared in host.openUrl permissions");
return url.href;
}
@@ -44,6 +44,7 @@ pub const SUPPORTED_PLUGIN_PERMISSIONS: &[&str] = &[
/// Cap the number of `host.network:<origin>` entries so a manifest cannot bloat
/// the sandbox CSP or enumerate large origin lists.
pub const MAX_PLUGIN_NETWORK_ORIGINS: usize = 8;
pub const MAX_PLUGIN_OPEN_URL_ORIGINS: usize = 8;
/// Bound the `visible_when` / `required_when` expression tree so a hostile
/// manifest cannot make the host or the dialog evaluator do unbounded work.
pub const MAX_PLUGIN_FIELD_CONDITION_DEPTH: usize = 8;
@@ -53,6 +54,24 @@ pub const MAX_PLUGIN_FIELD_CONDITION_NODES: usize = 64;
pub const MAX_PLUGIN_PICKER_FILTERS: usize = 16;
const HOST_NETWORK_PERMISSION_PREFIX: &str = "host.network:";
/// External navigation is independent of sandbox networking. Exact canonical
/// origins avoid wildcard, path, user-info, and default-port ambiguities.
pub fn parse_host_open_url_permission(permission: &str) -> Option<&str> {
let origin = permission.strip_prefix("host.openUrl:")?;
if origin.len() > 4096 || origin.contains('*') || origin.chars().any(|c| c.is_whitespace() || c == '\\') {
return None;
}
let url = reqwest::Url::parse(origin).ok()?;
if !matches!(url.scheme(), "http" | "https")
|| !url.username().is_empty()
|| url.password().is_some()
|| url.origin().ascii_serialization() != origin
{
return None;
}
Some(origin)
}
/// Parse a `host.network:https://host[:port]` permission into the origin that
/// may appear in the sandbox `connect-src`. Only https origins without path,
/// query, or fragment parts are accepted.
@@ -1210,12 +1229,14 @@ impl PluginManifest {
let mut seen_permissions = HashSet::new();
let mut network_origins: HashSet<&str> = HashSet::new();
let mut open_url_origins: HashSet<&str> = HashSet::new();
for permission in &self.permissions {
let valid = SUPPORTED_PLUGIN_PERMISSIONS.contains(&permission.as_str())
|| (permission.starts_with("host.network:") && parse_host_network_permission(permission).is_some());
|| parse_host_network_permission(permission).is_some()
|| parse_host_open_url_permission(permission).is_some();
if !valid {
errors.push(format!(
"Unsupported plugin Host API permission '{permission}' (network permissions must look like host.network:https://example.com)"
"Unsupported plugin Host API permission '{permission}' (origins must look like host.network:https://example.com or host.openUrl:https://example.com)"
));
} else if !seen_permissions.insert(permission) {
errors.push(format!("Duplicate plugin permission '{permission}'"));
@@ -1225,6 +1246,9 @@ impl PluginManifest {
errors.push(format!("Duplicate plugin network origin '{origin}'"));
}
}
if let Some(origin) = parse_host_open_url_permission(permission) {
open_url_origins.insert(origin);
}
}
if network_origins.len() > MAX_PLUGIN_NETWORK_ORIGINS {
errors.push(format!(
@@ -1232,6 +1256,12 @@ impl PluginManifest {
network_origins.len()
));
}
if open_url_origins.len() > MAX_PLUGIN_OPEN_URL_ORIGINS {
errors.push(format!(
"Plugin declares {} external URL origins; at most {MAX_PLUGIN_OPEN_URL_ORIGINS} are allowed",
open_url_origins.len()
));
}
validate_localizations(&self.localizations, &mut errors);
validate_declared_icon(plugin_dir, "Plugin icon", self.icon.as_deref(), &mut errors);
validate_contributions(
@@ -2217,6 +2247,29 @@ fn valid_locale_tag(value: &str) -> bool {
#[cfg(test)]
mod tests {
#[test]
fn external_url_permissions_require_exact_canonical_http_origins() {
for origin in ["https://example.com", "http://localhost:8080", "https://[::1]:8443"] {
assert_eq!(super::parse_host_open_url_permission(&format!("host.openUrl:{origin}")), Some(origin));
}
for permission in [
"host.openUrl",
"host.network:https://example.com",
"host.openUrl:*",
"host.openUrl:javascript:alert(1)",
"host.openUrl:file:///tmp/file",
"host.openUrl:https://example.com/path",
"host.openUrl:https://example.com/",
"host.openUrl:https://example.com:443",
"host.openUrl:https://*.example.com",
"host.openUrl:https://user@other.com",
"host.openUrl:https://example.com?token=secret",
"host.openUrl:https://example.com#fragment",
"host.openUrl:https://example.com\\@other.com",
] {
assert_eq!(super::parse_host_open_url_permission(permission), None, "{permission}");
}
}
use std::collections::BTreeMap;
use super::{
@@ -2816,6 +2869,29 @@ mod tests {
assert!(compatibility.errors.iter().any(|error| error.contains("host.network:http://api.vendor.com")));
}
#[test]
fn manifest_accepts_external_url_origins_but_rejects_malformed_or_excess_grants() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("ui")).unwrap();
std::fs::write(dir.path().join("ui/index.html"), "<!doctype html>").unwrap();
let mut manifest: PluginManifest = serde_json::from_value(serde_json::json!({
"manifest_version":1,"id":"io.dbx.bookmarks","name":"Bookmarks","version":"1.0.0","publisher":"example",
"engines":{"dbx":">=0.1.0","host_api":"^1.0"},"entrypoints":{"ui":{"root":"ui","entry":"ui/index.html"}},
"permissions":["host.openUrl:https://example.com","host.openUrl:http://localhost:8080"]
}))
.unwrap();
let valid = manifest.compatibility(dir.path(), "0.1.0");
assert!(valid.compatible, "{:?}", valid.errors);
manifest.permissions.push("host.openUrl:javascript:alert(1)".into());
assert!(manifest
.compatibility(dir.path(), "0.1.0")
.errors
.iter()
.any(|error| error.contains("Unsupported plugin Host API permission")));
manifest.permissions = (0..9).map(|index| format!("host.openUrl:https://site{index}.example.com")).collect();
assert!(manifest.compatibility(dir.path(), "0.1.0").errors.iter().any(|error| error.contains("at most 8")));
}
#[test]
fn accepts_read_only_host_permissions_and_still_rejects_unknown_permissions() {
let dir = tempfile::tempdir().unwrap();
@@ -145,9 +145,25 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含
- `host.data:read` 允许 `window.dbxPlugin.queryData` 在用户授权给插件的连接上执行一条只读 SQL(见 [只读数据查询](#只读数据查询))。每个连接都需要用户同意;它不会授予写入、DDL 或重连。
- `host.connections:read` 允许通过 `window.dbxPlugin.getConnections()` 或 `window.dbxPlugin.connections.list()` 获取当前所有已配置连接的只读脱敏信息(见 [连接信息读取](#连接信息读取))。
- `host.network:https://host[:port]` 声明浏览器可访问的 HTTPS origin,DBX 将它加入 CSP 的 `connect-src`;最多 8 个,不允许路径、通配符或 Token。仍受目标服务 CORS 规则约束。该权限不是原生 Sidecar 的网络防火墙。
- `host.openUrl:http(s)://host[:port]` 允许请求打开指定站点,最多 8 个精确 origin;与网络权限独立,不允许路径、凭据、通配符或尾部斜杠。使用标准化的 `URL.origin`(默认端口应省略);IPv6 地址使用方括号。
- `backend.transport` 默认是 `stdio-jsonl`;需要二进制帧时使用 `stdio-framed`,并同时声明 `host.binary`。
- `backend.executable`、`ui.root` 和 `ui.entry` 必须指向包内文件;带原生后端的包必须为每个目标平台提供对应可执行文件。
### 打开外部网址
在 manifest 中声明例如 `"host.openUrl:https://dbxio.com"`,再调用:
```js
if (window.dbxPlugin.capabilities?.openUrl) {
const { opened } = await window.dbxPlugin.openUrl("https://dbxio.com/cn/docs/plugins");
}
```
每次请求都由 DBX 显示插件名称和完整目标网址,只有用户在宿主确认后才打开。Web / Docker 通过宿主的可信链接点击打开新标签页,桌面端使用系统浏览器;取消或关闭工作台返回 `{ opened: false }`,非法网址或未声明站点会拒绝请求。同一工作台同时只接受一个待确认请求。`opened: true` 表示已发起打开,不代表目标网页成功加载。
接口只接受不带用户名、密码的 HTTP / HTTPS URL,最长 4,096 字符;不支持 `javascript:`、`file:` 等协议。iframe 仍保留 `sandbox="allow-scripts"`,不会获得任意弹窗、顶层导航或网络访问能力。旧宿主可能在安装时拒绝新权限;宿主或 SDK Dev Host 没有 `capabilities.openUrl` 时应隐藏入口,不要探测调用。
### Connection Provider
连接 Provider 负责声明连接表单,DBX 负责渲染表单和保存生命周期;插件只处理自己的连接协议。`binding` 决定字段语义:`config` 写入外部配置,`secret` 写入 Secret Store,`name`、`host`、`port`、`username`、`password`、`database` 映射到标准连接字段。
+16
View File
@@ -148,9 +148,25 @@ Manifest paths are relative to the package root. They cannot start with `/`, con
- `host.data:read` allows `window.dbxPlugin.queryData` to run one read-only SQL statement on a connection the user granted to the plugin (see [Read-only Data Queries](#read-only-data-queries)). Every connection needs the user's consent; it never grants writes, DDL, or reconnects.
- `host.connections:read` allows `window.dbxPlugin.getConnections()` or `window.dbxPlugin.connections.list()` to read secret-free summary metadata for all configured connections (see [Connection Enumeration](#connection-enumeration)).
- `host.network:https://host[:port]` declares a browser-accessible HTTPS origin added to CSP `connect-src`; at most eight origins are allowed, with no paths, wildcards, or tokens. The service's CORS rules still apply. This permission is not a native Sidecar network firewall.
- `host.openUrl:http(s)://host[:port]` permits requesting navigation to up to eight exact origins. It is independent of network access and rejects paths, credentials, wildcards, and trailing slashes. Use the canonical `URL.origin` (omit default ports); bracket IPv6 addresses.
- `backend.transport` defaults to `stdio-jsonl`. Use `stdio-framed` for binary frames and declare `host.binary` as well.
- `backend.executable`, `ui.root`, and `ui.entry` must point to package files. Native packages need a matching executable for every target.
### Open External URLs
Declare, for example, `"host.openUrl:https://dbxio.com"` in the manifest, then call:
```js
if (window.dbxPlugin.capabilities?.openUrl) {
const { opened } = await window.dbxPlugin.openUrl("https://dbxio.com/en/docs/plugins");
}
```
Every request displays the plugin name and complete destination in trusted DBX UI. Only explicit host approval opens it. Web / Docker uses a trusted host link click to open a new tab; desktop uses the system browser. Cancellation or workbench teardown returns `{ opened: false }`; invalid URLs and undeclared origins reject. Only one pending consent request is allowed per workbench. `opened: true` acknowledges initiation, not successful page loading.
Only HTTP / HTTPS URLs without credentials are accepted, up to 4,096 characters. Protocols such as `javascript:` and `file:` are rejected. The iframe keeps `sandbox="allow-scripts"` without arbitrary popups, top navigation, or networking. Older hosts may reject the new permission during installation. Hide the action on hosts and SDK Dev Host where `capabilities.openUrl` is absent rather than probing the API.
### Connection Providers
A connection provider declares a form; DBX renders the form and owns the connection lifecycle, while the plugin handles its protocol. `binding` gives a field its meaning: `config` is persisted as external configuration, `secret` goes to the Secret Store, and `name`, `host`, `port`, `username`, `password`, and `database` map to standard connection fields.
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+2 -1
View File
@@ -31,7 +31,8 @@
"items": {
"anyOf": [
{ "enum": ["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.schema:read", "host.storage", "host.ai", "host.clipboard:read", "host.data:read", "host.connections:read", "host.connections::read"] },
{ "type": "string", "pattern": "^host\\.network:https://[A-Za-z0-9._-]+(:[0-9]+)?$" }
{ "type": "string", "pattern": "^host\\.network:https://[A-Za-z0-9._-]+(:[0-9]+)?$" },
{ "type": "string", "pattern": "^host\\.openUrl:https?://[^\\s/?#@*\\\\]+$", "maxLength": 4109 }
]
}
},
+1
View File
@@ -146,6 +146,7 @@ function installBridge(channel) {
getAll: () => request("host.getConnections", {}),
}),
copy: (text) => request("host.copy", { text }),
openUrl: (url) => request("host.openUrl", { url }),
storage: {
get: (key) => request("host.storageGet", { key }),
set: (key, value) => request("host.storageSet", { key, value: value === undefined ? null : value }),
@@ -5,6 +5,22 @@ import { JSDOM } from "jsdom";
import { sandboxDocument } from "../browser-bridge.mjs";
import { hostMessage, pluginInitMessage } from "../ui/messages.js";
test("external URL requests use the host protocol and preserve rejection on unsupported hosts", async (t) => {
const dom = new JSDOM(sandboxDocument("<html><head></head><body></body></html>", "generation"), { runScripts: "dangerously" });
t.after(() => dom.window.close());
const w = dom.window;
let message;
w.postMessage = (value) => {
if (value.type === "request") message = value;
};
const request = w.dbxPlugin.openUrl("https://example.com/guide");
assert.equal(message.method, "host.openUrl");
assert.equal(message.params.url, "https://example.com/guide");
w.dispatchEvent(new w.MessageEvent("message", { source: w, data: hostMessage("generation", { type: "response", id: message.id, error: { message: "Host URL opening is unavailable" } }) }));
await assert.rejects(request, /Host URL opening is unavailable/);
assert.equal(w.dbxPlugin.capabilities?.openUrl, undefined);
});
test("bridge delivers host-compatible environment, lifecycle and binary events", async (t) => {
const dom = new JSDOM(sandboxDocument("<head></head>", "test"), { runScripts: "dangerously" });
t.after(() => dom.window.close());