[P1 security] Browser actor network.fetch permits arbitrary private-network requests and unbounded reads #134

Open
opened 2026-07-28 23:02:33 +00:00 by lost-rob0t · 0 comments
lost-rob0t commented 2026-07-28 23:02:33 +00:00 (Migrated from github.com)

Finding

src/lib/actors.js exposes this service to actors with the network.fetch capability:

const response = await fetch(url.href, {
  method: String(options.method || "GET").toUpperCase(),
  headers: options.headers || {},
  body: options.body,
  credentials: "omit",
  redirect: "follow",
  signal
});
const body = responseType === "json"
  ? await response.json()
  : await response.text();

The service currently allows:

  • loopback, RFC1918, link-local, metadata, and other private-network targets;
  • arbitrary methods, including state-changing POST/PUT/PATCH/DELETE;
  • actor-controlled headers and request bodies;
  • automatic redirect following;
  • full response buffering before any byte limit is enforced.

The runtime’s maxResponseBytes check happens only after response.text() / response.json() has already allocated and parsed the complete body. A large response can therefore exhaust browser memory even when the returned value is later rejected.

credentials: "omit" is useful but does not prevent local-service side effects, CSRF-like unauthenticated actions, DNS rebinding, or actor-supplied authorization headers.

Required fix

Do not expose raw browser fetch as a capability. Use the validated external-fetch gateway from the private-network issue, plus explicit actor policy.

const SAFE_METHODS = new Set(["GET", "HEAD"]);
const FORBIDDEN_HEADERS = /^(?:authorization|cookie|proxy-|sec-|host|origin|referer|x-api-key)/i;

function normalizeActorFetchRequest(payload, grant) {
  const method = String(payload?.options?.method || "GET").toUpperCase();
  if (!SAFE_METHODS.has(method) && !grant.allowMutations) {
    throw new Error(`Actor fetch method denied: ${method}`);
  }

  const headers = Object.fromEntries(
    Object.entries(payload?.options?.headers || {}).filter(
      ([name]) => !FORBIDDEN_HEADERS.test(name)
    )
  );

  return {
    url: requireGrantedOrigin(payload.url, grant.origins),
    method,
    headers,
    body: grant.allowBody ? boundedBody(payload?.options?.body) : undefined
  };
}

Read through a bounded stream rather than text() / json():

async function readBoundedBody(response, maxBytes, signal) {
  const reader = response.body?.getReader();
  if (!reader) return new Uint8Array();

  const chunks = [];
  let total = 0;
  for (;;) {
    signal?.throwIfAborted?.();
    const { done, value } = await reader.read();
    if (done) break;
    total += value.byteLength;
    if (total > maxBytes) {
      await reader.cancel("response limit exceeded");
      throw new RangeError(`Actor response exceeds ${maxBytes} bytes`);
    }
    chunks.push(value);
  }
  return concatenate(chunks, total);
}

Actor manifests should declare narrow grants, not only a Boolean capability:

{
  "capabilities": {
    "network.fetch": {
      "origins": ["https://api.example.com"],
      "methods": ["GET"],
      "maxRequests": 20,
      "maxRequestBytes": 16384,
      "maxResponseBytes": 1048576
    }
  }
}

Until granular grants and a safe gateway exist, disable network.fetch for imported/generated actors.

Acceptance criteria

  • Actor code cannot directly request private-network or metadata targets.
  • DNS and redirects are validated outside the browser page.
  • Methods, origins, headers, request bytes, response bytes, redirect count, and duration are independently bounded.
  • Oversized responses are cancelled while streaming, before full allocation/parsing.
  • Imported/generated actors receive no network permission by default.
  • Tests cover localhost POST, DNS rebinding, redirect-to-private, actor-supplied auth headers, large/chunked bodies, compressed bombs, and abort behavior.
## Finding `src/lib/actors.js` exposes this service to actors with the `network.fetch` capability: ```js const response = await fetch(url.href, { method: String(options.method || "GET").toUpperCase(), headers: options.headers || {}, body: options.body, credentials: "omit", redirect: "follow", signal }); const body = responseType === "json" ? await response.json() : await response.text(); ``` The service currently allows: - loopback, RFC1918, link-local, metadata, and other private-network targets; - arbitrary methods, including state-changing POST/PUT/PATCH/DELETE; - actor-controlled headers and request bodies; - automatic redirect following; - full response buffering before any byte limit is enforced. The runtime’s `maxResponseBytes` check happens only after `response.text()` / `response.json()` has already allocated and parsed the complete body. A large response can therefore exhaust browser memory even when the returned value is later rejected. `credentials: "omit"` is useful but does not prevent local-service side effects, CSRF-like unauthenticated actions, DNS rebinding, or actor-supplied authorization headers. ## Required fix Do not expose raw browser `fetch` as a capability. Use the validated external-fetch gateway from the private-network issue, plus explicit actor policy. ```js const SAFE_METHODS = new Set(["GET", "HEAD"]); const FORBIDDEN_HEADERS = /^(?:authorization|cookie|proxy-|sec-|host|origin|referer|x-api-key)/i; function normalizeActorFetchRequest(payload, grant) { const method = String(payload?.options?.method || "GET").toUpperCase(); if (!SAFE_METHODS.has(method) && !grant.allowMutations) { throw new Error(`Actor fetch method denied: ${method}`); } const headers = Object.fromEntries( Object.entries(payload?.options?.headers || {}).filter( ([name]) => !FORBIDDEN_HEADERS.test(name) ) ); return { url: requireGrantedOrigin(payload.url, grant.origins), method, headers, body: grant.allowBody ? boundedBody(payload?.options?.body) : undefined }; } ``` Read through a bounded stream rather than `text()` / `json()`: ```js async function readBoundedBody(response, maxBytes, signal) { const reader = response.body?.getReader(); if (!reader) return new Uint8Array(); const chunks = []; let total = 0; for (;;) { signal?.throwIfAborted?.(); const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > maxBytes) { await reader.cancel("response limit exceeded"); throw new RangeError(`Actor response exceeds ${maxBytes} bytes`); } chunks.push(value); } return concatenate(chunks, total); } ``` Actor manifests should declare narrow grants, not only a Boolean capability: ```json { "capabilities": { "network.fetch": { "origins": ["https://api.example.com"], "methods": ["GET"], "maxRequests": 20, "maxRequestBytes": 16384, "maxResponseBytes": 1048576 } } } ``` Until granular grants and a safe gateway exist, disable `network.fetch` for imported/generated actors. ## Acceptance criteria - Actor code cannot directly request private-network or metadata targets. - DNS and redirects are validated outside the browser page. - Methods, origins, headers, request bytes, response bytes, redirect count, and duration are independently bounded. - Oversized responses are cancelled while streaming, before full allocation/parsing. - Imported/generated actors receive no network permission by default. - Tests cover localhost POST, DNS rebinding, redirect-to-private, actor-supplied auth headers, large/chunked bodies, compressed bombs, and abort behavior.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
nsaspy/quasar-ui#134
No description provided.