diff --git a/docs/kernel-guide.md b/docs/kernel-guide.md index e4082b10f1..ce5491c25d 100644 --- a/docs/kernel-guide.md +++ b/docs/kernel-guide.md @@ -155,27 +155,56 @@ Only names in the vat's `globals` array are installed in the vat's compartment. The kernel ships with the following set, sourced from `@metamask/snaps-execution-environments`: -| Name | Category | Notes | -| ----------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `setTimeout` | Timer (attenuated) | Isolated per vat. Cancelled automatically on vat termination. | -| `clearTimeout` | Timer (attenuated) | Only clears timers created by the same vat. | -| `setInterval` | Timer (attenuated) | Isolated per vat. Cancelled automatically on vat termination. | -| `clearInterval` | Timer (attenuated) | Only clears intervals created by the same vat. | -| `Date` | Attenuated | Each `Date.now()` read adds up to 1 ms of random jitter, clamped monotonic non-decreasing; precise sub-millisecond timing cannot leak through. | -| `Math` | Attenuated | `Math.random()` is sourced from `crypto.getRandomValues`. **Not a CSPRNG** per the upstream NOTE — defends against stock-RNG timing side channels only. | -| `crypto` | Web Crypto | Hardened Web Crypto API. | -| `SubtleCrypto` | Web Crypto | Hardened Web Crypto API. | -| `TextEncoder` | Text codec | Plain hardened. | -| `TextDecoder` | Text codec | Plain hardened. | -| `URL` | URL | Plain hardened. | -| `URLSearchParams` | URL | Plain hardened. | -| `atob` | Base64 | Plain hardened. | -| `btoa` | Base64 | Plain hardened. | -| `AbortController` | Abort | Plain hardened. | -| `AbortSignal` | Abort | Plain hardened. | +| Name | Category | Notes | +| ----------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `setTimeout` | Timer (attenuated) | Isolated per vat. Cancelled automatically on vat termination. | +| `clearTimeout` | Timer (attenuated) | Only clears timers created by the same vat. | +| `setInterval` | Timer (attenuated) | Isolated per vat. Cancelled automatically on vat termination. | +| `clearInterval` | Timer (attenuated) | Only clears intervals created by the same vat. | +| `Date` | Attenuated | Each `Date.now()` read adds up to 1 ms of random jitter, clamped monotonic non-decreasing; precise sub-millisecond timing cannot leak through. | +| `Math` | Attenuated | `Math.random()` is sourced from `crypto.getRandomValues`. **Not a CSPRNG** per the upstream NOTE — defends against stock-RNG timing side channels only. | +| `crypto` | Web Crypto | Hardened Web Crypto API. | +| `SubtleCrypto` | Web Crypto | Hardened Web Crypto API. | +| `fetch` | Network (attenuated) | Wrapped by the Snaps network factory; teardown aborts in-flight requests and cancels open body streams on vat termination. **Requires `network.allowedHosts`** — see [Network endowment](#network-endowment). | +| `Request` | Network | Hardened constructor surfaced alongside `fetch` so vat code can build requests before calling it. | +| `Headers` | Network | Hardened constructor. | +| `Response` | Network | Hardened constructor; overrides `[Symbol.hasInstance]` so wrapped fetch results still pass `instanceof Response`. | +| `TextEncoder` | Text codec | Plain hardened. | +| `TextDecoder` | Text codec | Plain hardened. | +| `URL` | URL | Plain hardened. | +| `URLSearchParams` | URL | Plain hardened. | +| `atob` | Base64 | Plain hardened. | +| `btoa` | Base64 | Plain hardened. | +| `AbortController` | Abort | Plain hardened. | +| `AbortSignal` | Abort | Plain hardened. | "Plain hardened" means the value is the host's implementation wrapped with `harden()` — it behaves identically to the browser/Node version. "Attenuated" means the value is a deliberate reimplementation with different semantics; the Notes column flags the relevant differences. The canonical list lives in [`endowments.ts`](../packages/ocap-kernel/src/vats/endowments.ts). +### Network endowment + +`fetch`, `Request`, `Headers`, and `Response` are only available when the vat also declares a per-vat host allowlist in `VatConfig.network.allowedHosts`: + +```ts +await kernel.launchSubcluster({ + bootstrap: 'worker', + vats: { + worker: { + bundleSpec: '...', + globals: ['fetch', 'Request', 'Headers', 'Response'], + network: { allowedHosts: ['api.example.com', 'api.github.com'] }, + }, + }, +}); +``` + +Requesting `'fetch'` without an `allowedHosts` entry (or with an absent `network` block) fails `initVat` with `Vat "" requested "fetch" but no network.allowedHosts was specified`. There is no implicit allow-all; an empty `allowedHosts: []` is legal but rejects every outbound host. Host matching is a case-sensitive exact comparison against `URL.hostname` — ports and schemes are not considered, so `allowedHosts: ['api.example.com']` accepts both `http://api.example.com` and `https://api.example.com:8443`. `file://` URLs are **rejected** by fetch — use the `fs` platform capability for filesystem access. + +Lifecycle notes: + +- The network factory reads `globalThis.fetch` at call time — host applications that need to stub it (e.g., tests) should override the global before constructing the `VatSupervisor`. +- Teardown cancels in-flight requests and open body streams. It runs as part of `VatSupervisor.terminate()` alongside timer teardown. +- `fetch` returns a `ResponseWrapper` rather than the raw `Response`; the endowed `Response` constructor is patched so `instanceof Response` still returns `true` for wrapper instances. + ### Restricting or replacing the allowed set Two levers, applied at different layers: @@ -536,8 +565,9 @@ type VatConfig = { bundleName?: string; // Name of a pre-registered bundle creationOptions?: Record; // Options for vat creation parameters?: Record; // Static parameters passed to buildRootObject - platformConfig?: Partial; // Platform-specific configuration - globals?: string[]; // Host/Web API globals the vat requests — see [Vat Endowments](#vat-endowments) + platformConfig?: Partial; // Platform-specific configuration (currently `fs` only) + globals?: AllowedGlobalName[]; // Host/Web API globals the vat requests — see [Vat Endowments](#vat-endowments) + network?: { allowedHosts: string[] }; // Host allowlist required when requesting `fetch` }; // Configuration for a system subcluster diff --git a/docs/usage.md b/docs/usage.md index 149dda3e1f..f3d9982cb0 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -207,6 +207,21 @@ A vat can also request host/Web API globals (timers, `Date`, `crypto`, `URL`, } ``` +Network access is a special case: requesting `fetch` (and optionally `Request`/`Headers`/`Response`) also requires a per-vat host allowlist under `network.allowedHosts`. Without it, `initVat` rejects the vat. + +```json +{ + "bootstrap": "alice", + "vats": { + "alice": { + "bundleSpec": "http://localhost:3000/sample-vat.bundle", + "globals": ["fetch", "Request", "Headers", "Response"], + "network": { "allowedHosts": ["api.example.com"] } + } + } +} +``` + See [Vat Endowments](./kernel-guide.md#vat-endowments) in the kernel guide for the full list and for how to narrow the set with `Kernel.make({ allowedGlobalNames })`. ## Kernel API diff --git a/packages/evm-wallet-experiment/docs/setup-guide.md b/packages/evm-wallet-experiment/docs/setup-guide.md index 85a7731cdb..0c59ffc974 100644 --- a/packages/evm-wallet-experiment/docs/setup-guide.md +++ b/packages/evm-wallet-experiment/docs/setup-guide.md @@ -472,8 +472,8 @@ yarn ocap daemon exec launchSubcluster '{ }, "provider": { "bundleSpec": "packages/evm-wallet-experiment/src/vats/provider-vat.bundle", - "globals": ["TextEncoder", "TextDecoder"], - "platformConfig": { "fetch": { "allowedHosts": [".infura.io", "api.pimlico.io", "swap.api.cx.metamask.io"] } } + "globals": ["TextEncoder", "TextDecoder", "fetch", "Request", "Headers", "Response"], + "network": { "allowedHosts": [".infura.io", "api.pimlico.io", "swap.api.cx.metamask.io"] } }, "delegation": { "bundleSpec": "packages/evm-wallet-experiment/src/vats/delegation-vat.bundle", diff --git a/packages/evm-wallet-experiment/src/cluster-config.ts b/packages/evm-wallet-experiment/src/cluster-config.ts index 44f011631b..4486464cbf 100644 --- a/packages/evm-wallet-experiment/src/cluster-config.ts +++ b/packages/evm-wallet-experiment/src/cluster-config.ts @@ -62,10 +62,17 @@ export function makeWalletClusterConfig( }, provider: { bundleSpec: `${bundleBaseUrl}/provider-vat.bundle`, - globals: ['TextEncoder', 'TextDecoder'], - platformConfig: { - fetch: allowedHosts ? { allowedHosts } : {}, - }, + globals: allowedHosts + ? [ + 'TextEncoder', + 'TextDecoder', + 'fetch', + 'Request', + 'Headers', + 'Response', + ] + : ['TextEncoder', 'TextDecoder'], + ...(allowedHosts ? { network: { allowedHosts } } : {}), }, ...auxiliaryVat, }, diff --git a/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts b/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts index 6edb24862d..4b71b69251 100644 --- a/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts +++ b/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts @@ -71,8 +71,15 @@ export function launchWalletSubcluster( }, provider: { bundleSpec: `${BUNDLE_BASE}/provider-vat.bundle`, - globals: ['TextEncoder', 'TextDecoder'], - platformConfig: { fetch: { allowedHosts } }, + globals: [ + 'TextEncoder', + 'TextDecoder', + 'fetch', + 'Request', + 'Headers', + 'Response', + ], + network: { allowedHosts }, }, ...auxiliaryVat, }, diff --git a/packages/kernel-node-runtime/CHANGELOG.md b/packages/kernel-node-runtime/CHANGELOG.md index 5d0949c459..e05f20b8c6 100644 --- a/packages/kernel-node-runtime/CHANGELOG.md +++ b/packages/kernel-node-runtime/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** Drop `platformOptions.fetch` from `makeNodeJsVatSupervisor` ([#942](https://github.com/MetaMask/ocap-kernel/pull/942)) + - `fetch` is now a vat endowment; stub `globalThis.fetch` directly if needed + ## [0.1.0] ### Added diff --git a/packages/kernel-node-runtime/src/vat/vat-worker.ts b/packages/kernel-node-runtime/src/vat/vat-worker.ts index c08d2f17d4..af43cc3cd8 100644 --- a/packages/kernel-node-runtime/src/vat/vat-worker.ts +++ b/packages/kernel-node-runtime/src/vat/vat-worker.ts @@ -22,7 +22,6 @@ async function main(): Promise { const { logger: streamLogger } = await makeNodeJsVatSupervisor( vatId, LOG_TAG, - { fetch: { fromFetch: fetch } }, ); logger = streamLogger; logger.debug('vat-worker main'); diff --git a/packages/kernel-platforms/CHANGELOG.md b/packages/kernel-platforms/CHANGELOG.md index ee5c2508e2..a2ca27ec05 100644 --- a/packages/kernel-platforms/CHANGELOG.md +++ b/packages/kernel-platforms/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed + +- **BREAKING:** Remove the `fetch` platform capability and its exports (`fetchConfigStruct`, `FetchCapability`, `FetchConfig`, `makeHostCaveat`, `makeCaveatedFetch`) ([#942](https://github.com/MetaMask/ocap-kernel/pull/942)) + - `fetch` is now a vat endowment in `@metamask/ocap-kernel`; see its changelog for the migration + ## [0.1.0] ### Added diff --git a/packages/kernel-platforms/src/browser.ts b/packages/kernel-platforms/src/browser.ts index 2bcd1594b7..7df2fa9dfe 100644 --- a/packages/kernel-platforms/src/browser.ts +++ b/packages/kernel-platforms/src/browser.ts @@ -1,8 +1,6 @@ -import { capabilityFactory as fetchCapabilityFactory } from './capabilities/fetch/browser.ts'; import { capabilityFactory as fsCapabilityFactory } from './capabilities/fs/browser.ts'; import { makePlatformFactory } from './factory.ts'; export const makePlatform = makePlatformFactory({ - fetch: fetchCapabilityFactory, fs: fsCapabilityFactory, }); diff --git a/packages/kernel-platforms/src/capabilities/fetch/browser.test.ts b/packages/kernel-platforms/src/capabilities/fetch/browser.test.ts deleted file mode 100644 index 50bcc6f79d..0000000000 --- a/packages/kernel-platforms/src/capabilities/fetch/browser.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; - -import { capabilityFactory } from './browser.ts'; -import type { FetchConfig } from './types.ts'; - -describe('fetch browser capability', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('capabilityFactory', () => { - it('creates fetch capability', () => { - const config: FetchConfig = { allowedHosts: ['example.test'] }; - const fetchCapability = capabilityFactory(config); - - expect(typeof fetchCapability).toBe('function'); - }); - }); -}); diff --git a/packages/kernel-platforms/src/capabilities/fetch/browser.ts b/packages/kernel-platforms/src/capabilities/fetch/browser.ts deleted file mode 100644 index 4f84e57647..0000000000 --- a/packages/kernel-platforms/src/capabilities/fetch/browser.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { makeFetchCaveat, makeCaveatedFetch } from './shared.ts'; -import { fetchConfigStruct } from './types.ts'; -import type { FetchCapability, FetchConfig } from './types.ts'; -import { makeCapabilitySpecification } from '../../specification.ts'; - -export const { configStruct, capabilityFactory } = makeCapabilitySpecification( - fetchConfigStruct, - (config: FetchConfig): FetchCapability => { - const caveat = makeFetchCaveat(config); - return makeCaveatedFetch(globalThis.fetch, caveat); - }, -); diff --git a/packages/kernel-platforms/src/capabilities/fetch/nodejs.test.ts b/packages/kernel-platforms/src/capabilities/fetch/nodejs.test.ts deleted file mode 100644 index eceab08717..0000000000 --- a/packages/kernel-platforms/src/capabilities/fetch/nodejs.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { fetchMock } from '@ocap/repo-tools/test-utils/fetch-mock'; -import { readFile } from 'node:fs/promises'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; - -import { capabilityFactory } from './nodejs.ts'; -import type { FetchConfig } from './types.ts'; -import { createMockResponse } from '../../../test/utils.ts'; - -// Mock fs/promises -vi.mock('node:fs/promises', () => ({ - readFile: vi.fn(), -})); - -describe('fetch nodejs capability', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('capabilityFactory', () => { - it.each([ - { - name: 'without host restrictions', - config: {}, - input: 'file:///path/to/file.txt', - }, - { - name: 'with host restrictions', - config: { allowedHosts: ['example.test'] }, - input: 'file:///path/to/file.txt', - }, - { - name: 'with Request objects', - config: {}, - - input: new Request('file:///path/to/file.txt'), - }, - { - name: 'with URL objects', - config: {}, - input: new URL('file:///path/to/file.txt'), - }, - ])('handles file:// URLs $name', async ({ config, input }) => { - const fileContents = 'file contents'; - vi.mocked(readFile).mockResolvedValue(fileContents); - - const fetchCapability = capabilityFactory(config, { - fromFetch: fetchMock, - }); - - const result = await fetchCapability(input); - - expect(readFile).toHaveBeenCalledWith('/path/to/file.txt', 'utf8'); - - expect(result).toBeInstanceOf(Response); - expect(await result.text()).toBe(fileContents); - expect(fetchMock).not.toHaveBeenCalled(); // Should not call global fetch for file:// URLs - }); - - it('uses provided fromFetch when specified', async () => { - const mockResponse = createMockResponse(); - const customFetch = vi.fn().mockResolvedValue(mockResponse); - - const config: FetchConfig = { allowedHosts: ['example.test'] }; - const fetchCapability = capabilityFactory(config, { - fromFetch: customFetch, - }); - - await fetchCapability('https://example.test/path'); - - expect(customFetch).toHaveBeenCalledWith('https://example.test/path'); - expect(fetchMock).not.toHaveBeenCalled(); // Should use custom fetch instead - }); - - it.each([ - { - name: 'not provided', - factory: () => capabilityFactory({ allowedHosts: ['example.test'] }), - }, - { - name: 'undefined in options', - factory: () => - capabilityFactory( - { allowedHosts: ['example.test'] }, - { fromFetch: undefined as never }, - ), - }, - ])('throws error when fromFetch is $name', ({ factory }) => { - expect(() => factory()).toThrow( - 'Must provide explicit fromFetch capability', - ); - }); - }); -}); diff --git a/packages/kernel-platforms/src/capabilities/fetch/nodejs.ts b/packages/kernel-platforms/src/capabilities/fetch/nodejs.ts deleted file mode 100644 index 49479fdc10..0000000000 --- a/packages/kernel-platforms/src/capabilities/fetch/nodejs.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { readFile } from 'node:fs/promises'; - -import { makeFetchCaveat, makeCaveatedFetch } from './shared.ts'; -import { fetchConfigStruct } from './types.ts'; -import type { FetchCapability, FetchConfig } from './types.ts'; -import { makeCapabilitySpecification } from '../../specification.ts'; - -/** - * Extends the fetch capability with file:// URL support for Node.js - * - * @param fromFetch - The underlying fetch capability to wrap - * @returns A fetch capability with file:// URL support - */ -const makeExtendedFetch = (fromFetch: FetchCapability): FetchCapability => { - return async (...[input, ...args]: Parameters) => { - const url = input instanceof Request ? input.url : input; - const { protocol, pathname } = new URL(url); - - if (protocol === 'file:') { - const contents = await readFile(pathname, 'utf8'); - - return new Response(contents); - } - - return await fromFetch(input, ...args); - }; -}; - -export const { configStruct, capabilityFactory } = makeCapabilitySpecification( - fetchConfigStruct, - ( - config: FetchConfig, - options?: { fromFetch: FetchCapability }, - ): FetchCapability => { - if (!options?.fromFetch) { - throw new Error('Must provide explicit fromFetch capability'); - } - const { fromFetch } = options; - const caveat = makeFetchCaveat(config); - const extendedFetch = makeExtendedFetch(fromFetch); - return makeCaveatedFetch(extendedFetch, caveat); - }, -); diff --git a/packages/kernel-platforms/src/capabilities/fetch/shared.test.ts b/packages/kernel-platforms/src/capabilities/fetch/shared.test.ts deleted file mode 100644 index 39e799afc3..0000000000 --- a/packages/kernel-platforms/src/capabilities/fetch/shared.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { - resolveUrl, - makeHostCaveat, - makeFetchCaveat, - makeCaveatedFetch, -} from './shared.ts'; - -describe('resolveUrl', () => { - it.each([ - { name: 'string URL', input: 'https://example.test/path' }, - { - name: 'Request object URL', - - input: new Request('https://example.test/path'), - }, - { name: 'URL object', input: new URL('https://example.test/path') }, - ])('resolves $name', ({ input }) => { - const result = resolveUrl(input); - expect(result).toBeInstanceOf(URL); - expect(result.href).toBe('https://example.test/path'); - }); -}); - -describe('makeHostCaveat', () => { - it('allows allowed hosts', async () => { - const allowedHosts = ['example.test', 'api.github.com']; - const caveat = makeHostCaveat(allowedHosts); - - expect(await caveat('https://example.test/path')).toBeUndefined(); - expect(await caveat('https://api.github.com/users')).toBeUndefined(); - }); - - it('rejects disallowed hosts', async () => { - const allowedHosts = ['example.test']; - const caveat = makeHostCaveat(allowedHosts); - - await expect(caveat('https://malicious.test/path')).rejects.toThrow( - 'Invalid host: malicious.test', - ); - }); - - it.each([ - { - name: 'Request objects', - - input: new Request('https://example.test/path'), - }, - { name: 'URL objects', input: new URL('https://example.test/path') }, - ])('handles $name', async ({ input }) => { - const allowedHosts = ['example.test']; - const caveat = makeHostCaveat(allowedHosts); - - expect(await caveat(input)).toBeUndefined(); - }); -}); - -describe('makeFetchCaveat', () => { - it('creates caveat with hosts', async () => { - const config = { allowedHosts: ['example.test'] }; - const caveat = makeFetchCaveat(config); - - expect(await caveat('https://example.test/path')).toBeUndefined(); - await expect(caveat('https://malicious.test/path')).rejects.toThrow( - 'Invalid host: malicious.test', - ); - }); - - it('creates caveat with empty hosts', async () => { - const config = { allowedHosts: [] }; - const caveat = makeFetchCaveat(config); - - await expect(caveat('https://any-host.com/path')).rejects.toThrow( - 'Invalid host: any-host.com', - ); - }); - - it('creates caveat with undefined hosts', async () => { - const config = {}; - const caveat = makeFetchCaveat(config); - - await expect(caveat('https://any-host.com/path')).rejects.toThrow( - 'Invalid host: any-host.com', - ); - }); -}); - -describe('makeCaveatedFetch', () => { - it('applies caveat and calls fetch', async () => { - const mockResponse = { - status: 200, - text: async () => Promise.resolve('test'), - } as Response; - const mockFetch = vi.fn().mockResolvedValue(mockResponse); - const caveat = vi.fn().mockResolvedValue(undefined); - - const caveatedFetch = makeCaveatedFetch(mockFetch, caveat); - - const result = await caveatedFetch('https://example.test/path'); - - expect(caveat).toHaveBeenCalledWith('https://example.test/path'); - expect(mockFetch).toHaveBeenCalledWith('https://example.test/path'); - expect(result).toBe(mockResponse); - }); - - it('throws when caveat rejects', async () => { - const mockFetch = vi.fn(); - const caveat = vi.fn().mockRejectedValue(new Error('Host not allowed')); - - const caveatedFetch = makeCaveatedFetch(mockFetch, caveat); - - await expect(caveatedFetch('https://malicious.test/path')).rejects.toThrow( - 'Host not allowed', - ); - expect(caveat).toHaveBeenCalledWith('https://malicious.test/path'); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('passes through arguments', async () => { - const mockResponse = { - status: 200, - text: async () => Promise.resolve('test'), - } as Response; - const mockFetch = vi.fn().mockResolvedValue(mockResponse); - const caveat = vi.fn().mockResolvedValue(undefined); - - const caveatedFetch = makeCaveatedFetch(mockFetch, caveat); - const init = { method: 'POST', body: 'data' }; - - await caveatedFetch('https://example.test/path', init); - - expect(caveat).toHaveBeenCalledWith('https://example.test/path', init); - expect(mockFetch).toHaveBeenCalledWith('https://example.test/path', init); - }); -}); - -describe('shared fetch capability behavior', () => { - it('creates capability with restrictions', async () => { - const mockResponse = { - status: 200, - text: async () => Promise.resolve('test'), - } as Response; - const mockFetch = vi.fn().mockResolvedValue(mockResponse); - const caveat = makeFetchCaveat({ allowedHosts: ['example.test'] }); - const caveatedFetch = makeCaveatedFetch(mockFetch, caveat); - - // Should allow allowed host - const result = await caveatedFetch('https://example.test/path'); - expect(result).toBe(mockResponse); - expect(mockFetch).toHaveBeenCalledWith('https://example.test/path'); - - // Should reject disallowed host - await expect(caveatedFetch('https://malicious.test/path')).rejects.toThrow( - 'Invalid host: malicious.test', - ); - expect(mockFetch).toHaveBeenCalledTimes(1); // Only called once for allowed host - }); - - it('passes through arguments', async () => { - const mockResponse = { - status: 200, - text: async () => Promise.resolve('test'), - } as Response; - const mockFetch = vi.fn().mockResolvedValue(mockResponse); - const caveat = makeFetchCaveat({ allowedHosts: ['example.test'] }); - const caveatedFetch = makeCaveatedFetch(mockFetch, caveat); - const init = { method: 'POST', body: 'data' }; - - await caveatedFetch('https://example.test/path', init); - - expect(mockFetch).toHaveBeenCalledWith('https://example.test/path', init); - }); - - it.each([ - { - name: 'Request objects', - - input: new Request('https://example.test/path'), - }, - { name: 'URL objects', input: new URL('https://example.test/path') }, - ])('handles $name', async ({ input }) => { - const mockResponse = { - status: 200, - text: async () => Promise.resolve('test'), - } as Response; - const mockFetch = vi.fn().mockResolvedValue(mockResponse); - const caveat = makeFetchCaveat({ allowedHosts: ['example.test'] }); - const caveatedFetch = makeCaveatedFetch(mockFetch, caveat); - - const result = await caveatedFetch(input); - expect(result).toBe(mockResponse); - expect(mockFetch).toHaveBeenCalledWith(input); - }); -}); diff --git a/packages/kernel-platforms/src/capabilities/fetch/shared.ts b/packages/kernel-platforms/src/capabilities/fetch/shared.ts deleted file mode 100644 index 9692f786ad..0000000000 --- a/packages/kernel-platforms/src/capabilities/fetch/shared.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { FetchCapability, FetchCaveat, FetchConfig } from './types.ts'; - -/** - * Cross-platform URL resolution utility - * - * @param arg - The input to resolve - * @returns The resolved URL - */ -export const resolveUrl = (arg: Parameters[0]): URL => - new URL(arg instanceof Request ? arg.url : arg); - -/** - * Cross-platform host caveat factory - * - * @param allowedHosts - The allowed hosts - * @returns A caveat that restricts the fetch to only the allowed hosts - */ -export const makeHostCaveat = (allowedHosts: string[]): FetchCaveat => { - return harden(async (...args: Parameters) => { - const { host, protocol } = resolveUrl(args[0]); - // Allow file:// URLs to pass through - if (protocol === 'file:') { - return; - } - if (!allowedHosts.includes(host)) { - throw new Error(`Invalid host: ${host}`); - } - }); -}; - -/** - * Cross-platform fetch caveat factory - * - * @param config - The configuration for the fetch caveat - * @returns A caveat that restricts a fetch capability according to the specified configuration - */ -export const makeFetchCaveat = (config: FetchConfig): FetchCaveat => { - const { allowedHosts = [] } = config; - return makeHostCaveat(allowedHosts); -}; - -/** - * Cross-platform fetch caveat wrapper - * - * @param baseFetch - The underlying fetch capability to wrap - * @param caveat - The caveat to apply to the fetch capability - * @returns A fetch capability restricted by the provided caveat - */ -export const makeCaveatedFetch = ( - baseFetch: FetchCapability, - caveat: FetchCaveat, -): FetchCapability => { - return harden(async (...args: Parameters) => { - await caveat(...args); - const response = await baseFetch(...args); - return response; - }); -}; diff --git a/packages/kernel-platforms/src/capabilities/fetch/types.test.ts b/packages/kernel-platforms/src/capabilities/fetch/types.test.ts deleted file mode 100644 index f0de1b6a31..0000000000 --- a/packages/kernel-platforms/src/capabilities/fetch/types.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { fetchConfigStruct } from './types.ts'; -import type { FetchConfig } from './types.ts'; -import { superstructValidationError } from '../../../test/utils.ts'; - -describe('fetch types', () => { - describe('fetchConfigStruct', () => { - it.each([ - { name: 'empty config', config: {} }, - { - name: 'config with single allowed host', - config: { allowedHosts: ['example.test'] }, - }, - { - name: 'config with multiple allowed hosts', - config: { - allowedHosts: ['example.test', 'api.github.com', 'localhost'], - }, - }, - { - name: 'config with empty allowed hosts array', - config: { allowedHosts: [] }, - }, - { - name: 'config with various host formats', - config: { - allowedHosts: [ - 'example.test', - 'api.example.test', - 'localhost', - '127.0.0.1', - 'subdomain.example.org', - ], - }, - }, - ])('validates $name', ({ config }) => { - expect(() => fetchConfigStruct.create(config)).not.toThrow(); - }); - - it.each([ - { - name: 'non-array allowed hosts', - config: { allowedHosts: 'example.test' }, - }, - { - name: 'non-string host in array', - config: { allowedHosts: ['example.test', 123, 'api.github.com'] }, - }, - { - name: 'null host in array', - config: { allowedHosts: ['example.test', null, 'api.github.com'] }, - }, - { - name: 'undefined host in array', - config: { allowedHosts: ['example.test', undefined, 'api.github.com'] }, - }, - { - name: 'additional properties', - config: { allowedHosts: ['example.test'], extraProp: 'value' }, - }, - ])('rejects $name', ({ config }) => { - expect(() => fetchConfigStruct.create(config)).toThrow( - superstructValidationError, - ); - }); - - it('allows empty host', () => { - const config = { allowedHosts: ['example.test', '', 'api.github.com'] }; - expect(() => fetchConfigStruct.create(config)).not.toThrow(); - }); - - it('preserves config values', () => { - const config: FetchConfig = { - allowedHosts: ['example.test', 'api.github.com'], - }; - const validated = fetchConfigStruct.create(config); - - expect(validated.allowedHosts).toStrictEqual([ - 'example.test', - 'api.github.com', - ]); - }); - - it('allows undefined hosts', () => { - const config: FetchConfig = {}; - const validated = fetchConfigStruct.create(config); - - expect(validated.allowedHosts).toBeUndefined(); - }); - }); -}); diff --git a/packages/kernel-platforms/src/capabilities/fetch/types.ts b/packages/kernel-platforms/src/capabilities/fetch/types.ts deleted file mode 100644 index 889389dc48..0000000000 --- a/packages/kernel-platforms/src/capabilities/fetch/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { array, exactOptional, object, string } from '@metamask/superstruct'; -import type { Infer } from '@metamask/superstruct'; - -export type FetchCapability = typeof fetch; - -export type FetchCaveat = ( - ...args: Parameters -) => Promise; - -export const fetchConfigStruct = object({ - allowedHosts: exactOptional(array(string())), -}); - -export type FetchConfig = Infer; diff --git a/packages/kernel-platforms/src/capabilities/index.test.ts b/packages/kernel-platforms/src/capabilities/index.test.ts index 0acfd47fe4..41dd9bb94e 100644 --- a/packages/kernel-platforms/src/capabilities/index.test.ts +++ b/packages/kernel-platforms/src/capabilities/index.test.ts @@ -5,7 +5,6 @@ import { platformConfigStruct } from './index.ts'; describe('platformConfigStruct', () => { it.each([ { name: 'empty config', config: {} }, - { name: 'config with fetch capability', config: { fetch: {} } }, { name: 'config with fs capability', config: { fs: { rootDir: '/tmp' } } }, ])('validates $name', ({ config }) => { expect(() => platformConfigStruct.create(config)).not.toThrow(); diff --git a/packages/kernel-platforms/src/capabilities/index.ts b/packages/kernel-platforms/src/capabilities/index.ts index c11483a4c8..f4a3c251e3 100644 --- a/packages/kernel-platforms/src/capabilities/index.ts +++ b/packages/kernel-platforms/src/capabilities/index.ts @@ -1,7 +1,5 @@ import { object, exactOptional } from '@metamask/superstruct'; -import { fetchConfigStruct } from './fetch/types.ts'; -import type { FetchCapability, FetchConfig } from './fetch/types.ts'; import { fsConfigStruct } from './fs/types.ts'; import type { FsCapability, FsConfig } from './fs/types.ts'; @@ -9,10 +7,6 @@ import type { FsCapability, FsConfig } from './fs/types.ts'; * Registry of all platform capabilities (platform-agnostic) */ export type PlatformCapabilityRegistry = { - fetch: { - config: FetchConfig; - capability: FetchCapability; - }; fs: { config: FsConfig; capability: FsCapability; @@ -21,6 +15,5 @@ export type PlatformCapabilityRegistry = { // Create validation struct for PlatformConfig export const platformConfigStruct = object({ - fetch: exactOptional(fetchConfigStruct), fs: exactOptional(fsConfigStruct), }); diff --git a/packages/kernel-platforms/src/factory.test.ts b/packages/kernel-platforms/src/factory.test.ts index 88893e129b..536fb2422a 100644 --- a/packages/kernel-platforms/src/factory.test.ts +++ b/packages/kernel-platforms/src/factory.test.ts @@ -5,7 +5,6 @@ import type { PlatformConfig, PlatformFactory } from './types.ts'; describe('makePlatformFactory', () => { const createMockFactories = () => ({ - fetch: vi.fn().mockReturnValue({ request: vi.fn() }), fs: vi.fn().mockReturnValue({ readFile: vi.fn() }), }); @@ -18,21 +17,15 @@ describe('makePlatformFactory', () => { it.each([ { name: 'single capability', - config: { fetch: {} }, - expectedCapabilities: ['fetch'], - expectedOptions: {}, - }, - { - name: 'multiple capabilities', - config: { fetch: {}, fs: { rootDir: '/tmp' } }, - expectedCapabilities: ['fetch', 'fs'], + config: { fs: { rootDir: '/tmp' } }, + expectedCapabilities: ['fs'] as const, expectedOptions: {}, }, { name: 'with options', - config: { fetch: {} }, - expectedCapabilities: ['fetch'], - expectedOptions: { fetch: { timeout: 5000 } }, + config: { fs: { rootDir: '/tmp' } }, + expectedCapabilities: ['fs'] as const, + expectedOptions: { fs: { timeout: 5000 } }, }, ])( 'creates platform with $name', @@ -60,30 +53,22 @@ describe('makePlatformFactory', () => { it('creates platform with partial config', async () => { const mockFactories = createMockFactories(); const platformFactory = makePlatformFactory(mockFactories); - const config = { fetch: {} }; + const config = { fs: { rootDir: '/tmp' } }; const platform = await platformFactory(config); - expect(platform.fetch).toBeDefined(); - expect(platform.fs).toBeUndefined(); + expect(platform.fs).toBeDefined(); }); - it.each([ - { - name: 'unregistered capability', - factories: { fetch: vi.fn() }, - config: { fetch: {}, unknown: {} } as Partial, - expectedError: - 'Config provided entry for unregistered capability: unknown', - }, - { - name: 'missing factory', - factories: { fetch: vi.fn() }, - config: { fetch: {}, fs: { rootDir: '/tmp' } }, - expectedError: 'Config provided entry for unregistered capability: fs', - }, - ])('throws error for $name', async ({ factories, config, expectedError }) => { + it('throws for unregistered capability', async () => { + const factories = { fs: vi.fn() }; const platformFactory = makePlatformFactory(factories); - await expect(platformFactory(config)).rejects.toThrow(expectedError); + const config = { + fs: { rootDir: '/tmp' }, + unknown: {}, + } as Partial; + await expect(platformFactory(config)).rejects.toThrow( + 'Config provided entry for unregistered capability: unknown', + ); }); }); diff --git a/packages/kernel-platforms/src/index.test.ts b/packages/kernel-platforms/src/index.test.ts index cff49abb20..0231671612 100644 --- a/packages/kernel-platforms/src/index.test.ts +++ b/packages/kernel-platforms/src/index.test.ts @@ -10,17 +10,14 @@ describe('kernel-platforms index', () => { }); it('exports type definitions', () => { - // Test that types are properly exported by using them - const capabilityName: CapabilityName = 'fetch'; - expect(capabilityName).toBe('fetch'); + const capabilityName: CapabilityName = 'fs'; + expect(capabilityName).toBe('fs'); - // Test that we can use the generic types // eslint-disable-next-line @typescript-eslint/no-unused-vars - type TestCapability = Capability<'fetch'>; + type TestCapability = Capability<'fs'>; // eslint-disable-next-line @typescript-eslint/no-unused-vars - type TestConfig = CapabilityConfig<'fetch'>; + type TestConfig = CapabilityConfig<'fs'>; - // This test passes if TypeScript compilation succeeds expect(true).toBe(true); }); }); diff --git a/packages/kernel-platforms/src/nodejs.ts b/packages/kernel-platforms/src/nodejs.ts index fd1dbd9eeb..ba16679e42 100644 --- a/packages/kernel-platforms/src/nodejs.ts +++ b/packages/kernel-platforms/src/nodejs.ts @@ -1,8 +1,6 @@ -import { capabilityFactory as fetchCapabilityFactory } from './capabilities/fetch/nodejs.ts'; import { capabilityFactory as fsCapabilityFactory } from './capabilities/fs/nodejs.ts'; import { makePlatformFactory } from './factory.ts'; export const makePlatform = makePlatformFactory({ - fetch: fetchCapabilityFactory, fs: fsCapabilityFactory, }); diff --git a/packages/kernel-platforms/src/platform-test.ts b/packages/kernel-platforms/src/platform-test.ts index 39b3bee3a3..1142ac8fd6 100644 --- a/packages/kernel-platforms/src/platform-test.ts +++ b/packages/kernel-platforms/src/platform-test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import type { PlatformFactory } from './types.ts'; @@ -12,48 +12,22 @@ export const createPlatformTestSuite = ( }); it.each([ - { - name: 'fetch capability', - config: { fetch: {} }, - expectedFetch: { type: 'function' }, - expectedFs: { type: 'undefined' }, - }, { name: 'fs capability', config: { fs: { rootDir: '/tmp' } }, - expectedFetch: { type: 'undefined' }, - expectedFs: { type: 'object' }, - }, - { - name: 'both capabilities', - config: { - fetch: {}, - fs: { rootDir: '/tmp' }, - }, - expectedFetch: { type: 'function' }, expectedFs: { type: 'object' }, }, - ])( - 'creates platform with $name', - async ({ config, expectedFetch, expectedFs }) => { - const options = config.fetch - ? { fetch: { fromFetch: vi.fn() } } - : undefined; - const platform = await makePlatform(config, options as never); - - expect(typeof platform.fetch).toBe(expectedFetch.type); - expect(typeof platform.fs).toBe(expectedFs.type); - }, - ); + ])('creates platform with $name', async ({ config, expectedFs }) => { + const platform = await makePlatform(config); + expect(typeof platform.fs).toBe(expectedFs.type); + }); it('creates platform with partial config', async () => { - const config = { fetch: {} }; - const options = { fetch: { fromFetch: vi.fn() } }; - const platform = await makePlatform(config, options as never); + const config = { fs: { rootDir: '/tmp' } }; + const platform = await makePlatform(config); - expect(platform.fetch).toBeDefined(); - expect(platform.fs).toBeUndefined(); - expect(typeof platform.fetch).toBe('function'); + expect(platform.fs).toBeDefined(); + expect(typeof platform.fs).toBe('object'); }); }); }; diff --git a/packages/kernel-platforms/src/specification.test.ts b/packages/kernel-platforms/src/specification.test.ts index f9ef1d5d5c..fbb3d13c08 100644 --- a/packages/kernel-platforms/src/specification.test.ts +++ b/packages/kernel-platforms/src/specification.test.ts @@ -1,43 +1,43 @@ import { describe, expect, it, vi } from 'vitest'; import { superstructValidationError } from '../test/utils.ts'; -import { fetchConfigStruct } from './capabilities/fetch/types.ts'; -import type { FetchConfig } from './capabilities/fetch/types.ts'; +import { fsConfigStruct } from './capabilities/fs/types.ts'; +import type { FsConfig } from './capabilities/fs/types.ts'; import { makeCapabilitySpecification } from './specification.ts'; describe('makeCapabilitySpecification', () => { it('creates specification with configStruct and capabilityFactory', () => { const mockCapabilityFactory = vi.fn(); const specification = makeCapabilitySpecification( - fetchConfigStruct, + fsConfigStruct, mockCapabilityFactory, ); expect(specification).toHaveProperty('configStruct'); expect(specification).toHaveProperty('capabilityFactory'); - expect(specification.configStruct).toBe(fetchConfigStruct); + expect(specification.configStruct).toBe(fsConfigStruct); expect(specification.capabilityFactory).toBe(mockCapabilityFactory); }); it('validates config using configStruct', () => { const mockCapabilityFactory = vi.fn(); const specification = makeCapabilitySpecification( - fetchConfigStruct, + fsConfigStruct, mockCapabilityFactory, ); - const validConfig: FetchConfig = { allowedHosts: ['example.test'] }; + const validConfig: FsConfig = { rootDir: '/tmp' }; expect(() => specification.configStruct.create(validConfig)).not.toThrow(); }); it('rejects invalid config using configStruct', () => { const mockCapabilityFactory = vi.fn(); const specification = makeCapabilitySpecification( - fetchConfigStruct, + fsConfigStruct, mockCapabilityFactory, ); - const invalidConfig = { allowedHosts: 'not-an-array' }; + const invalidConfig = { rootDir: 123 }; expect(() => specification.configStruct.create(invalidConfig)).toThrow( superstructValidationError, ); @@ -46,11 +46,11 @@ describe('makeCapabilitySpecification', () => { it('calls capabilityFactory with config and options', () => { const mockCapabilityFactory = vi.fn().mockReturnValue('mock-capability'); const specification = makeCapabilitySpecification( - fetchConfigStruct, + fsConfigStruct, mockCapabilityFactory, ); - const config: FetchConfig = { allowedHosts: ['example.test'] }; + const config: FsConfig = { rootDir: '/tmp' }; const options = { timeout: 5000 }; const result = specification.capabilityFactory(config, options); @@ -62,11 +62,11 @@ describe('makeCapabilitySpecification', () => { it('calls capabilityFactory with config only', () => { const mockCapabilityFactory = vi.fn().mockReturnValue('mock-capability'); const specification = makeCapabilitySpecification( - fetchConfigStruct, + fsConfigStruct, mockCapabilityFactory, ); - const config: FetchConfig = { allowedHosts: ['example.test'] }; + const config: FsConfig = { rootDir: '/tmp' }; const result = specification.capabilityFactory(config); diff --git a/packages/kernel-test/src/endowments.test.ts b/packages/kernel-test/src/endowments.test.ts index f307b4dcba..8ddf55fd6e 100644 --- a/packages/kernel-test/src/endowments.test.ts +++ b/packages/kernel-test/src/endowments.test.ts @@ -32,10 +32,9 @@ describe('endowments', () => { main: { bundleSpec: getBundleSpec('endowment-fetch'), parameters: {}, - platformConfig: { - fetch: { - allowedHosts: [goodHost], - }, + globals: ['fetch', 'Request', 'Headers', 'Response'], + network: { + allowedHosts: [goodHost], }, }, }, @@ -60,6 +59,9 @@ describe('endowments', () => { 'buildRootObject', 'bootstrap', `response: ${expectedResponse}`, + `Request constructor: ok`, + `Headers constructor: ok`, + `Response constructor: ok`, `error: Error: Invalid host: ${badHost}`, ]); }); diff --git a/packages/kernel-test/src/vats/endowment-fetch.ts b/packages/kernel-test/src/vats/endowment-fetch.ts index 2eb8dcf533..d5b96bc24f 100644 --- a/packages/kernel-test/src/vats/endowment-fetch.ts +++ b/packages/kernel-test/src/vats/endowment-fetch.ts @@ -4,7 +4,8 @@ import { unwrapTestLogger } from '../test-powers.ts'; import type { TestPowers } from '../test-powers.ts'; /** - * Build a root object for a vat that uses the fetch capability. + * Build a root object for a vat that uses the network endowment (`fetch` + * plus `Request`, `Headers`, `Response` constructors). * * @param vatPowers - The powers of the vat. * @param vatPowers.logger - The logger for the vat. @@ -25,6 +26,17 @@ export async function buildRootObject(vatPowers: TestPowers) { const response = await fetch(url); const text = await response.text(); tlog(`response: ${text}`); + // Verify hardened Request/Headers/Response constructors are + // available on a successful path so the test can assert on them. + tlog( + `Request constructor: ${new Request(url) instanceof Request ? 'ok' : 'missing'}`, + ); + tlog( + `Headers constructor: ${new Headers({ 'x-test': '1' }) instanceof Headers ? 'ok' : 'missing'}`, + ); + tlog( + `Response constructor: ${new Response('body') instanceof Response ? 'ok' : 'missing'}`, + ); return text; } catch (error) { tlog(`error: ${String(error)}`); diff --git a/packages/nodejs-test-workers/src/workers/mock-fetch.ts b/packages/nodejs-test-workers/src/workers/mock-fetch.ts index 7918ee2b73..cb6e0fa8b3 100644 --- a/packages/nodejs-test-workers/src/workers/mock-fetch.ts +++ b/packages/nodejs-test-workers/src/workers/mock-fetch.ts @@ -7,6 +7,14 @@ const LOG_TAG = 'nodejs-test-vat-worker'; let logger = new Logger(LOG_TAG); +// The Snaps network factory reads `globalThis.fetch` at call time, so stub +// it before the supervisor is constructed. Endoify hardens intrinsics but +// not `globalThis.fetch`, so the override sticks. +globalThis.fetch = async (input) => { + logger.debug('fetch', input); + return new Response('Hello, world!'); +}; + main().catch((reason) => logger.error('main exited with error', reason)); /** @@ -21,14 +29,6 @@ async function main(): Promise { const { logger: streamLogger } = await makeNodeJsVatSupervisor( vatId, LOG_TAG, - { - fetch: { - fromFetch: async (input: string | URL | Request) => { - logger.debug('fetch', input); - return new Response('Hello, world!'); - }, - }, - }, ); logger = streamLogger; logger.debug('vat-worker main'); diff --git a/packages/ocap-kernel/CHANGELOG.md b/packages/ocap-kernel/CHANGELOG.md index 7bd4596ce8..36fbbb48b9 100644 --- a/packages/ocap-kernel/CHANGELOG.md +++ b/packages/ocap-kernel/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `fetch`, `Request`, `Headers`, and `Response` to the default vat endowments ([#942](https://github.com/MetaMask/ocap-kernel/pull/942)) + - Add `VatConfig.network: { allowedHosts: string[] }`; requesting `'fetch'` without it rejects `initVat` + - **BREAKING:** remove `VatConfig.platformConfig.fetch` — migrate to `globals: ['fetch', ...]` + `network.allowedHosts` + - **BREAKING:** `MakeAllowedGlobals` now takes a `{ logger }` options bag - Integrate Snaps attenuated endowment factories into vat globals ([#937](https://github.com/MetaMask/ocap-kernel/pull/937)) - Add `setInterval`, `clearInterval`, `crypto`, `SubtleCrypto`, and `Math` (crypto-backed `Math.random`) to the default vat endowments - **BREAKING:** `setTimeout` now enforces a 10 ms minimum delay (upstream Snaps `MINIMUM_TIMEOUT`); shorter delays are silently coerced to 10 ms diff --git a/packages/ocap-kernel/src/types.test.ts b/packages/ocap-kernel/src/types.test.ts index 242b1ac835..98869f9373 100644 --- a/packages/ocap-kernel/src/types.test.ts +++ b/packages/ocap-kernel/src/types.test.ts @@ -123,7 +123,7 @@ describe('isVatConfig', () => { config: { bundleSpec: 'bundle.js', platformConfig: { - fetch: { allowedHosts: ['api.github.com'] }, + fs: { rootDir: '/tmp' }, }, }, expected: true, @@ -135,12 +135,20 @@ describe('isVatConfig', () => { creationOptions: { foo: 'bar' }, parameters: { baz: 123 }, platformConfig: { - fetch: { allowedHosts: ['api.github.test'] }, fs: { rootDir: '/tmp', existsSync: true }, }, }, expected: true, }, + { + name: 'with valid network config', + config: { + bundleSpec: 'bundle.js', + globals: ['fetch'], + network: { allowedHosts: ['api.github.com'] }, + }, + expected: true, + }, ])('validates $name', ({ config, expected }) => { expect(isVatConfig(config)).toBe(expected); }); @@ -151,17 +159,22 @@ describe('isVatConfig', () => { config: { bundleSpec: 'bundle.js', platformConfig: { - fetch: { allowedHosts: 'not-an-array' }, + fs: { rootDir: 123 }, }, }, }, { - name: 'invalid platformConfig fetch config', + name: 'network.allowedHosts not an array', config: { bundleSpec: 'bundle.js', - platformConfig: { - fetch: { invalidField: 'value' }, - }, + network: { allowedHosts: 'not-an-array' }, + }, + }, + { + name: 'network.allowedHosts contains non-strings', + config: { + bundleSpec: 'bundle.js', + network: { allowedHosts: ['ok.test', 123] }, }, }, ])('rejects configs with $name', ({ config }) => { diff --git a/packages/ocap-kernel/src/types.ts b/packages/ocap-kernel/src/types.ts index 3d42ddea8c..599a85fb15 100644 --- a/packages/ocap-kernel/src/types.ts +++ b/packages/ocap-kernel/src/types.ts @@ -586,11 +586,26 @@ export type PlatformServices = { // Cluster configuration +/** + * Per-vat network configuration. Present only when the vat opts into the + * `fetch` endowment via {@link VatConfig.globals}; an absent or missing + * `allowedHosts` is treated as a denial of all hosts (there is no implicit + * allow-all). + */ +export type VatNetworkConfig = { + allowedHosts: string[]; +}; + +export const vatNetworkConfigStruct = object({ + allowedHosts: array(string()), +}); + export type VatConfig = UserCodeSpec & { creationOptions?: Record; parameters?: Record; platformConfig?: Partial; globals?: AllowedGlobalName[]; + network?: VatNetworkConfig; }; const UserCodeSpecStruct = union([ @@ -612,15 +627,22 @@ export const VatConfigStruct = define('VatConfig', (value) => { return false; } - const { creationOptions, parameters, platformConfig, globals, ...specOnly } = - value as Record; + const { + creationOptions, + parameters, + platformConfig, + globals, + network, + ...specOnly + } = value as Record; return ( is(specOnly, UserCodeSpecStruct) && (!creationOptions || is(creationOptions, UnsafeJsonStruct)) && (!parameters || is(parameters, UnsafeJsonStruct)) && (!platformConfig || is(platformConfig, platformConfigStruct)) && - (!globals || is(globals, array(AllowedGlobalNameStruct))) + (!globals || is(globals, array(AllowedGlobalNameStruct))) && + (!network || is(network, vatNetworkConfigStruct)) ); }); diff --git a/packages/ocap-kernel/src/vats/VatSupervisor.test.ts b/packages/ocap-kernel/src/vats/VatSupervisor.test.ts index 5f691a1dd7..2c103fd153 100644 --- a/packages/ocap-kernel/src/vats/VatSupervisor.test.ts +++ b/packages/ocap-kernel/src/vats/VatSupervisor.test.ts @@ -47,7 +47,7 @@ const makeVatSupervisor = async ({ vatPowers?: Record; makePlatform?: PlatformFactory; platformOptions?: Record; - makeAllowedGlobals?: () => VatEndowments; + makeAllowedGlobals?: (options: { logger: Logger }) => VatEndowments; fetchBlob?: FetchBlob; writerOnEnd?: () => void; } = {}): Promise<{ @@ -255,7 +255,7 @@ describe('VatSupervisor', () => { describe('platform configuration', () => { it('accepts makePlatform and platformOptions parameters', async () => { const makePlatform = vi.fn().mockResolvedValue({}); - const platformOptions = { fetch: { fromFetch: globalThis.fetch } }; + const platformOptions = { fs: { rootDir: '/tmp' } }; const { supervisor } = await makeVatSupervisor({ makePlatform, @@ -290,6 +290,106 @@ describe('VatSupervisor', () => { expect(factory).toHaveBeenCalledTimes(1); }); + it('rejects fetch requests without network.allowedHosts', async () => { + const dispatch = vi.fn(); + const mockFetchBlob: FetchBlob = vi.fn().mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(''), + }); + + // Use a plain function, not vi.fn(), since `makeVatEndowments` + // hardens the globals record transitively — a vi.fn() here would + // freeze the mock's internals and break unrelated tests that mock-reset + // afterwards. + const stubFetch = (() => undefined) as unknown as typeof fetch; + const { stream } = await makeVatSupervisor({ + dispatch, + makeAllowedGlobals: () => makeVatEndowments({ fetch: stubFetch }), + fetchBlob: mockFetchBlob, + }); + + await stream.receiveInput({ + id: 'test-init', + method: 'initVat', + params: { + vatConfig: { + bundleSpec: 'test.bundle', + parameters: {}, + globals: ['fetch'], + }, + state: [], + }, + jsonrpc: '2.0', + }); + await delay(50); + + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'test-init', + error: expect.objectContaining({ + message: expect.stringContaining( + 'requested "fetch" but no network.allowedHosts', + ), + }), + }), + ); + }); + + it('proceeds past the fetch-allowlist guard when network.allowedHosts is supplied', async () => { + // If the guard mis-fires, dispatch would receive the specific + // 'requested "fetch" but no network.allowedHosts' error. Asserting + // its absence proves the caveat wrap ran and init moved on (any + // later error, e.g., bundle load, is acceptable). + const dispatch = vi.fn(); + const mockFetchBlob: FetchBlob = vi.fn().mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(''), + }); + + const stubFetch = (() => undefined) as unknown as typeof fetch; + const { stream } = await makeVatSupervisor({ + dispatch, + makeAllowedGlobals: () => makeVatEndowments({ fetch: stubFetch }), + fetchBlob: mockFetchBlob, + }); + + await stream.receiveInput({ + id: 'test-init', + method: 'initVat', + params: { + vatConfig: { + bundleSpec: 'test.bundle', + parameters: {}, + globals: ['fetch'], + network: { allowedHosts: ['example.test'] }, + }, + state: [], + }, + jsonrpc: '2.0', + }); + await delay(50); + + // With makeLiveSlots mocked, init completes and dispatch receives + // the delivery result — assert that positive shape directly so the + // test doesn't pass vacuously if init were to short-circuit. + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'test-init', + result: expect.anything(), + }), + ); + expect(dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ + id: 'test-init', + error: expect.objectContaining({ + message: expect.stringMatching( + /requested "fetch" but no network\.allowedHosts/u, + ), + }), + }), + ); + }); + it('rejects an unknown global at the initVat RPC boundary', async () => { // VatConfig.globals is now typed as AllowedGlobalName[] and validated by // AllowedGlobalNameStruct at the RPC boundary, so an unknown name is diff --git a/packages/ocap-kernel/src/vats/VatSupervisor.ts b/packages/ocap-kernel/src/vats/VatSupervisor.ts index 97dd3cf4b6..f036a53d5a 100644 --- a/packages/ocap-kernel/src/vats/VatSupervisor.ts +++ b/packages/ocap-kernel/src/vats/VatSupervisor.ts @@ -31,6 +31,8 @@ import { import { loadBundle } from './bundle-loader.ts'; import { createDefaultEndowments, VatEndowmentsStruct } from './endowments.ts'; import type { AllowedGlobalName, MakeAllowedGlobals } from './endowments.ts'; +import { makeCaveatedFetch, makeHostCaveat } from './network-caveat.ts'; +import type { FetchCapability } from './network-caveat.ts'; import { makeGCAndFinalize } from '../garbage-collection/gc-finalize.ts'; import { makeDummyMeterControl } from '../liveslots/meter-control.ts'; import { makeSupervisorSyscall } from '../liveslots/syscall.ts'; @@ -163,7 +165,9 @@ export class VatSupervisor { this.#fetchBlob = fetchBlob ?? defaultFetchBlob; this.#platformOptions = platformOptions ?? {}; this.#makePlatform = makePlatform; - const endowments = makeAllowedGlobals(); + const endowments = makeAllowedGlobals({ + logger: this.#logger.subLogger({ tags: ['endowments'] }), + }); // Defense in depth: custom `MakeAllowedGlobals` factories may return the // wrong shape (e.g., no `teardown` callable) — assert before use so the // failure surfaces at construction rather than during termination. @@ -368,7 +372,8 @@ export class VatSupervisor { meterControl: makeDummyMeterControl(), }); - const { bundleSpec, parameters, platformConfig, globals } = vatConfig; + const { bundleSpec, parameters, platformConfig, globals, network } = + vatConfig; // If the kernel specified a restricted set of allowed global names, // filter the full allowlist down to only those names. @@ -394,6 +399,24 @@ export class VatSupervisor { } } + // Post-wrap the Snaps-produced `fetch` with a per-vat host allowlist. + // The factory itself takes no allowlist, so restriction is applied here + // where `VatConfig.network.allowedHosts` is in scope. Requesting `fetch` + // without an allowlist is rejected — there is no implicit-allow-all + // pathway. + if (hasProperty(requestedGlobals, 'fetch')) { + const allowedHosts = network?.allowedHosts; + if (!allowedHosts) { + throw new Error( + `Vat "${this.id}" requested "fetch" but no network.allowedHosts was specified`, + ); + } + requestedGlobals.fetch = makeCaveatedFetch( + requestedGlobals.fetch as FetchCapability, + makeHostCaveat([...allowedHosts]), + ); + } + const workerEndowments = { console: this.#logger.subLogger({ tags: ['console'] }), assert: globalThis.assert, diff --git a/packages/ocap-kernel/src/vats/endowments.test.ts b/packages/ocap-kernel/src/vats/endowments.test.ts index 412ac10305..9d4c234817 100644 --- a/packages/ocap-kernel/src/vats/endowments.test.ts +++ b/packages/ocap-kernel/src/vats/endowments.test.ts @@ -1,3 +1,4 @@ +import { Logger } from '@metamask/logger'; import { afterEach, describe, it, expect, vi } from 'vitest'; import { createDefaultEndowments } from './endowments.ts'; @@ -22,6 +23,8 @@ vi.mock('@metamask/snaps-execution-environments/endowments', async () => { }; }); +const makeLogger = (): Logger => new Logger('test'); + describe('createDefaultEndowments', () => { // Ordering constraint: tests that pass a `vi.fn()` callback to the real // `setTimeout` endowment must run LAST. Snaps' timeout factory calls @@ -30,15 +33,20 @@ describe('createDefaultEndowments', () => { // frozen mock, and the resulting error surfaces on the NEXT test. afterEach(() => { state.override = null; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); it('produces the expected global names', () => { - const { globals } = createDefaultEndowments(); + const { globals } = createDefaultEndowments({ logger: makeLogger() }); expect(Object.keys(globals).sort()).toStrictEqual([ 'AbortController', 'AbortSignal', 'Date', + 'Headers', 'Math', + 'Request', + 'Response', 'SubtleCrypto', 'TextDecoder', 'TextEncoder', @@ -49,31 +57,32 @@ describe('createDefaultEndowments', () => { 'clearInterval', 'clearTimeout', 'crypto', + 'fetch', 'setInterval', 'setTimeout', ]); }); it('does not leak teardownFunction into globals', () => { - const { globals } = createDefaultEndowments(); + const { globals } = createDefaultEndowments({ logger: makeLogger() }); expect(Object.keys(globals)).not.toContain('teardownFunction'); }); it('freezes both the result and the globals record', () => { - const result = createDefaultEndowments(); + const result = createDefaultEndowments({ logger: makeLogger() }); expect(Object.isFrozen(result)).toBe(true); expect(Object.isFrozen(result.globals)).toBe(true); }); it('returns isolated instances per call', () => { - const first = createDefaultEndowments(); - const second = createDefaultEndowments(); + const first = createDefaultEndowments({ logger: makeLogger() }); + const second = createDefaultEndowments({ logger: makeLogger() }); expect(first).not.toBe(second); expect(first.globals.setTimeout).not.toBe(second.globals.setTimeout); }); it('teardown resolves without error when no resources are held', async () => { - const { teardown } = createDefaultEndowments(); + const { teardown } = createDefaultEndowments({ logger: makeLogger() }); expect(await teardown()).toBeUndefined(); }); @@ -101,7 +110,7 @@ describe('createDefaultEndowments', () => { }, ] as unknown as typeof state.override; - const { teardown } = createDefaultEndowments(); + const { teardown } = createDefaultEndowments({ logger: makeLogger() }); let caught: unknown; try { @@ -125,15 +134,144 @@ describe('createDefaultEndowments', () => { }, ] as unknown as typeof state.override; - expect(() => createDefaultEndowments()).toThrow( + expect(() => createDefaultEndowments({ logger: makeLogger() })).toThrow( /Failed to construct endowment factory for \[setTimeout, clearTimeout\]/u, ); }); + it('passes a working notify callback to the network factory', () => { + const notifyCalls: unknown[] = []; + state.override = [ + { + names: ['fetch'], + factory: (options?: { + notify?: (notification: unknown) => unknown; + }) => { + notifyCalls.push(options?.notify); + return { fetch: () => undefined }; + }, + }, + ] as unknown as typeof state.override; + + createDefaultEndowments({ logger: makeLogger() }); + expect(typeof notifyCalls[0]).toBe('function'); + }); + + it('routes notify calls through the logger at debug level', async () => { + const logger = makeLogger(); + const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => { + // silence test output + }); + let capturedNotify: + | ((n: { method: string; params: unknown }) => Promise) + | undefined; + + state.override = [ + { + names: ['fetch'], + factory: (options?: { + notify?: (n: { method: string; params: unknown }) => Promise; + }) => { + capturedNotify = options?.notify; + return { fetch: () => undefined }; + }, + }, + ] as unknown as typeof state.override; + + createDefaultEndowments({ logger }); + await capturedNotify?.({ + method: 'OutboundRequest', + params: { source: 'fetch' }, + }); + + expect(debugSpy).toHaveBeenCalledWith('network:OutboundRequest', { + source: 'fetch', + }); + }); + + it('swallows logger errors inside notify and surfaces them via console.error', async () => { + const logger = makeLogger(); + const transportError = new Error('transport broke'); + vi.spyOn(logger, 'debug').mockImplementation(() => { + throw transportError; + }); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + let capturedNotify: + | ((n: { method: string; params: unknown }) => Promise) + | undefined; + + state.override = [ + { + names: ['fetch'], + factory: (options?: { + notify?: (n: { method: string; params: unknown }) => Promise; + }) => { + capturedNotify = options?.notify; + return { fetch: () => undefined }; + }, + }, + ] as unknown as typeof state.override; + + createDefaultEndowments({ logger }); + expect( + await capturedNotify?.({ + method: 'OutboundRequest', + params: { source: 'fetch' }, + }), + ).toBeUndefined(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[ocap-kernel] network endowment logger transport failed', + transportError, + ); + }); + + it('teardown aborts in-flight fetches started by the network factory', async () => { + // Stub globalThis.fetch with a never-resolving promise that respects + // AbortSignal. The Snaps factory's `withTeardown` helper drops — rather + // than rejects — the vat-visible fetch promise once teardown runs, so + // the meaningful assertions are (1) the abort signal propagated into + // the base fetch and (2) teardown returns cleanly without hanging on + // the open connection. `vi.stubGlobal` + `unstubAllGlobals` in afterEach + // keeps the global mutation scoped to this test. + let abortReceived = false; + vi.stubGlobal( + 'fetch', + (async (_input: unknown, init?: RequestInit) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + abortReceived = true; + reject(new Error('aborted')); + }); + })) as typeof fetch, + ); + + const { globals, teardown } = createDefaultEndowments({ + logger: makeLogger(), + }); + const fetchFn = globals.fetch as typeof fetch; + + const inFlight = fetchFn('https://example.test/slow'); + // Suppress unhandled-rejection noise — the Snaps factory drops this + // promise on teardown (neither resolves nor rejects it). + inFlight.catch(() => undefined); + + // Give the factory a microtask to register the open connection. + await new Promise((resolve) => setTimeout(resolve, 0)); + + await teardown(); + + expect(abortReceived).toBe(true); + }); + it('teardown cancels pending timers', async () => { // SES lockdown freezes Date, preventing vi.useFakeTimers(); use a real // delay that exceeds the factory's 10ms MINIMUM_TIMEOUT. - const { globals, teardown } = createDefaultEndowments(); + const { globals, teardown } = createDefaultEndowments({ + logger: makeLogger(), + }); const setTimeoutFn = globals.setTimeout as typeof globalThis.setTimeout; const callback = vi.fn(); setTimeoutFn(callback, 10); diff --git a/packages/ocap-kernel/src/vats/endowments.ts b/packages/ocap-kernel/src/vats/endowments.ts index 33697d4e83..5cd4d7e261 100644 --- a/packages/ocap-kernel/src/vats/endowments.ts +++ b/packages/ocap-kernel/src/vats/endowments.ts @@ -1,4 +1,6 @@ +import type { Logger } from '@metamask/logger'; import { buildCommonEndowments } from '@metamask/snaps-execution-environments/endowments'; +import type { NotifyFunction } from '@metamask/snaps-execution-environments/endowments'; import { enums, func, @@ -19,11 +21,10 @@ import { * `Date.now()` or `Math.random()` throws in secure mode unless a working * replacement is endowed. * - * NOTE: adding `fetch`, `Request`, `Response`, `Headers`, or `console` here - * will pull in a Snaps factory that requires runtime options (`notify` or - * `sourceLabel`) and will throw when called without them. Integrating those - * requires adjusting {@link createDefaultEndowments} to pass the right - * options through — see ocap-kernel issue #936 for the network case. + * NOTE: adding `console` here will pull in a Snaps factory that requires + * a `sourceLabel` option and will throw when called without it. Integrating + * that requires adjusting {@link createDefaultEndowments} to pass the + * option through. */ const ALLOWED_GLOBAL_NAMES = [ // Attenuated timer factories — isolated per vat, with teardown for @@ -49,6 +50,17 @@ const ALLOWED_GLOBAL_NAMES = [ 'crypto', 'SubtleCrypto', + // Attenuated network. The Snaps factory wraps `fetch` so teardown can + // abort in-flight requests and cancel open body streams; `Request`, + // `Headers`, and `Response` are hardened constructors surfaced alongside + // it so vat code can build requests/headers before calling `fetch`. + // Host restriction is applied by `VatSupervisor` per-vat using + // `makeHostCaveat` — the factory itself accepts no allowlist. + 'fetch', + 'Request', + 'Headers', + 'Response', + // Plain hardened Web APIs (no attenuation). 'TextEncoder', 'TextDecoder', @@ -107,31 +119,89 @@ export const VatEndowmentsStruct = object({ teardown: func(), }); +/** + * Options consumed by {@link MakeAllowedGlobals} factories. + */ +export type MakeAllowedGlobalsOptions = { + /** + * Logger used by stateful endowments for observability — e.g. the network + * factory emits `OutboundRequest`/`OutboundResponse` notifications through + * it at debug level. Sub-scope with `subLogger` beforehand if caller wants + * a dedicated tag. + */ + logger: Logger; +}; + /** * Factory that produces a fresh {@link VatEndowments} for a single vat. * Consumers supply this to a `VatSupervisor` to override the default * endowment set (see {@link createDefaultEndowments}). */ -export type MakeAllowedGlobals = () => VatEndowments; +export type MakeAllowedGlobals = ( + options: MakeAllowedGlobalsOptions, +) => VatEndowments; + +/** + * Build a `notify` callback for the Snaps network factory that routes + * outbound request/response lifecycle events to the vat logger at debug + * level. + * + * The callback is awaited inside the factory's fetch implementation, so a + * throw here would propagate into the vat's `fetch` call. The try/catch is + * defensive: the logger's own methods don't throw today, but we don't want + * an accidental transport failure to turn into a vat-visible fetch error. + * When the logger does fail, surface it via `console.error` so the outage + * is visible — silent swallow would hide a broken audit trail. + * + * @param logger - The logger to route notifications through. + * @returns A notify callback suitable for the Snaps network factory. + */ +const makeLoggerNotify = (logger: Logger): NotifyFunction => { + return async ({ method, params }) => { + try { + logger.debug(`network:${method}`, params); + } catch (error) { + try { + // eslint-disable-next-line no-console + console.error( + '[ocap-kernel] network endowment logger transport failed', + error, + ); + } catch { + // fetch must not break on a broken host console either + } + } + }; +}; /** * Build a fresh set of vat endowments from the Snaps attenuated factories, * filtered to the names in {@link ALLOWED_GLOBAL_NAMES}. Each call produces - * an isolated instance — timers and other stateful endowments are not shared - * across vats, so one vat cannot clear another vat's timers. + * an isolated instance — timers, network state, and other stateful + * endowments are not shared across vats, so one vat cannot clear another + * vat's timers or abort another vat's in-flight fetches. + * + * Snaps' `buildCommonEndowments()` also ships `console`, `WebAssembly`, + * typed arrays, `Intl`, etc. Those are either SES intrinsics already present + * in every Compartment (so endowing is redundant) or deliberately withheld + * from vats. * - * Snaps' `buildCommonEndowments()` also ships `fetch`, `console`, - * `WebAssembly`, typed arrays, `Intl`, etc. Those are either SES intrinsics - * already present in every Compartment (so endowing is redundant) or - * deliberately withheld from vats (e.g., unattenuated network access). + * The `fetch` produced here is NOT host-restricted. `VatSupervisor` wraps + * it with a `makeHostCaveat` before handing it to a Compartment, reading + * the allowlist from the vat's own `VatConfig.network.allowedHosts`. * * The aggregate `teardown` uses `Promise.allSettled` so one failing factory * does not silently mask failures in others; all rejections are surfaced as * an {@link AggregateError}. * + * @param options - Factory options; see {@link MakeAllowedGlobalsOptions}. + * @param options.logger - The logger to route notifications through. * @returns The endowment globals and an aggregate teardown function. */ -export function createDefaultEndowments(): VatEndowments { +export function createDefaultEndowments({ + logger, +}: MakeAllowedGlobalsOptions): VatEndowments { + const notify = makeLoggerNotify(logger); const globals: Record = {}; const teardowns: (() => Promise | void)[] = []; @@ -141,7 +211,7 @@ export function createDefaultEndowments(): VatEndowments { } let result; try { - result = factory(); + result = factory({ notify }); } catch (error) { const cause = error instanceof Error ? error.message : String(error); throw new Error( diff --git a/packages/ocap-kernel/src/vats/network-caveat.test.ts b/packages/ocap-kernel/src/vats/network-caveat.test.ts new file mode 100644 index 0000000000..e2c4e6a878 --- /dev/null +++ b/packages/ocap-kernel/src/vats/network-caveat.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + resolveUrl, + makeHostCaveat, + makeCaveatedFetch, +} from './network-caveat.ts'; + +describe('resolveUrl', () => { + it.each([ + { name: 'string URL', input: 'https://example.test/path' }, + { + name: 'Request object URL', + input: new Request('https://example.test/path'), + }, + { name: 'URL object', input: new URL('https://example.test/path') }, + ])('resolves $name', ({ input }) => { + const result = resolveUrl(input); + expect(result).toBeInstanceOf(URL); + expect(result.href).toBe('https://example.test/path'); + }); + + it('throws for malformed string URLs', () => { + expect(() => resolveUrl('not a url')).toThrow(/Invalid URL/u); + }); +}); + +describe('makeHostCaveat', () => { + it('allows allowed hostnames', async () => { + const caveat = makeHostCaveat(['example.test', 'api.github.com']); + expect(await caveat('https://example.test/path')).toBeUndefined(); + expect(await caveat('https://api.github.com/users')).toBeUndefined(); + }); + + it('rejects disallowed hostnames', async () => { + const caveat = makeHostCaveat(['example.test']); + await expect(caveat('https://malicious.test/path')).rejects.toThrow( + 'Invalid host: malicious.test', + ); + }); + + it('ignores port when matching hostnames', async () => { + const caveat = makeHostCaveat(['api.example.test']); + expect(await caveat('https://api.example.test:8443/path')).toBeUndefined(); + }); + + it.each([ + { label: 'file: string input', input: 'file:///etc/passwd' }, + { label: 'file: Request input', input: new Request('file:///etc/passwd') }, + ])('rejects $label with an fs-capability hint', async ({ input }) => { + const caveat = makeHostCaveat(['example.test']); + await expect(caveat(input)).rejects.toThrow( + /fetch cannot target file:\/\/ URLs.*fs platform capability/u, + ); + }); + + it.each([ + { label: 'data:', input: 'data:text/plain,hello' }, + { label: 'blob:', input: 'blob:https://example.test/abc123' }, + ])( + 'rejects $label URLs via the hostname check (opaque origin has empty hostname)', + async ({ input }) => { + const caveat = makeHostCaveat(['example.test']); + await expect(caveat(input)).rejects.toThrow('Invalid host:'); + }, + ); + + it.each([ + { + name: 'Request objects', + input: new Request('https://example.test/path'), + }, + { name: 'URL objects', input: new URL('https://example.test/path') }, + ])('handles $name', async ({ input }) => { + const caveat = makeHostCaveat(['example.test']); + expect(await caveat(input)).toBeUndefined(); + }); + + it('rejects malformed URLs by propagating the URL constructor error', async () => { + const caveat = makeHostCaveat(['example.test']); + await expect(caveat('not a url')).rejects.toThrow(/Invalid URL/u); + }); +}); + +describe('makeCaveatedFetch', () => { + it('applies caveat and forwards to fetch', async () => { + const mockResponse = new Response('test'); + const baseFetch = vi.fn().mockResolvedValue(mockResponse); + const caveat = vi.fn().mockResolvedValue(undefined); + + const caveated = makeCaveatedFetch(baseFetch, caveat); + const result = await caveated('https://example.test/path'); + + expect(caveat).toHaveBeenCalledWith('https://example.test/path'); + expect(baseFetch).toHaveBeenCalledWith('https://example.test/path'); + expect(result).toBe(mockResponse); + }); + + it('does not call fetch when caveat rejects', async () => { + const baseFetch = vi.fn(); + const caveat = vi.fn().mockRejectedValue(new Error('Host not allowed')); + + const caveated = makeCaveatedFetch(baseFetch, caveat); + await expect(caveated('https://malicious.test/path')).rejects.toThrow( + 'Host not allowed', + ); + expect(baseFetch).not.toHaveBeenCalled(); + }); + + it('forwards init options to fetch', async () => { + const baseFetch = vi.fn().mockResolvedValue(new Response('test')); + const caveat = vi.fn().mockResolvedValue(undefined); + + const caveated = makeCaveatedFetch(baseFetch, caveat); + const init = { method: 'POST', body: 'data' }; + await caveated('https://example.test/path', init); + + expect(caveat).toHaveBeenCalledWith('https://example.test/path', init); + expect(baseFetch).toHaveBeenCalledWith('https://example.test/path', init); + }); + + it('composes host caveat with base fetch end-to-end', async () => { + const baseFetch = vi.fn().mockResolvedValue(new Response('ok')); + const caveated = makeCaveatedFetch( + baseFetch, + makeHostCaveat(['example.test']), + ); + + const response = await caveated('https://example.test/data'); + expect(await response.text()).toBe('ok'); + expect(baseFetch).toHaveBeenCalledTimes(1); + + await expect(caveated('https://evil.test/data')).rejects.toThrow( + 'Invalid host: evil.test', + ); + expect(baseFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ocap-kernel/src/vats/network-caveat.ts b/packages/ocap-kernel/src/vats/network-caveat.ts new file mode 100644 index 0000000000..9e3f0d283b --- /dev/null +++ b/packages/ocap-kernel/src/vats/network-caveat.ts @@ -0,0 +1,62 @@ +export type FetchCapability = typeof fetch; + +type FetchCaveat = (...args: Parameters) => Promise; + +/** + * Resolve the target URL from a fetch input argument. Accepts the same input + * shapes as `fetch` itself (string, URL, or Request). + * + * @param arg - The input to resolve. + * @returns The resolved URL. + */ +export const resolveUrl = (arg: Parameters[0]): URL => + new URL(arg instanceof Request ? arg.url : arg); + +/** + * Build a caveat that rejects fetches whose hostname is not in + * `allowedHosts`. Matching is a case-sensitive exact comparison against + * `URL.hostname` — **ports and schemes are not considered**, so + * `allowedHosts: ['api.example.com']` accepts `http://api.example.com`, + * `https://api.example.com`, and `https://api.example.com:8443` alike. + * + * `file://` URLs are rejected outright: vats that need local file access + * must use the `fs` platform capability, not fetch. This avoids the footgun + * where a vat that opts into `fetch` for HTTP requests inadvertently gains + * unrestricted filesystem read access. + * + * @param allowedHosts - The allowed hostnames. + * @returns A caveat that restricts fetch to the allowed hostnames. + */ +export const makeHostCaveat = (allowedHosts: string[]): FetchCaveat => { + return harden(async (...args: Parameters) => { + const { hostname, protocol } = resolveUrl(args[0]); + if (protocol === 'file:') { + throw new Error( + `fetch cannot target file:// URLs. Use the fs platform capability ` + + `(VatConfig.platformConfig.fs) for filesystem access.`, + ); + } + if (!allowedHosts.includes(hostname)) { + throw new Error(`Invalid host: ${hostname}`); + } + }); +}; + +/** + * Wrap a fetch capability so a caveat runs before every call. The caveat may + * throw to reject the request; a throw prevents the underlying fetch from + * being invoked. + * + * @param baseFetch - The fetch capability to wrap. + * @param caveat - The caveat to apply before each call. + * @returns A fetch capability gated by the caveat. + */ +export const makeCaveatedFetch = ( + baseFetch: FetchCapability, + caveat: FetchCaveat, +): FetchCapability => { + return harden(async (...args: Parameters) => { + await caveat(...args); + return await baseFetch(...args); + }); +};