diff --git a/impro-plugin/docs/docs.md b/impro-plugin/docs/docs.md index 40bbd91a..77f1fecf 100644 --- a/impro-plugin/docs/docs.md +++ b/impro-plugin/docs/docs.md @@ -2699,8 +2699,9 @@ One token in a rich-text stream — `text`, `facet`, `inline`, or `block`. > **fetch**(`url`, `init?`): `Promise`\<[`PluginResponse`](#pluginresponse)\> -Proxied fetch through the host. Requires the `"networkRequest"` permission -scope and the target URL must be covered by the plugin's manifest allowlist. +Proxied fetch through the host. The target URL must match one of the +`permissions.fetch` URL patterns declared in the plugin's manifest, which +the user grants at install. `init` accepts `method`, `headers` (plain object, `Headers`, `Map`, or `[name, value]` iterable), and a string `body`. Resolves to a [PluginResponse](#pluginresponse). @@ -2733,3 +2734,46 @@ Convenience wrapper that returns a new [FlattenedTokens](#flattenedtokens) for ` #### Returns [`FlattenedTokens`](#flattenedtokens) + +*** + +### getUserGrantedFetchOrigins() + +> **getUserGrantedFetchOrigins**(): `Promise`\<`string`[]\> + +The origins the user has granted this plugin, as fetch patterns +(`https://example.com/*`). Manifest-declared permissions are not included. + +#### Returns + +`Promise`\<`string`[]\> + +*** + +### requestFetchPermission() + +> **requestFetchPermission**(`url`): `Promise`\<`boolean`\> + +Asks the user to grant this plugin network access to `url`'s origin — for +endpoints the plugin can't know in advance, such as a user-supplied API +provider. Requires `permissions.userFetch` in the manifest. + +Grants are origin-scoped (scheme, host, and port; the path is ignored) and +persist until the user revokes them in the app's plugin settings. Resolves +`true` without prompting if the origin is already permitted, so it is safe +to call before every request. Resolves `false` if the user declines, if +`url` can't be an origin, or if a prompt for this plugin is already open. + +Any credential for the endpoint is the plugin's to hold — use +[Plugin.saveLocalData](#savelocaldata) rather than [Plugin.saveData](#savedata), which +syncs through the user's account preferences. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `url` | `string` | + +#### Returns + +`Promise`\<`boolean`\> diff --git a/impro-plugin/main.d.ts b/impro-plugin/main.d.ts index d2713ca4..54d48cb1 100644 --- a/impro-plugin/main.d.ts +++ b/impro-plugin/main.d.ts @@ -1,6 +1,7 @@ /** - * Proxied fetch through the host. Requires the `"networkRequest"` permission - * scope and the target URL must be covered by the plugin's manifest allowlist. + * Proxied fetch through the host. The target URL must match one of the + * `permissions.fetch` URL patterns declared in the plugin's manifest, which + * the user grants at install. * `init` accepts `method`, `headers` (plain object, `Headers`, `Map`, or * `[name, value]` iterable), and a string `body`. Resolves to a * {@link PluginResponse}. @@ -9,6 +10,30 @@ * @returns {Promise} */ export function fetch(url: string, init?: PluginFetchInit): Promise; +/** + * Asks the user to grant this plugin network access to `url`'s origin — for + * endpoints the plugin can't know in advance, such as a user-supplied API + * provider. Requires `permissions.userFetch` in the manifest. + * + * Grants are origin-scoped (scheme, host, and port; the path is ignored) and + * persist until the user revokes them in the app's plugin settings. Resolves + * `true` without prompting if the origin is already permitted, so it is safe + * to call before every request. Resolves `false` if the user declines, if + * `url` can't be an origin, or if a prompt for this plugin is already open. + * + * Any credential for the endpoint is the plugin's to hold — use + * {@link Plugin.saveLocalData} rather than {@link Plugin.saveData}, which + * syncs through the user's account preferences. + * @param {string} url + * @returns {Promise} + */ +export function requestFetchPermission(url: string): Promise; +/** + * The origins the user has granted this plugin, as fetch patterns + * (`https://example.com/*`). Manifest-declared permissions are not included. + * @returns {Promise} + */ +export function getUserGrantedFetchOrigins(): Promise; /** * Convenience wrapper that returns a new {@link FlattenedTokens} for `tokens`. * @param {RichTextToken[]} tokens diff --git a/impro-plugin/main.js b/impro-plugin/main.js index 84e43327..7a4b7214 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -539,8 +539,9 @@ export class App { } /** - * Proxied fetch through the host. Requires the `"networkRequest"` permission - * scope and the target URL must be covered by the plugin's manifest allowlist. + * Proxied fetch through the host. The target URL must match one of the + * `permissions.fetch` URL patterns declared in the plugin's manifest, which + * the user grants at install. * `init` accepts `method`, `headers` (plain object, `Headers`, `Map`, or * `[name, value]` iterable), and a string `body`. Resolves to a * {@link PluginResponse}. @@ -555,6 +556,40 @@ export async function fetch(url, init = {}) { return new PluginResponse(result); } +/** + * Asks the user to grant this plugin network access to `url`'s origin — for + * endpoints the plugin can't know in advance, such as a user-supplied API + * provider. Requires `permissions.userFetch` in the manifest. + * + * Grants are origin-scoped (scheme, host, and port; the path is ignored) and + * persist until the user revokes them in the app's plugin settings. Resolves + * `true` without prompting if the origin is already permitted, so it is safe + * to call before every request. Resolves `false` if the user declines, if + * `url` can't be an origin, or if a prompt for this plugin is already open. + * + * Any credential for the endpoint is the plugin's to hold — use + * {@link Plugin.saveLocalData} rather than {@link Plugin.saveData}, which + * syncs through the user's account preferences. + * @param {string} url + * @returns {Promise} + */ +export async function requestFetchPermission(url) { + return /** @type {boolean} */ ( + await hostCall("requestFetchPermission", { url }) + ); +} + +/** + * The origins the user has granted this plugin, as fetch patterns + * (`https://example.com/*`). Manifest-declared permissions are not included. + * @returns {Promise} + */ +export async function getUserGrantedFetchOrigins() { + return /** @type {string[]} */ ( + await hostCall("getUserGrantedFetchOrigins", {}) + ); +} + /** * @param {unknown} value * @returns {value is { forEach: (callback: (value: string, name: string) => void) => void }} diff --git a/src/css/style.css b/src/css/style.css index e2b6c029..c98b8bcb 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -7350,6 +7350,13 @@ context-menu-item-group > context-menu-item > button { word-break: break-all; } +.permission-prompt-note { + display: block; + margin-top: 12px; + font-size: 13px; + color: var(--text-color-muted); +} + @media (hover: hover) { .modal-dialog-message a:hover { text-decoration: underline; @@ -10638,6 +10645,55 @@ rendered-markdown ul:has(> li > input[type="checkbox"]) { color: var(--text-color); } +.plugin-system-settings { + display: block; + margin-top: 16px; +} + +.plugin-system-settings-bar { + padding: 8px 16px; + background-color: var(--background-color-secondary); + border-top: 1px solid var(--generic-border-color); + border-bottom: 1px solid var(--generic-border-color); + color: var(--text-color-muted); +} + +.plugin-system-settings-body { + padding: 16px; +} + +.plugin-network-access-title { + font-size: 15px; + font-weight: 600; +} + +.plugin-network-access-description { + margin-top: 4px; + font-size: 13px; + color: var(--text-color-muted); +} + +.plugin-network-access-list { + margin-top: 12px; + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; +} + +.plugin-network-access-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.plugin-network-access-item code { + font-family: var(--monospace-font, monospace); + font-size: 13px; + word-break: break-all; +} + .plugin-content { --plugin-prose-inset: 0px; } diff --git a/src/js/plugins/pluginModal.js b/src/js/plugins/pluginModal.js index 89aff693..be29de55 100644 --- a/src/js/plugins/pluginModal.js +++ b/src/js/plugins/pluginModal.js @@ -118,13 +118,15 @@ const ACTION_LABELS = { 'Send feed feedback (e.g. "show fewer/more like this") on your behalf', }; -function permissionsSectionTemplate({ title, items }) { +function permissionsSectionTemplate({ title, items = [] }) { return html`
${title}
-
    - ${items.map((item) => html`
  • ${item}
  • `)} -
+ ${items.length > 0 + ? html`
    + ${items.map((item) => html`
  • ${item}
  • `)} +
` + : ""}
`; } @@ -140,6 +142,13 @@ function permissionsListTemplate({ permissions }) { }), ); } + if (permissions.userFetch) { + sections.push( + permissionsSectionTemplate({ + title: "Send network requests to user-specified domains", + }), + ); + } const actionScopes = permissions.actions ?? []; if (actionScopes.length > 0) { sections.push( @@ -169,6 +178,25 @@ export async function showPluginInstallPermissionsModal({ ); } +export async function showPluginFetchPermissionModal({ pluginName, origin }) { + const name = pluginName ?? "This plugin"; + return confirmModal( + html` + ${name} wants permission to: +
+ ${permissionsSectionTemplate({ + title: "Send network requests to:", + items: [html`${origin}`], + })} +
`, + { + title: "Allow network access?", + confirmButtonText: "Allow", + }, + ); +} + export async function showPluginUpdatePermissionsModal({ pluginName, pluginVersion, diff --git a/src/js/plugins/pluginPermissions.js b/src/js/plugins/pluginPermissions.js index 0b3ddbfd..771bfaa8 100644 --- a/src/js/plugins/pluginPermissions.js +++ b/src/js/plugins/pluginPermissions.js @@ -17,6 +17,7 @@ export function parsePermissions(permissions) { ); if (fetchPatterns.length > 0) parsed.fetch = fetchPatterns; } + if (permissions.userFetch === true) parsed.userFetch = true; if (permissions.actions) { const actionsArray = Array.isArray(permissions.actions) ? permissions.actions @@ -39,8 +40,17 @@ export function diffPermissions(current, next) { const diff = {}; let hasAny = false; for (const key of Object.keys(next)) { - const have = new Set(current[key] ?? []); - const added = (next[key] ?? []).filter((entry) => !have.has(entry)); + const nextValue = next[key]; + // Scope flags (userFetch) are booleans, not pattern lists + if (!Array.isArray(nextValue)) { + if (nextValue && !current[key]) { + diff[key] = nextValue; + hasAny = true; + } + continue; + } + const have = new Set(Array.isArray(current[key]) ? current[key] : []); + const added = nextValue.filter((entry) => !have.has(entry)); if (added.length > 0) { diff[key] = added; hasAny = true; @@ -50,11 +60,45 @@ export function diffPermissions(current, next) { } export function isEmptyPermissions(obj) { - return Object.values(obj).every( - (entries) => !Array.isArray(entries) || entries.length === 0, + return Object.values(obj).every((value) => + Array.isArray(value) ? value.length === 0 : !value, ); } +export function isUserFetchAllowed(permissions) { + return permissions.userFetch === true; +} + +// Canonicalizes a plugin-supplied URL into an origin-scoped fetch pattern, +// or null if it can't be one. User grants cover a whole origin: path is +// discarded, port kept. +export function normalizeFetchOrigin(url) { + let parsedUrl = null; + try { + parsedUrl = new URL(url); + } catch { + return null; + } + if (parsedUrl.username || parsedUrl.password) return null; + const host = parsedUrl.hostname.toLowerCase(); + if (!host || host.includes("*")) return null; + if (parsedUrl.protocol === "http:") { + if (!isLoopbackHost(host)) return null; + } else if (parsedUrl.protocol !== "https:") { + return null; + } + const port = parsedUrl.port ? `:${parsedUrl.port}` : ""; + return `${parsedUrl.protocol}//${host}${port}/*`; +} + +// Sanitizes stored user-granted origins into canonical fetch patterns. The +// installed-plugins list lives in the user's preferences record, we need to +// sanitize before using it. +export function parseUserGrantedFetchOrigins(origins) { + if (!Array.isArray(origins)) return []; + return unique(origins.map(normalizeFetchOrigin).filter(Boolean)); +} + export function isFetchAllowed(url, permissions) { let parsedUrl = null; try { diff --git a/src/js/plugins/pluginPreferencesManager.js b/src/js/plugins/pluginPreferencesManager.js index 63a8829c..770aac7f 100644 --- a/src/js/plugins/pluginPreferencesManager.js +++ b/src/js/plugins/pluginPreferencesManager.js @@ -1,4 +1,5 @@ import { Signal, ReactiveStore, ComputedMap } from "/js/signals.js"; +import { parseUserGrantedFetchOrigins } from "/js/plugins/pluginPermissions.js"; // Handles persisting plugin settings in user preferences export class PluginPreferencesManager extends ReactiveStore { @@ -81,6 +82,28 @@ export class PluginPreferencesManager extends ReactiveStore { })); } + // User-granted fetch origins are kept apart from the manifest permissions. + async addUserGrantedFetchOrigin(pluginId, origin) { + await this.updateInstalledPlugin(pluginId, (entry) => { + const granted = parseUserGrantedFetchOrigins( + entry.userGrantedFetchOrigins, + ); + if (granted.includes(origin)) { + return { ...entry, userGrantedFetchOrigins: granted }; + } + return { ...entry, userGrantedFetchOrigins: [...granted, origin] }; + }); + } + + async removeUserGrantedFetchOrigin(pluginId, origin) { + await this.updateInstalledPlugin(pluginId, (entry) => ({ + ...entry, + userGrantedFetchOrigins: parseUserGrantedFetchOrigins( + entry.userGrantedFetchOrigins, + ).filter((granted) => granted !== origin), + })); + } + readSettingsForPlugin(pluginId) { return this.preferencesProvider .requirePreferences() diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index 8dc4416b..7263f2e4 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -5,6 +5,7 @@ import { hidePluginModal, showPluginInstallPermissionsModal, showPluginUpdatePermissionsModal, + showPluginFetchPermissionModal, } from "/js/plugins/pluginModal.js"; import { showPluginToast, hidePluginToast, showToast } from "/js/toasts.js"; import { PluginRenderer } from "/js/plugins/pluginRendering.js"; @@ -31,6 +32,9 @@ import { diffPermissions, isEmptyPermissions, isActionAllowed, + isUserFetchAllowed, + parseUserGrantedFetchOrigins, + normalizeFetchOrigin, } from "/js/plugins/pluginPermissions.js"; import { compareVersions, groupBy, isDev, sortBy } from "/js/utils.js"; import { Signal, SignalMap, SignalSet, ReactiveStore } from "/js/signals.js"; @@ -48,6 +52,17 @@ function requireHostMethodArg(method, name, value) { export const PLUGIN_PREVIEW_QUERY_PARAM = "plugin-preview"; +function getGrantedOriginsForPlugin(entry) { + return parseUserGrantedFetchOrigins(entry?.userGrantedFetchOrigins); +} + +// Whether the plugin's settings page has a host-owned section to render. +// Keyed off the same normalization the section uses, so the settings link +// can't point at an empty section. +function hasSystemSettings(entry) { + return getGrantedOriginsForPlugin(entry).length > 0; +} + // Page id must also be a valid URL segment const PAGE_ID_PATTERN = /^[a-z0-9-]+$/; @@ -173,6 +188,7 @@ export class PluginService extends ReactiveStore { author: entry.author, enabled: entry.enabled, hasSettings: this.$settingTabs.get(entry.id) !== null, + hasSystemSettings: hasSystemSettings(entry), })); }); this.$settingTabs = new SignalMap(); @@ -205,6 +221,7 @@ export class PluginService extends ReactiveStore { ? new PluginLocalDataStore(session.did) : new PluginMemoryDataStore(); this.isPreviewMode = false; + this._pendingFetchPermissionRequests = new Set(); this._dataLayer = dataLayer; this._hiddenFeedItemsStore = hiddenFeedItemsStore; this._setupRegistries(); @@ -521,6 +538,15 @@ export class PluginService extends ReactiveStore { return pluginFetch(permissions, url, init); }); + this.pluginBridge.addHostMethod( + "requestFetchPermission", + (plugin, { url }) => this._requestFetchPermission(plugin.pluginId, url), + ); + + this.pluginBridge.addHostMethod("getUserGrantedFetchOrigins", (plugin) => + this.getUserGrantedFetchOrigins(plugin.pluginId), + ); + this.pluginBridge.addHostMethod("getPost", async (plugin, { uri }) => { try { return await this._dataLayer.declarative.ensurePost(uri); @@ -671,7 +697,50 @@ export class PluginService extends ReactiveStore { _getPermissionsForPlugin(pluginId) { const entry = this.prefManager.$installedPlugin.get(pluginId); - return parsePermissions(entry?.permissions ?? {}); + const permissions = parsePermissions(entry?.permissions ?? {}); + if (!isUserFetchAllowed(permissions)) return permissions; + const granted = this.getUserGrantedFetchOrigins(pluginId); + if (granted.length === 0) return permissions; + return { + ...permissions, + fetch: [...(permissions.fetch ?? []), ...granted], + }; + } + + async _requestFetchPermission(pluginId, url) { + const entry = this.prefManager.$installedPlugin.get(pluginId); + if (!isUserFetchAllowed(parsePermissions(entry?.permissions ?? {}))) { + throw new Error(`"${pluginId}" does not have the "userFetch" permission`); + } + const origin = normalizeFetchOrigin(url); + if (!origin) return false; + if (this._getPermissionsForPlugin(pluginId).fetch?.includes(origin)) { + return true; + } + if (this._pendingFetchPermissionRequests.has(pluginId)) return false; + this._pendingFetchPermissionRequests.add(pluginId); + let granted = false; + try { + granted = await showPluginFetchPermissionModal({ + pluginName: entry?.name ?? null, + origin, + }); + } finally { + this._pendingFetchPermissionRequests.delete(pluginId); + } + if (!granted) return false; + await this.prefManager.addUserGrantedFetchOrigin(pluginId, origin); + return true; + } + + async revokeUserGrantedFetchOrigin(pluginId, origin) { + await this.prefManager.removeUserGrantedFetchOrigin(pluginId, origin); + } + + getUserGrantedFetchOrigins(pluginId) { + return getGrantedOriginsForPlugin( + this.prefManager.$installedPlugin.get(pluginId), + ); } _requireActionPermission(plugin, action) { @@ -1022,14 +1091,23 @@ export class PluginService extends ReactiveStore { if (!accepted) throw new PermissionsDeclinedError(); } const { name, version, author, description } = liveManifest; - await this.prefManager.updateInstalledPlugin(pluginId, (entry) => ({ - ...entry, - name, - version, - author, - description, - permissions, - })); + const keepUserGrantedFetch = isUserFetchAllowed(permissions); + await this.prefManager.updateInstalledPlugin(pluginId, (entry) => { + const next = { + ...entry, + name, + version, + author, + description, + permissions, + }; + // Drop stored grants when the new manifest no longer requests + // userFetch, so a later manifest that re-adds the scope starts fresh. + if (!keepUserGrantedFetch && next.userGrantedFetchOrigins) { + delete next.userGrantedFetchOrigins; + } + return next; + }); await this.pluginBridge.reloadPlugin( pluginId, version, diff --git a/src/js/views/installedPlugins.view.js b/src/js/views/installedPlugins.view.js index d5f572b1..510bbcea 100644 --- a/src/js/views/installedPlugins.view.js +++ b/src/js/views/installedPlugins.view.js @@ -304,7 +304,8 @@ class InstalledPluginsView extends View { : "Update"} ` : ""} - ${plugin.enabled && plugin.hasSettings + ${plugin.enabled && + (plugin.hasSettings || plugin.hasSystemSettings) ? html` +
+ +
+ `; +} + +function systemSettingsTemplate({ origins, onRevoke }) { + return html`
+
System settings
+
+

Network access

+

+ You granted this plugin permission to send requests to these addresses. +

+
    + ${origins.map( + (origin) => + html`
  • + ${origin} + +
  • `, + )} +
+
+
`; +} class PluginSettingsView extends View { async render({ root, router, layout, params, context: { pluginService } }) { @@ -74,25 +123,40 @@ class PluginSettingsView extends View { ${pluginLoadError.message ?? "This plugin failed to load."}

`; } + const grantedOrigins = + pluginService.getUserGrantedFetchOrigins(pluginId); if (!settingTab) { if (pluginLoading) { return html`
`; } - return html`

- This plugin has no settings. -

`; + if (grantedOrigins.length === 0) { + return html`

+ This plugin has no settings. +

`; + } } - return html`
- -
`; + return html`${settingTab + ? pluginOwnedSettingsTemplate({ pluginService, settingTab }) + : ""} + ${grantedOrigins.length > 0 + ? systemSettingsTemplate({ + origins: grantedOrigins, + onRevoke: (origin) => + pluginService + .revokeUserGrantedFetchOrigin(pluginId, origin) + .catch((e) => { + console.error(e); + showToast("Failed to revoke access", { + style: "error", + }); + }), + }) + : ""}`; })()} `, diff --git a/tests/e2e/specs/views/installedPlugins.view.test.js b/tests/e2e/specs/views/installedPlugins.view.test.js index 758ee692..10b6b3cb 100644 --- a/tests/e2e/specs/views/installedPlugins.view.test.js +++ b/tests/e2e/specs/views/installedPlugins.view.test.js @@ -1,7 +1,11 @@ import { test, expect } from "../../base.js"; import { login } from "../../helpers.js"; import { MockServer } from "../../mockServer.js"; -import { TEST_PLUGIN_ID, TEST_PLUGIN_MANIFEST } from "../../testPlugins.js"; +import { + TEST_PLUGIN_ID, + TEST_PLUGIN_MANIFEST, + getNoSettingsPluginSource, +} from "../../testPlugins.js"; function seedInstalled(mockServer) { mockServer.installedPlugins = [{ ...TEST_PLUGIN_MANIFEST, enabled: false }]; @@ -94,6 +98,31 @@ test.describe("Installed plugins view", () => { }); }); + test("shows the Settings link for a plugin whose only settings are system-owned", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.localPluginSource = getNoSettingsPluginSource(); + await mockServer.setup(page); + await login(page); + mockServer.installedPlugins = [ + { + ...TEST_PLUGIN_MANIFEST, + enabled: true, + permissions: { userFetch: true }, + userGrantedFetchOrigins: ["https://api.example.com/*"], + }, + ]; + + await page.goto("/plugins/installed"); + const sampleItem = page.locator(".plugin-list-item", { + hasText: "Test Plugin", + }); + await expect(sampleItem.locator(".plugin-settings-link")).toBeVisible({ + timeout: 10000, + }); + }); + test("uninstall button removes the plugin after confirmation", async ({ page, }) => { diff --git a/tests/e2e/specs/views/pluginSettings.view.test.js b/tests/e2e/specs/views/pluginSettings.view.test.js index f5b4811d..34514598 100644 --- a/tests/e2e/specs/views/pluginSettings.view.test.js +++ b/tests/e2e/specs/views/pluginSettings.view.test.js @@ -12,6 +12,8 @@ import { getFailingPluginSource, getNoSettingsPluginSource, getTestPluginSource, + getFetchPermissionPluginSource, + REQUESTED_FETCH_ORIGIN, } from "../../testPlugins.js"; const PLUGIN_ID = TEST_PLUGIN_ID; @@ -337,6 +339,121 @@ test.describe("Plugin settings view", () => { ).toBeVisible({ timeout: 10000 }); }); + test.describe("User-granted fetch origins", () => { + function seedPromptingPlugin(mockServer) { + mockServer.installedPlugins = [ + { + ...TEST_PLUGIN_MANIFEST, + enabled: true, + permissions: { userFetch: true }, + }, + ]; + } + + test("grants an origin from the prompt and lists it in settings", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.localPluginSource = getFetchPermissionPluginSource(); + await mockServer.setup(page); + await login(page); + seedPromptingPlugin(mockServer); + + await page.goto(`/plugin/${PLUGIN_ID}/settings`); + await expect( + page.locator('[data-testid="fetch-permission-prompt"]'), + ).toBeVisible({ timeout: 10000 }); + await page.locator('[data-testid="modal-confirm-button"]').click(); + + const networkAccess = page.locator( + '[data-testid="plugin-network-access"]', + ); + await expect(networkAccess).toBeVisible({ timeout: 10000 }); + await expect(networkAccess).toContainText(REQUESTED_FETCH_ORIGIN); + }); + + test("revoking removes the origin", async ({ page }) => { + const mockServer = new MockServer(); + mockServer.localPluginSource = getFetchPermissionPluginSource(); + await mockServer.setup(page); + await login(page); + seedPromptingPlugin(mockServer); + + await page.goto(`/plugin/${PLUGIN_ID}/settings`); + await page + .locator('[data-testid="modal-confirm-button"]') + .click({ timeout: 10000 }); + const networkAccess = page.locator( + '[data-testid="plugin-network-access"]', + ); + await expect(networkAccess).toBeVisible({ timeout: 10000 }); + + await networkAccess + .locator('[data-testid="revoke-fetch-origin"]') + .click(); + await expect(networkAccess).toBeHidden({ timeout: 10000 }); + }); + + test("renders plugin-owned and system-owned settings as separate sections", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.localPluginSource = getTestPluginSource(); + await mockServer.setup(page); + await login(page); + mockServer.installedPlugins = [ + { + ...TEST_PLUGIN_MANIFEST, + enabled: true, + permissions: { userFetch: true }, + userGrantedFetchOrigins: [REQUESTED_FETCH_ORIGIN], + }, + ]; + + await page.goto(`/plugin/${PLUGIN_ID}/settings`); + await expect( + page.locator('[data-testid="plugin-owned-settings"]'), + ).toBeVisible({ timeout: 10000 }); + await expect( + page.locator('[data-testid="plugin-system-settings"]'), + ).toBeVisible(); + }); + + test("omits the system section when nothing is granted", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.localPluginSource = getTestPluginSource(); + await mockServer.setup(page); + await login(page); + seedEnabled(mockServer); + + await page.goto(`/plugin/${PLUGIN_ID}/settings`); + await expect( + page.locator('[data-testid="plugin-owned-settings"]'), + ).toBeVisible({ timeout: 10000 }); + await expect( + page.locator('[data-testid="plugin-system-settings"]'), + ).toBeHidden(); + }); + + test("declining leaves nothing granted", async ({ page }) => { + const mockServer = new MockServer(); + mockServer.localPluginSource = getFetchPermissionPluginSource(); + await mockServer.setup(page); + await login(page); + seedPromptingPlugin(mockServer); + + await page.goto(`/plugin/${PLUGIN_ID}/settings`); + await page + .locator('[data-testid="modal-cancel-button"]') + .click({ timeout: 10000 }); + await expect( + page.locator('[data-testid="plugin-network-access"]'), + ).toBeHidden(); + }); + }); + test.describe("Logged-out behavior", () => { test("redirects to /login when not authenticated", async ({ page }) => { await page.goto(`/plugin/${PLUGIN_ID}/settings`); diff --git a/tests/e2e/testPlugins.js b/tests/e2e/testPlugins.js index c92b6299..1d6bcacd 100644 --- a/tests/e2e/testPlugins.js +++ b/tests/e2e/testPlugins.js @@ -256,6 +256,20 @@ class TestPlugin extends Plugin { TestPlugin.register(); `; +// Asks for a user-granted fetch origin as soon as it loads, so the host's +// permission prompt appears without needing plugin-rendered UI to trigger it. +export const REQUESTED_FETCH_ORIGIN = "https://api.example.com/*"; + +const FETCH_PERMISSION_PLUGIN_BODY = /* js */ ` +class TestPlugin extends Plugin { + async onload() { + await requestFetchPermission("https://api.example.com/v1/chat"); + } +} + +TestPlugin.register(); +`; + // A plugin that seeds the composer with a signature string on every open // (post and reply). Used by composer-init e2e tests. const POST_COMPOSER_INIT_PLUGIN_BODY = /* js */ ` @@ -348,6 +362,10 @@ export function getNoSettingsPluginSource() { return getWorkerSource() + "\n" + NO_SETTINGS_PLUGIN_BODY; } +export function getFetchPermissionPluginSource() { + return getWorkerSource() + "\n" + FETCH_PERMISSION_PLUGIN_BODY; +} + export function getPostComposerInitPluginSource() { return getWorkerSource() + "\n" + POST_COMPOSER_INIT_PLUGIN_BODY; } diff --git a/tests/unit/specs/plugins/pluginModal.test.js b/tests/unit/specs/plugins/pluginModal.test.js index 61b9afec..3b90267d 100644 --- a/tests/unit/specs/plugins/pluginModal.test.js +++ b/tests/unit/specs/plugins/pluginModal.test.js @@ -4,8 +4,11 @@ import { showPluginModal as _showPluginModal, updatePluginModal as _updatePluginModal, hidePluginModal, + showPluginInstallPermissionsModal, + showPluginFetchPermissionModal, } from "/js/plugins/pluginModal.js"; import { PluginRenderer } from "/js/plugins/pluginRendering.js"; +import { respondToConfirm, waitFor } from "../../testHelpers.js"; function showPluginModal(opts) { const pluginRenderer = new PluginRenderer(null, opts.pluginId); @@ -442,3 +445,39 @@ describe("hidePluginModal", () => { assert.deepEqual(onDismiss.mock.callCount(), 0); }); }); + +describe("permission prompts", () => { + it("describes a userFetch scope in the install prompt", async () => { + const prompting = showPluginInstallPermissionsModal({ + pluginName: "Alpha", + permissions: { userFetch: true }, + }); + await waitFor(() => + document.querySelector('[data-testid="permission-prompt"]'), + ); + const prompt = document.querySelector('[data-testid="permission-prompt"]'); + // A userFetch-only manifest must not render an empty permission list + assert(prompt.querySelector(".permission-prompt-section")); + await respondToConfirm(false); + await prompting; + }); + + it("renders the requested origin in the fetch prompt", async () => { + const prompting = showPluginFetchPermissionModal({ + pluginName: "Alpha", + origin: "https://api.example.com/*", + }); + await waitFor(() => + document.querySelector('[data-testid="fetch-permission-prompt"]'), + ); + const prompt = document.querySelector( + '[data-testid="fetch-permission-prompt"]', + ); + assert.equal( + prompt.querySelector("code").textContent, + "https://api.example.com/*", + ); + await respondToConfirm(false); + assert.equal(await prompting, false); + }); +}); diff --git a/tests/unit/specs/plugins/pluginPermissions.test.js b/tests/unit/specs/plugins/pluginPermissions.test.js index 904cf119..afc20c8b 100644 --- a/tests/unit/specs/plugins/pluginPermissions.test.js +++ b/tests/unit/specs/plugins/pluginPermissions.test.js @@ -6,6 +6,9 @@ import { isEmptyPermissions, isFetchAllowed, isActionAllowed, + isUserFetchAllowed, + normalizeFetchOrigin, + parseUserGrantedFetchOrigins, } from "/js/plugins/pluginPermissions.js"; describe("parsePermissions", () => { @@ -137,6 +140,38 @@ describe("diffPermissions", () => { }); }); +describe("diffPermissions (boolean scopes)", () => { + it("reports a newly declared userFetch", () => { + assert.deepEqual(diffPermissions({}, { userFetch: true }), { + userFetch: true, + }); + }); + + it("does not report a userFetch that was already granted", () => { + assert.equal( + diffPermissions({ userFetch: true }, { userFetch: true }), + null, + ); + }); + + it("diffs patterns alongside a boolean scope", () => { + assert.deepEqual( + diffPermissions( + { fetch: ["https://a.com/*"] }, + { fetch: ["https://a.com/*", "https://b.com/*"], userFetch: true }, + ), + { fetch: ["https://b.com/*"], userFetch: true }, + ); + }); + + it("survives a stored value whose shape doesn't match the manifest", () => { + assert.deepEqual( + diffPermissions({ fetch: true }, { fetch: ["https://a.com/*"] }), + { fetch: ["https://a.com/*"] }, + ); + }); +}); + describe("isEmptyPermissions (missing-key shape)", () => { it("returns true for an empty object", () => { assert(isEmptyPermissions({})); @@ -151,6 +186,140 @@ describe("isEmptyPermissions", () => { it("returns false when any array is non-empty", () => { assert(!isEmptyPermissions({ fetch: ["https://a.com/*"] })); }); + + // Load-bearing: this is what keeps a prompting plugin out of preview + // installs, and userFetch is a boolean rather than an array. + it("returns false for a userFetch grant", () => { + assert(!isEmptyPermissions({ userFetch: true })); + }); + + it("returns true for a falsy userFetch", () => { + assert(isEmptyPermissions({ userFetch: false })); + }); +}); + +describe("parsePermissions (userFetch)", () => { + it("keeps a literal true", () => { + assert.deepEqual(parsePermissions({ userFetch: true }), { + userFetch: true, + }); + }); + + it("drops truthy non-boolean values", () => { + assert.deepEqual(parsePermissions({ userFetch: "yes" }), {}); + assert.deepEqual(parsePermissions({ userFetch: 1 }), {}); + assert.deepEqual(parsePermissions({ userFetch: false }), {}); + }); +}); + +describe("isUserFetchAllowed", () => { + it("requires the parsed flag", () => { + assert(isUserFetchAllowed({ userFetch: true })); + assert(!isUserFetchAllowed({})); + assert(!isUserFetchAllowed({ fetch: ["https://a.com/*"] })); + }); +}); + +describe("parseUserGrantedFetchOrigins", () => { + it("normalizes and de-duplicates stored origins", () => { + assert.deepEqual( + parseUserGrantedFetchOrigins([ + "https://api.example.com/v1", + "https://api.example.com/v2", + "http://localhost:11434/api", + ]), + ["https://api.example.com/*", "http://localhost:11434/*"], + ); + }); + + // The preferences record is writable by anything holding the account's + // credentials, so stored values are untrusted input + it("drops anything that isn't a normalizable origin", () => { + assert.deepEqual( + parseUserGrantedFetchOrigins([ + "https://*/*", + "http://evil.example.com/*", + "not a url", + 42, + null, + ]), + [], + ); + }); + + it("returns an empty array for a non-array value", () => { + assert.deepEqual(parseUserGrantedFetchOrigins(undefined), []); + assert.deepEqual(parseUserGrantedFetchOrigins("https://a.com/*"), []); + assert.deepEqual(parseUserGrantedFetchOrigins(null), []); + }); +}); + +describe("normalizeFetchOrigin", () => { + it("discards the path and keeps the origin", () => { + assert.equal( + normalizeFetchOrigin("https://api.example.com/v1/chat?key=1#x"), + "https://api.example.com/*", + ); + }); + + it("preserves an explicit port", () => { + assert.equal( + normalizeFetchOrigin("http://localhost:11434/api/generate"), + "http://localhost:11434/*", + ); + }); + + it("drops a default port, matching URL normalization", () => { + assert.equal( + normalizeFetchOrigin("https://example.com:443/foo"), + "https://example.com/*", + ); + }); + + it("lowercases the host", () => { + assert.equal( + normalizeFetchOrigin("https://API.Example.COM/foo"), + "https://api.example.com/*", + ); + }); + + it("allows http only for loopback", () => { + assert.equal( + normalizeFetchOrigin("http://127.0.0.1:8080/"), + "http://127.0.0.1:8080/*", + ); + assert.equal( + normalizeFetchOrigin("http://[::1]:8080/"), + "http://[::1]:8080/*", + ); + assert.equal(normalizeFetchOrigin("http://example.com/"), null); + }); + + it("rejects embedded credentials", () => { + assert.equal(normalizeFetchOrigin("https://user:pass@example.com/"), null); + assert.equal(normalizeFetchOrigin("https://user@example.com/"), null); + }); + + it("rejects non-http(s) schemes", () => { + assert.equal(normalizeFetchOrigin("ftp://example.com/"), null); + assert.equal(normalizeFetchOrigin("javascript:alert(1)"), null); + assert.equal(normalizeFetchOrigin("data:text/plain,hi"), null); + }); + + it("rejects unparseable input", () => { + assert.equal(normalizeFetchOrigin("not a url"), null); + assert.equal(normalizeFetchOrigin(""), null); + assert.equal(normalizeFetchOrigin(null), null); + }); + + it("produces a pattern that isFetchAllowed accepts for that origin only", () => { + const permissions = { + fetch: [normalizeFetchOrigin("https://api.example.com/v1")], + }; + assert(isFetchAllowed("https://api.example.com/other", permissions)); + assert(!isFetchAllowed("https://evil.example.com/", permissions)); + assert(!isFetchAllowed("http://api.example.com/", permissions)); + }); }); describe("isFetchAllowed", () => { diff --git a/tests/unit/specs/plugins/pluginService.test.js b/tests/unit/specs/plugins/pluginService.test.js index 951725d9..4f34b34b 100644 --- a/tests/unit/specs/plugins/pluginService.test.js +++ b/tests/unit/specs/plugins/pluginService.test.js @@ -9,6 +9,7 @@ import { EventEmitter } from "/js/eventEmitter.js"; import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js"; import { Constellation } from "/js/constellation.js"; import { respondToConfirm } from "../../testHelpers.js"; +import { isFetchAllowed } from "/js/plugins/pluginPermissions.js"; function emptyDataLayer() { const dataLayer = new EventEmitter(); @@ -822,6 +823,44 @@ describe("$pluginsInfo", () => { assert.deepEqual(info[0].id, "alpha"); }); + it("flags hasSystemSettings from granted fetch origins", () => { + const { service, state } = makeService({}); + state.installedPlugins = [ + { id: "alpha", name: "Alpha", version: "1.0.0", enabled: true }, + { + id: "beta", + name: "Beta", + version: "1.0.0", + enabled: true, + userGrantedFetchOrigins: ["https://api.example.com/*"], + }, + ]; + const info = service.$pluginsInfo.get(); + assert.equal( + info.find((plugin) => plugin.id === "alpha").hasSystemSettings, + false, + ); + assert.equal( + info.find((plugin) => plugin.id === "beta").hasSystemSettings, + true, + ); + }); + + it("does not flag hasSystemSettings for stored origins that can't be normalized", () => { + const { service, state } = makeService({}); + state.installedPlugins = [ + { + id: "alpha", + name: "Alpha", + version: "1.0.0", + enabled: true, + userGrantedFetchOrigins: ["https://*/*", 42], + }, + ]; + // Otherwise the settings link would point at an empty system section + assert.equal(service.$pluginsInfo.get()[0].hasSystemSettings, false); + }); + it("includes __LOCAL plugins when localPluginsEnabled is true", () => { const { service, state } = makeService({ localListings: [] }); state.installedPlugins = [ @@ -2590,3 +2629,226 @@ describe("getPostComposerInit", () => { }); }); }); + +describe("user-granted fetch origins", () => { + async function makeInstalledService({ + permissions = { userFetch: true }, + } = {}) { + const { state, provider } = makeProvider(); + const service = makeServiceWithRealBridge({ provider }); + await service.prefManager.addInstalledPlugin({ + id: "alpha", + name: "Alpha", + version: "1.0.0", + author: "Someone", + repo: "ow/alpha", + enabled: true, + permissions, + }); + return { service, state }; + } + + function callRequest(service, url) { + return service.pluginBridge._hostCallHandlers.get("requestFetchPermission")( + { pluginId: "alpha" }, + { url }, + ); + } + + function storedEntry(state) { + return state.installedPlugins.find((plugin) => plugin.id === "alpha"); + } + + it("throws for a plugin without the userFetch permission", async () => { + const { service } = await makeInstalledService({ permissions: {} }); + await assert.rejects( + () => callRequest(service, "https://api.example.com/v1"), + /userFetch/, + ); + }); + + it("grants the origin when the user confirms", async () => { + const { service, state } = await makeInstalledService(); + const requesting = callRequest(service, "https://api.example.com/v1/chat"); + await respondToConfirm(true); + assert.equal(await requesting, true); + assert.deepEqual(storedEntry(state).userGrantedFetchOrigins, [ + "https://api.example.com/*", + ]); + assert( + isFetchAllowed( + "https://api.example.com/anything", + service._getPermissionsForPlugin("alpha"), + ), + ); + }); + + it("does not write the grant into the manifest permissions", async () => { + const { service, state } = await makeInstalledService(); + const requesting = callRequest(service, "https://api.example.com/v1"); + await respondToConfirm(true); + await requesting; + // Laundering a user grant into entry.permissions would make a later + // manifest claiming the same host diff to nothing. + assert.deepEqual(storedEntry(state).permissions, { userFetch: true }); + }); + + it("resolves true without prompting when already granted", async () => { + const { service } = await makeInstalledService(); + const requesting = callRequest(service, "https://api.example.com/v1"); + await respondToConfirm(true); + await requesting; + assert.equal( + await callRequest(service, "https://api.example.com/other"), + true, + ); + assert.equal( + document.querySelector('[data-testid="fetch-permission-prompt"]'), + null, + ); + }); + + it("resolves false without prompting for a url that can't be an origin", async () => { + const { service } = await makeInstalledService(); + assert.equal(await callRequest(service, "http://example.com/"), false); + assert.equal(await callRequest(service, "not a url"), false); + assert.equal( + document.querySelector('[data-testid="fetch-permission-prompt"]'), + null, + ); + }); + + it("grants nothing when the user declines", async () => { + const { service, state } = await makeInstalledService(); + const requesting = callRequest(service, "https://api.example.com/v1"); + await respondToConfirm(false); + assert.equal(await requesting, false); + assert.equal(storedEntry(state).userGrantedFetchOrigins, undefined); + }); + + it("prompts again after a decline", async () => { + const { service, state } = await makeInstalledService(); + const declining = callRequest(service, "https://api.example.com/v1"); + await respondToConfirm(false); + await declining; + + const retrying = callRequest(service, "https://api.example.com/v1"); + await respondToConfirm(true); + assert.equal(await retrying, true); + assert.deepEqual(storedEntry(state).userGrantedFetchOrigins, [ + "https://api.example.com/*", + ]); + }); + + it("refuses a second request while a prompt is open", async () => { + const { service } = await makeInstalledService(); + const first = callRequest(service, "https://api.example.com/v1"); + const second = await callRequest(service, "https://other.example.com/v1"); + assert.equal(second, false); + await respondToConfirm(true); + assert.equal(await first, true); + }); + + it("ignores a stored grant that isn't a normalizable origin", async () => { + const { service } = await makeInstalledService(); + await service.prefManager.updateInstalledPlugin("alpha", (entry) => ({ + ...entry, + userGrantedFetchOrigins: ["https://*/*", "http://evil.example.com/*", 42], + })); + const permissions = service._getPermissionsForPlugin("alpha"); + assert.deepEqual(permissions.fetch ?? [], []); + assert(!isFetchAllowed("https://evil.example.com/", permissions)); + }); + + it("merges user grants with manifest patterns", async () => { + const { service } = await makeInstalledService({ + permissions: { userFetch: true, fetch: ["https://declared.example/*"] }, + }); + const requesting = callRequest(service, "https://granted.example/v1"); + await respondToConfirm(true); + await requesting; + const permissions = service._getPermissionsForPlugin("alpha"); + assert(isFetchAllowed("https://declared.example/x", permissions)); + assert(isFetchAllowed("https://granted.example/x", permissions)); + }); + + it("revokes a granted origin", async () => { + const { service } = await makeInstalledService(); + const requesting = callRequest(service, "https://api.example.com/v1"); + await respondToConfirm(true); + await requesting; + await service.revokeUserGrantedFetchOrigin( + "alpha", + "https://api.example.com/*", + ); + assert.deepEqual(service.getUserGrantedFetchOrigins("alpha"), []); + assert( + !isFetchAllowed( + "https://api.example.com/x", + service._getPermissionsForPlugin("alpha"), + ), + ); + }); + + it("exposes granted origins to the plugin", async () => { + const { service } = await makeInstalledService(); + const requesting = callRequest(service, "https://api.example.com/v1"); + await respondToConfirm(true); + await requesting; + const origins = await service.pluginBridge._hostCallHandlers.get( + "getUserGrantedFetchOrigins", + )({ pluginId: "alpha" }, {}); + assert.deepEqual(origins, ["https://api.example.com/*"]); + }); +}); + +describe("preview installs and userFetch", () => { + // Preview's guarantee is "runs with nothing granted"; a plugin that can + // prompt for origins at runtime must not slip through it. + it("refuses to preview a plugin declaring userFetch", async () => { + const { state, provider } = makeProvider(); + const service = makeServiceWithRealBridge({ provider }); + service.remoteRegistry = { + getListing: async () => ({ id: "alpha", repo: "ow/alpha" }), + getListings: async () => [{ id: "alpha", repo: "ow/alpha" }], + }; + service.sourceProvider = { + getLiveManifest: async () => ({ + id: "alpha", + name: "Alpha", + version: "1.0.0", + permissions: { userFetch: true }, + }), + }; + + await service._installPreviewPlugin("alpha"); + + assert.deepEqual(state.installedPlugins, []); + }); +}); + +describe("updating a plugin that adds userFetch", () => { + it("prompts with the new scope and stores it", async () => { + const { service, state } = makeService({ + remoteListings: [{ id: "alpha", repo: "ow/alpha" }], + liveManifests: { + alpha: { id: "alpha", name: "Alpha", version: "1.0.0" }, + }, + }); + await service.installPlugin("alpha"); + service.sourceProvider.getLiveManifest = async () => ({ + id: "alpha", + name: "Alpha", + version: "1.1.0", + permissions: { userFetch: true }, + }); + + const updating = service.updatePlugin("alpha"); + await respondToConfirm(true); + assert.deepEqual(await updating, { updated: true, version: "1.1.0" }); + const entry = state.installedPlugins.find( + (plugin) => plugin.id === "alpha", + ); + assert.deepEqual(entry.permissions, { userFetch: true }); + }); +});