diff --git a/apps/web/src/lib/plugins/plugin-capabilities.fixture.mjs b/apps/web/src/lib/plugins/plugin-capabilities.fixture.mjs index fff6b0a1..ffd966f2 100644 --- a/apps/web/src/lib/plugins/plugin-capabilities.fixture.mjs +++ b/apps/web/src/lib/plugins/plugin-capabilities.fixture.mjs @@ -5,7 +5,7 @@ export default { ai: () => context.ai.generate({ system: 'Translate', prompt: 'hello', maxOutputTokens: 100 }), public: () => context.network.fetch('https://example.org/feed', { transport: 'public', redirect: 'manual' }).then(async r => ({ status: r.status, text: await r.text(), url: r.url })), unlisted: () => context.network.fetch('https://unlisted.org/feed', { transport: 'public' }), - 'direct-unlisted': () => context.network.fetch('https://unlisted.org/feed'), + 'direct-unlisted': () => context.network.fetch('http://192.168.1.8/feed', { credentials: 'include', headers: { Authorization: 'Bearer test', 'X-Plugin-Token': 'test' } }), post: () => context.network.fetch('https://example.org/feed', { transport: 'public', method: 'POST', body: 'data' }), })) context.commands.register({ id, title: id, run: async () => { results.set(context.pluginId, await action()); } }); }, diff --git a/apps/web/src/lib/plugins/plugin-host.test.mjs b/apps/web/src/lib/plugins/plugin-host.test.mjs index 8fdb682f..151e75f7 100644 --- a/apps/web/src/lib/plugins/plugin-host.test.mjs +++ b/apps/web/src/lib/plugins/plugin-host.test.mjs @@ -67,6 +67,8 @@ beforeEach(() => { describe("EdgeEverPluginHost", () => { test('generic AI/public network calls require declared permissions while public reads allow arbitrary public hosts', async () => { const calls = []; + const directCalls = []; + globalThis.window.fetch = async (...input) => { directCalls.push(input); return new Response('direct'); }; const host = new EdgeEverPluginHost({ repository, scope: 'test', aiAdapter: { status: async () => ({ configured: true }), generate: async input => { calls.push(input); return { text: 'HELLO' }; } }, publicNetworkAdapter: { fetchPublic: async input => { calls.push(input); return { url: input.url, status: 429, statusText: 'Too Many Requests', headers: {}, body: new TextEncoder().encode('limited').buffer }; } }, @@ -75,11 +77,14 @@ describe("EdgeEverPluginHost", () => { host.installManifest({ type: 'plugin', id, name: id, version: '1.0.0', apiVersion: '1', entry: new URL('./plugin-capabilities.fixture.mjs', import.meta.url).href, permissions: ['ui:commands', ...permissions], ...(networkHosts ? { networkHosts } : {}) }, 'https://example.org/manifest.json'); await host.setEnabled(id, true); }; - await install('org.test.denied', ['network'], ['example.org']); + await install('org.test.denied', ['network']); await expect(host.runCommand('org.test.denied', 'ai')).rejects.toThrow(); await expect(host.runCommand('org.test.denied', 'public')).rejects.toThrow(); expect(calls).toHaveLength(0); + await host.runCommand('org.test.denied', 'direct-unlisted'); + expect(String(directCalls[0][0])).toBe('http://192.168.1.8/feed'); + expect(directCalls[0][1].credentials).toBe('include'); + expect(new Headers(directCalls[0][1].headers).get('Authorization')).toBe('Bearer test'); await install('org.test.allowed', ['network', 'network:public', 'ai:generate']); - await expect(host.runCommand('org.test.allowed', 'direct-unlisted')).rejects.toThrow(); await expect(host.runCommand('org.test.allowed', 'post')).rejects.toThrow(); expect(calls).toHaveLength(0); await host.runCommand('org.test.allowed', 'ai'); expect(capabilityResults.get('org.test.allowed')).toEqual({ text: 'HELLO' }); await host.runCommand('org.test.allowed', 'public'); expect(capabilityResults.get('org.test.allowed')).toEqual({ status: 429, text: 'limited', url: 'https://example.org/feed' }); diff --git a/apps/web/src/lib/plugins/plugin-host.ts b/apps/web/src/lib/plugins/plugin-host.ts index 3b5f0cbc..6cdefeb6 100644 --- a/apps/web/src/lib/plugins/plugin-host.ts +++ b/apps/web/src/lib/plugins/plugin-host.ts @@ -353,16 +353,6 @@ const EVENT_PERMISSIONS: Partial> "resource.deleted": "resources:read", }; -const isAllowedNetworkHost = (hostname: string, allowedHosts: string[]) => - allowedHosts.some((allowedHost) => { - const normalized = allowedHost.trim().toLocaleLowerCase(); - if (normalized.startsWith("*.")) { - const suffix = normalized.slice(1); - return hostname.endsWith(suffix) && hostname !== suffix.slice(1); - } - return hostname === normalized; - }); - const resolveManifestEntry = (manifestUrl: string, entry: string) => new URL(entry, manifestUrl).href; const assertConfirmedManifest = (confirmedManifest: ExtensionManifest | undefined, downloadedManifest: ExtensionManifest) => { @@ -1383,8 +1373,8 @@ export class EdgeEverPluginHost { assertPermission(manifest, "network"); lifetime.signal.throwIfAborted(); const url = new URL(input); - if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname))) { - throw new Error("Plugin network requests must use HTTPS, except for localhost development."); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error("Plugin network requests must use HTTP or HTTPS."); } const { transport = 'direct', ...requestInit } = init ?? {}; const signal = AbortSignal.any([lifetime.signal, ...(requestInit.signal ? [requestInit.signal] : [])]); @@ -1401,10 +1391,7 @@ export class EdgeEverPluginHost { return response; } if (transport !== 'direct') throw new Error('Unsupported network transport.'); - if (!manifest.networkHosts?.length || !isAllowedNetworkHost(url.hostname.toLocaleLowerCase(), manifest.networkHosts)) { - throw new Error(`${url.hostname} is not declared in this plugin's networkHosts.`); - } - return window.fetch(url, { ...requestInit, signal, credentials: "omit" }); + return window.fetch(url, { ...requestInit, signal }); }, }, ui: { diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 7638fdad..3be437c3 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -111,7 +111,7 @@ Supported permissions: - `ui:panels` - `ui:embeds` -Direct browser access through `context.network.fetch()` requires a `networkHosts` allowlist. The anonymous, read-only `network:public` transport may reach any public HTTPS host and does not use a static host list. +Network access through `context.network.fetch()` requires the `network` capability declaration, but it is not restricted to a static host list. `networkHosts` remains accepted as legacy descriptive metadata and is not enforced. The anonymous, read-only `network:public` transport is available when a plugin needs a cross-origin public response without credentials. ## Plugin entry @@ -312,17 +312,19 @@ await context.storage.set("cursor", "next-page"); const cursor = await context.storage.get("cursor"); ``` -Direct browser requests are limited to HTTPS, except localhost development, and to declared hosts: +Direct requests may use HTTP or HTTPS with arbitrary destinations, methods, headers, bodies, and browser credential modes. The Web runtime still follows the browser's CORS and cookie rules: ```json { - "permissions": ["network"], - "networkHosts": ["api.example.com", "*.trusted.example.com"] + "permissions": ["network"] } ``` ```ts -await context.network.fetch("https://api.example.com/items"); +await context.network.fetch("https://api.example.com/items", { + headers: { Authorization: `Bearer ${token}`, "X-Client": "my-plugin" }, + credentials: "include", +}); ``` Use regular `storage` for cursors and preferences. Sensitive strings such as API keys belong in `secrets`: @@ -487,7 +489,7 @@ const result = await context.ai.generate({ `system` is limited to 8,000 characters, `prompt` to 90,000, output to 5,000 tokens, and generation to 120 seconds. The backend requires an interactive user session, disables AI in public demo mode, and redacts provider errors. AI calls have a four-request per-workspace guard in each backend instance; this is not a distributed quota. Model charges follow the configured provider. Plugin deactivation aborts outstanding calls. -Existing `network.fetch(url, init)` remains browser fetch with CORS, omitted browser credentials, and a required `networkHosts` allowlist. To read arbitrary public feeds or APIs without credentials, add both `network` and `network:public` and explicitly select `transport: "public"`. This public transport is a single broad capability: it does not require or enforce a static host list. The anonymous public transport header restrictions below do not apply to ordinary direct requests; capabilities that need browser credentials such as cookies should use a separate, explicit authorization capability instead of borrowing `network:public`. +The default `network.fetch(url, init)` transport is a trusted browser request. It accepts arbitrary HTTP/HTTPS destinations, methods, bodies, request headers such as `Authorization`, and the requested browser credential mode. It remains subject to the runtime browser's CORS and cookie policy. `networkHosts` is legacy descriptive metadata and is not a security boundary. To read a cross-origin public feed or API without credentials, add both `network` and `network:public` and explicitly select `transport: "public"`. ```json { @@ -511,4 +513,4 @@ The host selects the least expensive safe transport without changing the plugin All native/server drivers share one policy package. Desktop and self-hosted Bun validate every DNS answer and pass the validated address directly to TLS; private, special-use and mixed public/private answers are rejected. Cloudflare fallback uses workerd's default public-only Internet egress and no private-service bindings. Synthetic VPN/fake-IP DNS answers in reserved ranges are rejected; do not disable this check. Nonstandard workerd deployments must preserve public-only global egress. The Web fallback returns bounded binary bytes rather than Base64 JSON, avoiding Base64's transfer expansion. -Plugin permission checks and direct-request host allowlists run in the trusted client host. The public transport intentionally grants anonymous read access to arbitrary public HTTPS hosts. Backend routes independently require user authentication and enforce public-only egress; they do not trust client-supplied plugin IDs and do not claim server-attested per-plugin isolation. The trusted-JavaScript limitation still applies. The backend never receives a source enum, search window, evidence schema or report workflow. +Plugin capability declarations primarily communicate intent and catch accidental API use; enabled plugins are trusted JavaScript, not a security sandbox. A plugin that can read notes and use the network can transmit those notes. The public transport intentionally grants anonymous read access to arbitrary public HTTPS hosts. Backend routes independently require user authentication and enforce public-only egress so the shared EdgeEver service cannot be used to reach private networks; they do not claim server-attested per-plugin isolation. The backend never receives a source enum, search window, evidence schema or report workflow. diff --git a/docs/plugin-development.zh-CN.md b/docs/plugin-development.zh-CN.md index cd482622..c8a1ca89 100644 --- a/docs/plugin-development.zh-CN.md +++ b/docs/plugin-development.zh-CN.md @@ -111,7 +111,7 @@ Registry 格式: - `ui:panels` - `ui:embeds` -通过 `context.network.fetch()` 直接使用浏览器网络时,必须在 Manifest 的 `networkHosts` 中声明目标域名。匿名只读的 `network:public` 传输可以访问任意公开 HTTPS 域名,不使用静态域名列表。 +通过 `context.network.fetch()` 访问网络时需要声明 `network` 能力,但不受静态域名列表限制。`networkHosts` 仅作为兼容旧版的描述性元数据保留,不参与拦截。插件需要无凭据读取跨域公开响应时,可以声明匿名只读的 `network:public` 传输。 ## 插件入口 @@ -312,17 +312,19 @@ await context.storage.set("cursor", "next-page"); const cursor = await context.storage.get("cursor"); ``` -浏览器直接请求只能使用 HTTPS;本地开发允许 localhost HTTP,并且目标域名必须提前声明: +直接请求可以访问任意 HTTP 或 HTTPS 地址,并使用任意方法、请求头、正文和浏览器凭据模式。Web 运行时仍遵循浏览器的 CORS 与 Cookie 规则: ```json { - "permissions": ["network"], - "networkHosts": ["api.example.com", "*.trusted.example.com"] + "permissions": ["network"] } ``` ```ts -await context.network.fetch("https://api.example.com/items"); +await context.network.fetch("https://api.example.com/items", { + headers: { Authorization: `Bearer ${token}`, "X-Client": "my-plugin" }, + credentials: "include", +}); ``` 普通 `storage` 适合游标和偏好设置。API Key 等敏感字符串应使用 `secrets`: @@ -487,7 +489,7 @@ const result = await context.ai.generate({ `system` 最多 8,000 字符,`prompt` 最多 90,000 字符,输出最多 5,000 token,生成最长 120 秒。后端要求交互式用户会话,公开演示模式禁用 AI,供应商错误脱敏。每个后端实例对每工作区的 AI 调用设置四路并发保护,不是分布式配额。模型费用沿用已配置供应商的计费;停用插件会中止其调用。 -已有 `network.fetch(url, init)` 保留浏览器 fetch 行为,受 CORS 限制、省略浏览器凭据,并要求 `networkHosts` 白名单。需要无凭据读取任意公开订阅或 API 时,同时声明 `network` 和 `network:public`,并显式选择 `transport: "public"`。公开传输是一项统一能力,不要求也不执行静态域名列表。下述匿名公开传输的请求头限制不适用于普通直连;需要 Cookie 等浏览器凭据的能力应使用独立、明确的授权能力,不能借用 `network:public`: +默认的 `network.fetch(url, init)` 是受信任的浏览器请求,可以访问任意 HTTP/HTTPS 地址,使用任意方法、正文、`Authorization` 等请求头以及调用方指定的浏览器凭据模式;它仍受所在运行时的 CORS 与 Cookie 策略约束。`networkHosts` 仅为兼容旧版保留,不是安全边界。需要无凭据读取跨域公开订阅或 API 时,同时声明 `network` 和 `network:public`,并显式选择 `transport: "public"`: ```json { @@ -511,4 +513,4 @@ const feed = await response.text(); // 插件自己解析。 桌面端、自托管与云端驱动共用同一策略包。桌面端和 Bun 自托管会校验全部 DNS 结果,并把已校验地址直接交给 TLS;私网、特殊用途和混合公私地址全部拒绝。Cloudflare 回退使用 workerd 默认的仅公开 Internet 出口,不使用私网服务绑定。VPN/fake-IP DNS 返回的保留地址也会拒绝,不应禁用检查;非标准 workerd 部署须保留仅公开网络出口。Web 回退返回有大小限制的二进制正文,不再使用 Base64 JSON,避免 Base64 的额外传输体积。 -插件权限检查和直接请求的域名白名单在可信客户端宿主执行。公开传输有意允许匿名读取任意公开 HTTPS 域名。后端独立要求用户认证并限制仅公开网络,不信任客户端提交的插件 ID,也不声称提供服务端证明的插件隔离;仍遵循可信 JavaScript 边界。后端不接收来源枚举、搜索时间范围、证据结构或报告流程。 +插件能力声明主要用于告知意图并发现误用;启用后的插件属于受信任 JavaScript,不是安全沙箱。一个同时能够读取笔记和访问网络的插件可以把笔记发送出去。公开传输有意允许匿名读取任意公开 HTTPS 域名。后端仍独立要求用户认证并限制仅公开网络,避免共享的 EdgeEver 服务被用来访问私网;它不声称提供服务端证明的插件隔离。后端不接收来源枚举、搜索时间范围、证据结构或报告流程。 diff --git a/packages/plugin-api/dist/index.js b/packages/plugin-api/dist/index.js index 8cefe1c8..2599fc03 100644 --- a/packages/plugin-api/dist/index.js +++ b/packages/plugin-api/dist/index.js @@ -214,9 +214,6 @@ var parseExtensionManifest = (value) => { if (networkHosts?.some((host) => !/^(?:\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i.test(host))) { throw new Error("networkHosts entries must be hostnames without a scheme, port, or path."); } - if (permissions.includes("network") && !permissions.includes("network:public") && !networkHosts?.length) { - throw new Error("Plugins requesting direct network access must declare networkHosts."); - } const platforms = value.platforms === undefined ? undefined : Array.isArray(value.platforms) && value.platforms.every((platform) => ["web", "desktop", "android", "ios"].includes(String(platform))) ? [...new Set(value.platforms.map(String))] : (() => { throw new Error("Plugin platforms contains an unsupported platform."); })(); diff --git a/packages/plugin-api/src/index.test.mjs b/packages/plugin-api/src/index.test.mjs index c915c1cd..9ce6e58c 100644 --- a/packages/plugin-api/src/index.test.mjs +++ b/packages/plugin-api/src/index.test.mjs @@ -50,8 +50,8 @@ describe("extension manifests", () => { })).toThrow("must use #RRGGBB"); }); - test("requires an allowlist for direct network plugins", () => { - expect(() => parseExtensionManifest({ + test("allows direct network plugins without a static host list", () => { + expect(parseExtensionManifest({ type: "plugin", id: "org.edgeever.network", name: "Network", @@ -59,7 +59,7 @@ describe("extension manifests", () => { apiVersion: "1", entry: "./main.js", permissions: ["network"], - })).toThrow("must declare networkHosts"); + })).toMatchObject({ permissions: ["network"] }); }); test("allows public read-only network plugins without a static host list", () => { diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index 107838bd..cc861b52 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -627,9 +627,6 @@ export const parseExtensionManifest = (value: unknown): ExtensionManifest => { if (networkHosts?.some((host) => !/^(?:\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i.test(host))) { throw new Error("networkHosts entries must be hostnames without a scheme, port, or path."); } - if (permissions.includes("network") && !permissions.includes("network:public") && !networkHosts?.length) { - throw new Error("Plugins requesting direct network access must declare networkHosts."); - } const platforms = value.platforms === undefined ? undefined : Array.isArray(value.platforms) && value.platforms.every((platform) => ["web", "desktop", "android", "ios"].includes(String(platform)))