diff --git a/apps/web/src/components/plugins/PluginUpdateDialog.tsx b/apps/web/src/components/plugins/PluginUpdateDialog.tsx
index 9e9cb230..0c2c192d 100644
--- a/apps/web/src/components/plugins/PluginUpdateDialog.tsx
+++ b/apps/web/src/components/plugins/PluginUpdateDialog.tsx
@@ -49,7 +49,7 @@ export const PluginUpdateDialog = ({
{update.addedPermissions.map((permission) => (
- {permissionLabel(permission)}
+ {permission === "network:public" ? t("plugins.permissions.publicNetwork") : permissionLabel(permission)}
))}
diff --git a/apps/web/src/components/settings/PluginManagerCard.tsx b/apps/web/src/components/settings/PluginManagerCard.tsx
index e0ba93b7..be88539b 100644
--- a/apps/web/src/components/settings/PluginManagerCard.tsx
+++ b/apps/web/src/components/settings/PluginManagerCard.tsx
@@ -225,7 +225,7 @@ const PluginDetailView = ({
{t("plugins.details.permissions")}
{manifest.permissions.map((permission) => (
- {permissionLabel(permission)}
+ {permission === "network:public" ? t("plugins.permissions.publicNetwork") : permissionLabel(permission)}
))}
@@ -613,7 +613,7 @@ export const PluginManagerCard = ({
{extension.manifest.permissions.slice(0, 3).map((permission) => (
- {permissionLabel(permission)}
+ {permission === "network:public" ? t("plugins.permissions.publicNetwork") : permissionLabel(permission)}
))}
{extension.manifest.permissions.length > 3 ? (
diff --git a/apps/web/src/lib/plugins/plugin-capabilities.fixture.mjs b/apps/web/src/lib/plugins/plugin-capabilities.fixture.mjs
index b3cf56ad..fff6b0a1 100644
--- a/apps/web/src/lib/plugins/plugin-capabilities.fixture.mjs
+++ b/apps/web/src/lib/plugins/plugin-capabilities.fixture.mjs
@@ -5,6 +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'),
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 11f8a6fe..8fdb682f 100644
--- a/apps/web/src/lib/plugins/plugin-host.test.mjs
+++ b/apps/web/src/lib/plugins/plugin-host.test.mjs
@@ -65,24 +65,25 @@ beforeEach(() => {
});
describe("EdgeEverPluginHost", () => {
- test('generic AI/public network calls require declared permissions and destination hosts', async () => {
+ test('generic AI/public network calls require declared permissions while public reads allow arbitrary public hosts', async () => {
const calls = [];
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 }; } },
});
- const install = async (id, permissions) => {
- 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: ['example.org'] }, 'https://example.org/manifest.json');
+ const install = async (id, permissions, networkHosts) => {
+ 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']);
+ await install('org.test.denied', ['network'], ['example.org']);
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 install('org.test.allowed', ['network', 'network:public', 'ai:generate']);
- await expect(host.runCommand('org.test.allowed', 'unlisted')).rejects.toThrow();
+ 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' });
+ await host.runCommand('org.test.allowed', 'unlisted'); expect(calls.at(-1).url).toBe('https://unlisted.org/feed');
expect(calls[0].signal.aborted).toBe(false); await host.setEnabled('org.test.allowed', false); expect(calls[0].signal.aborted).toBe(true);
await host.dispose();
});
diff --git a/apps/web/src/lib/plugins/plugin-host.ts b/apps/web/src/lib/plugins/plugin-host.ts
index 93a103ca..3b5f0cbc 100644
--- a/apps/web/src/lib/plugins/plugin-host.ts
+++ b/apps/web/src/lib/plugins/plugin-host.ts
@@ -1386,9 +1386,6 @@ export class EdgeEverPluginHost {
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 (!manifest.networkHosts?.length || !isAllowedNetworkHost(url.hostname.toLocaleLowerCase(), manifest.networkHosts)) {
- throw new Error(`${url.hostname} is not declared in this plugin's networkHosts.`);
- }
const { transport = 'direct', ...requestInit } = init ?? {};
const signal = AbortSignal.any([lifetime.signal, ...(requestInit.signal ? [requestInit.signal] : [])]);
if (transport === 'public') {
@@ -1404,6 +1401,9 @@ 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" });
},
},
diff --git a/docs/plugin-development.md b/docs/plugin-development.md
index c2941e00..7638fdad 100644
--- a/docs/plugin-development.md
+++ b/docs/plugin-development.md
@@ -111,7 +111,7 @@ Supported permissions:
- `ui:panels`
- `ui:embeds`
-Network access through `context.network.fetch()` also requires a `networkHosts` allowlist in the manifest.
+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.
## Plugin entry
@@ -312,7 +312,7 @@ await context.storage.set("cursor", "next-page");
const cursor = await context.storage.get("cursor");
```
-Requests are limited to HTTPS, except localhost development, and to declared hosts:
+Direct browser requests are limited to HTTPS, except localhost development, and to declared hosts:
```json
{
@@ -487,12 +487,11 @@ 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 and omitted credentials. Add both `network` and `network:public`, declare `networkHosts`, and explicitly select `transport: "public"` to use the generic public Internet transport:
+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`.
```json
{
- "permissions": ["network", "network:public"],
- "networkHosts": ["example.org"]
+ "permissions": ["network", "network:public"]
}
```
@@ -506,10 +505,10 @@ const response = await context.network.fetch("https://example.org/feed.xml", {
const feed = await response.text(); // Parse inside the plugin.
```
-Public mode supports HTTPS GET/HEAD on port 443, no request body or credentials, a 20-second deadline, and at most 2,000,000 decoded response bytes. It always returns redirects without following them (`redirect: "error"` rejects them). Upstream 403/429 remain upstream status codes; this transport does not bypass platform restrictions. Allowed request headers: Accept, Accept-Language, If-None-Match, If-Modified-Since, Range. Only content/cache metadata, Location and Retry-After are returned; Set-Cookie is excluded. The response is buffered within the size limit, not an unlimited streaming proxy.
+Public mode supports any public HTTPS host on port 443 with GET/HEAD, no request body or credentials, a 20-second deadline, and at most 2,000,000 decoded response bytes. It always returns redirects without following them (`redirect: "error"` rejects them), so plugins can inspect a destination before requesting it separately. Upstream 403/429 remain upstream status codes; this transport does not bypass platform restrictions. Allowed request headers: Accept, Accept-Language, If-None-Match, If-Modified-Since, Range. Only content/cache metadata, Location and Retry-After are returned; Set-Cookie is excluded. The response is buffered within the size limit, not an unlimited streaming proxy.
The host selects the least expensive safe transport without changing the plugin API. Web first tries browser fetch; a readable CORS response stays entirely client-side, while a browser network/CORS `TypeError` falls back to the authenticated backend relay. Desktop uses its Electron main process and the user's own network, capped at four concurrent requests. It does not relay public content through the EdgeEver backend. Cancellation propagates to every transport.
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/domain checks run in the trusted client host. Backend routes independently require user authentication and enforce public-only egress; they do not trust client-supplied plugin IDs or allowlists, 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 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.
diff --git a/docs/plugin-development.zh-CN.md b/docs/plugin-development.zh-CN.md
index 7598947c..cd482622 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` 中声明目标域名。
+通过 `context.network.fetch()` 直接使用浏览器网络时,必须在 Manifest 的 `networkHosts` 中声明目标域名。匿名只读的 `network:public` 传输可以访问任意公开 HTTPS 域名,不使用静态域名列表。
## 插件入口
@@ -312,7 +312,7 @@ await context.storage.set("cursor", "next-page");
const cursor = await context.storage.get("cursor");
```
-网络请求只能使用 HTTPS;本地开发允许 localhost HTTP,并且目标域名必须提前声明:
+浏览器直接请求只能使用 HTTPS;本地开发允许 localhost HTTP,并且目标域名必须提前声明:
```json
{
@@ -487,12 +487,11 @@ const result = await context.ai.generate({
`system` 最多 8,000 字符,`prompt` 最多 90,000 字符,输出最多 5,000 token,生成最长 120 秒。后端要求交互式用户会话,公开演示模式禁用 AI,供应商错误脱敏。每个后端实例对每工作区的 AI 调用设置四路并发保护,不是分布式配额。模型费用沿用已配置供应商的计费;停用插件会中止其调用。
-已有 `network.fetch(url, init)` 保留浏览器 fetch 行为,受 CORS 限制并省略凭据。使用通用公开网络传输时,需要同时声明 `network`、`network:public` 和 `networkHosts`,并显式选择 `transport: "public"`:
+已有 `network.fetch(url, init)` 保留浏览器 fetch 行为,受 CORS 限制、省略浏览器凭据,并要求 `networkHosts` 白名单。需要无凭据读取任意公开订阅或 API 时,同时声明 `network` 和 `network:public`,并显式选择 `transport: "public"`。公开传输是一项统一能力,不要求也不执行静态域名列表。下述匿名公开传输的请求头限制不适用于普通直连;需要 Cookie 等浏览器凭据的能力应使用独立、明确的授权能力,不能借用 `network:public`:
```json
{
- "permissions": ["network", "network:public"],
- "networkHosts": ["example.org"]
+ "permissions": ["network", "network:public"]
}
```
@@ -506,10 +505,10 @@ const response = await context.network.fetch("https://example.org/feed.xml", {
const feed = await response.text(); // 插件自己解析。
```
-公开模式仅支持 443 端口的 HTTPS GET/HEAD,不携带请求体和凭据;超时 20 秒,解码后的响应正文最多 2,000,000 字节。重定向只返回、不跟随(`redirect: "error"` 会拒绝)。来源的 403/429 保留为来源状态,不绕过平台访问限制。允许的请求头为 Accept、Accept-Language、If-None-Match、If-Modified-Since、Range;仅返回内容/缓存元数据、Location 和 Retry-After,不返回 Set-Cookie。响应在上限内缓冲,不是无限流式代理。
+公开模式允许访问任意公开域名,但仅支持 443 端口的 HTTPS GET/HEAD,不携带请求体和凭据;超时 20 秒,解码后的响应正文最多 2,000,000 字节。重定向只返回、不跟随(`redirect: "error"` 会拒绝),插件可以检查目标后再单独请求。来源的 403/429 保留为来源状态,不绕过平台访问限制。允许的请求头为 Accept、Accept-Language、If-None-Match、If-Modified-Since、Range;仅返回内容/缓存元数据、Location 和 Retry-After,不返回 Set-Cookie。响应在上限内缓冲,不是无限流式代理。
宿主在不改变插件 API 的前提下选择成本最低的安全传输。Web 先尝试浏览器请求:CORS 可读的响应完全留在客户端;只有浏览器以网络/CORS `TypeError` 拒绝时,才回退到已认证的后端中继。桌面端通过 Electron 主进程和用户本机网络请求,最多四路并发,不再把公开内容转发到 EdgeEver 后端。取消信号会传递到所有传输路径。
桌面端、自托管与云端驱动共用同一策略包。桌面端和 Bun 自托管会校验全部 DNS 结果,并把已校验地址直接交给 TLS;私网、特殊用途和混合公私地址全部拒绝。Cloudflare 回退使用 workerd 默认的仅公开 Internet 出口,不使用私网服务绑定。VPN/fake-IP DNS 返回的保留地址也会拒绝,不应禁用检查;非标准 workerd 部署须保留仅公开网络出口。Web 回退返回有大小限制的二进制正文,不再使用 Base64 JSON,避免 Base64 的额外传输体积。
-插件权限和域名检查在可信客户端宿主执行。后端独立要求用户认证并限制仅公开网络,不信任客户端提交的插件 ID 或白名单,也不声称提供服务端证明的插件隔离;仍遵循可信 JavaScript 边界。后端不接收来源枚举、搜索时间范围、证据结构或报告流程。
+插件权限检查和直接请求的域名白名单在可信客户端宿主执行。公开传输有意允许匿名读取任意公开 HTTPS 域名。后端独立要求用户认证并限制仅公开网络,不信任客户端提交的插件 ID,也不声称提供服务端证明的插件隔离;仍遵循可信 JavaScript 边界。后端不接收来源枚举、搜索时间范围、证据结构或报告流程。
diff --git a/packages/plugin-api/dist/index.js b/packages/plugin-api/dist/index.js
index 584a75c0..8cefe1c8 100644
--- a/packages/plugin-api/dist/index.js
+++ b/packages/plugin-api/dist/index.js
@@ -214,8 +214,8 @@ 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") && !networkHosts?.length) {
- throw new Error("Plugins requesting network permission must declare networkHosts.");
+ 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 25a7452e..c915c1cd 100644
--- a/packages/plugin-api/src/index.test.mjs
+++ b/packages/plugin-api/src/index.test.mjs
@@ -50,7 +50,7 @@ describe("extension manifests", () => {
})).toThrow("must use #RRGGBB");
});
- test("requires an allowlist for network plugins", () => {
+ test("requires an allowlist for direct network plugins", () => {
expect(() => parseExtensionManifest({
type: "plugin",
id: "org.edgeever.network",
@@ -62,6 +62,18 @@ describe("extension manifests", () => {
})).toThrow("must declare networkHosts");
});
+ test("allows public read-only network plugins without a static host list", () => {
+ expect(parseExtensionManifest({
+ type: "plugin",
+ id: "org.edgeever.public-network",
+ name: "Public network",
+ version: "1.0.0",
+ apiVersion: "1",
+ entry: "./main.js",
+ permissions: ["network", "network:public"],
+ })).toMatchObject({ permissions: ["network", "network:public"] });
+ });
+
test("normalizes a host-rendered plugin settings schema", () => {
const manifest = parseExtensionManifest({
type: "plugin",
diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts
index 6b9a0c61..107838bd 100644
--- a/packages/plugin-api/src/index.ts
+++ b/packages/plugin-api/src/index.ts
@@ -627,8 +627,8 @@ 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") && !networkHosts?.length) {
- throw new Error("Plugins requesting network permission must declare networkHosts.");
+ 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
diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts
index cbb61642..181a4766 100644
--- a/packages/shared/src/i18n/en-US.ts
+++ b/packages/shared/src/i18n/en-US.ts
@@ -656,6 +656,9 @@ export const enUS = {
install: "Install",
installing: "Installing…",
empty: "No plugins or themes are installed on this device.",
+ permissions: {
+ publicNetwork: "Read content from any public website",
+ },
toggle: "Enable or disable {{name}}",
uninstall: "Uninstall",
settings: {
diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts
index e8a419db..80903324 100644
--- a/packages/shared/src/i18n/zh-CN.ts
+++ b/packages/shared/src/i18n/zh-CN.ts
@@ -656,6 +656,9 @@ export const zhCN = {
install: "安装",
installing: "安装中…",
empty: "当前设备尚未安装插件或主题。",
+ permissions: {
+ publicNetwork: "读取任意公开网站内容",
+ },
toggle: "启用或停用 {{name}}",
uninstall: "卸载",
settings: {