---
title: "URL egress inventory"
description: "Local-first visual environment for building and running AI workflows. Build agents visually, deploy anywhere, privacy by design."
canonical: https://docs.nodetool.ai/url-egress-inventory
markdown: https://docs.nodetool.ai/url-egress-inventory.md
product: NodeTool
source: https://github.com/nodetool-ai/nodetool/blob/main/docs/url-egress-inventory.md
---

# URL egress inventory

# URL egress inventory

Where NodeTool's server can be told to open an outbound request, who tells it,
and what decides whether it may.

A URL that reaches this process is rarely typed by the person who owns the
machine. It rides in on a media ref inside a workflow, in a provider's result
body, on a chat client's attachment, or straight out of a model that read
someone else's web page a moment ago. A bare `fetch(uri)` on any of those is
the server making a request on a stranger's behalf from inside the perimeter,
which is how `http://169.254.169.254/latest/meta-data/` gets read.

The machine-readable list is
`packages/runtime/tests/url-egress-inventory.ts`; the audit that keeps it honest
is `url-egress-audit.test.ts` beside it. Run both with
`npm run test --workspace=packages/runtime -- url-egress`.

## The policy

**Default:** https, to a host that is not a loopback, link-local, RFC1918,
CGNAT or benchmarking address — and every redirect hop re-checked.
`safeFetch` in `packages/runtime/src/providers/safe-url.ts` is that policy as a
function, and it is the only thing that counts as protection. `isSafePublicHttpsUrl`
and friends are predicates: a predicate can refuse an initial URL, but a public
host that answers `302 Location: http://127.0.0.1:6379/` walks past one.

**Media refs** go through `fetchExternalMedia`
(`packages/runtime/src/external-media-fetch.ts`), which is `safeFetch` plus one
documented opt-out (below).

**The sandbox fetch bridge** runs its own policy on purpose: plain http is
allowed, and a host — never guest code — may waive the address check for a node
that replaces a trusted `lib.http` node. It reads the same address table.

### One address table

`isBlockedIpLiteral` in `providers/safe-url.ts` decides which literals are
internal. It covers IPv4 (loopback, 0.0.0.0/8, RFC1918, 169.254/16, CGNAT,
198.18/15), the alternate spellings the WHATWG parser normalizes for us
(decimal, hex, octal, short form), IPv6 loopback/unspecified/ULA/link-local, and
the IPv6 forms that carry an IPv4 inside them — IPv4-mapped, IPv4-compatible,
6to4, NAT64.

Four tables existed before: this one, the sandbox's in
`packages/agents/src/network-guard.ts` (which now imports it), a private copy
inside `unified-websocket-runner.ts` (deleted — it allowed http and screened
only the first URL), and `isSafeHttpUrl` in `together-nodes` /
`atlascloud-nodes`, which survive as local predicates in front of a protected
fetch. The audit fails if a private copy comes back.

### DNS rebinding

**Decision: not defended in-process; the deployment's egress control is the
boundary.** A name that passes the address check can resolve to a private
address a millisecond later, and this process does not choose the address
`fetch` connects to — pinning one means resolving ourselves and dialing an IP
with a `Host` header, which breaks TLS verification and virtual hosting for
every provider CDN we download from.

One exception, and it earns the round trip: when the URL is handed to a
*separate process* that opens its own sockets, `assertResolvedHostAllowed`
(`packages/agents/src/network-guard.ts`) resolves the name and refuses a
blocked answer first. `yt_dlp` is the surface that uses it.

Every inventory entry records which of the two it is, in `dnsRebinding`. An
entry that claims `resolve-and-check` must call the resolver.

### The self-hosted opt-out

`NODETOOL_ALLOW_PRIVATE_MEDIA_FETCH=1` turns the guard off for media-ref
fetches — and for nothing else. It exists for an install that genuinely serves
media off its own LAN (`http://nas.local/clip.mp4`). It is read per call, so it
takes effect without a restart, and it is off by default. See
[configuration.md](configuration.md).

## Guarded surfaces

Everything here fetches a URL somebody else chose, through the protected fetch.

| Surface | File | URL comes from |
|---|---|---|
| media ref → bytes (Python bridge + TS nodes) | `packages/runtime/src/media-ref-bytes.ts` | workflow |
| AudioRef → bytes | `packages/audio-nodes/src/lib/audio-wav.ts` | workflow |
| Model3DRef / ImageRef → bytes | `packages/video-nodes/src/nodes/model3d/utils.ts` | workflow |
| `nodetool.model3d.RenderToImage` | `packages/video-nodes/src/nodes/model3d/render.ts` | workflow |
| chat source images + output auto-save | `packages/websocket/src/unified-websocket-runner.ts` | chat client |
| asset import by reference | `packages/websocket/src/http-api.ts` | chat client |
| `save_asset` / `view_image` | `packages/agents/src/capabilities/assets.ts` | model |
| `yt_dlp` download | `packages/agents/src/capabilities/media.ts` | model |
| Apify run artifacts → assets | `packages/agents/src/apify/assets.ts` | provider response |
| Reve reference image | `packages/reve-nodes/src/reve-base.ts` | workflow |
| HF pipeline media input | `packages/huggingface-nodes/src/huggingface-base.ts` | workflow |
| FAL media upload | `packages/fal-nodes/src/fal-base.ts` | workflow |
| Replicate files upload | `packages/replicate-nodes/src/replicate-base.ts` | workflow |
| OpenAI media input | `packages/llm-nodes/src/nodes/openai.ts` | workflow |
| Together asset resolution | `packages/together-nodes/src/together-base.ts` | workflow |
| AtlasCloud media pass-through | `packages/atlascloud-nodes/src/atlascloud-factory.ts` | workflow |
| KIE result downloads | `packages/kie-nodes/src/kie-base.ts` | provider response |
| Topaz result downloads | `packages/topaz-nodes/src/topaz-base.ts` | provider response |
| MiniMax audio / file downloads | `packages/minimax-nodes/src/minimax-base.ts` | provider response |
| MiniMax image download | `packages/minimax-nodes/src/nodes/text-to-image.ts` | provider response |
| Gemini node video download | `packages/llm-nodes/src/nodes/gemini.ts` | provider response |
| provider result downloads | `packages/runtime/src/providers/{fal,replicate,kie,topaz,meshy,rodin,minimax,evolink,gemini,anthropic}-provider.ts` | provider response |
| MCP OAuth Client ID Metadata Document fetch | `packages/websocket/src/oauth/cimd.ts` | model/client (an MCP client's self-hosted `client_id` URL) |

The provider row is ten files, each downloading a URL a provider's response
named:

- `packages/runtime/src/providers/fal-provider.ts`
- `packages/runtime/src/providers/replicate-provider.ts`
- `packages/runtime/src/providers/kie-provider.ts`
- `packages/runtime/src/providers/topaz-provider.ts`
- `packages/runtime/src/providers/meshy-provider.ts`
- `packages/runtime/src/providers/rodin-provider.ts`
- `packages/runtime/src/providers/minimax-provider.ts`
- `packages/runtime/src/providers/evolink-provider.ts`
- `packages/runtime/src/providers/gemini-provider.ts`
- `packages/runtime/src/providers/anthropic-provider.ts`

Screening code itself: `packages/runtime/src/providers/safe-url.ts`,
`packages/runtime/src/external-media-fetch.ts`,
`packages/agents/src/network-guard.ts`, `packages/agents/src/apify/policy.ts`.
The guest bridge is `packages/agents/src/js-sandbox.ts`.

## Exemptions

**Fixed provider hosts (43 files).** The URL is a constant in this repo, at most
with a path or query interpolated — `api.elevenlabs.io`, `fal.run`,
`generativelanguage.googleapis.com`, the OAuth token endpoints, the codegen
schema fetchers. Screening them would refuse nothing and would break an
install whose configured endpoint is deliberately internal. They carry the
provider's own credential, so the rule that matters for them is the opposite
one: a URL that is *not* fixed must not inherit that credential. Each is listed
in the data module with its auth scope.

**Deliberately private hosts.** Reaching an internal address is the feature:

- `packages/runtime/src/comfy-executor.ts` — a ComfyUI server, normally
  localhost or the LAN. The address comes from settings, never from a graph.
- `packages/agents/src/capabilities/web.ts` — `BROWSER_URL`, the operator's
  screenshot service. Every model-named URL in that file goes through
  `safeFetch`; this one is the operator's own.
- `packages/cli/src/nodetool.ts` — runs on the operator's machine against their
  own API URL, loopback by default.
- `packages/deploy/src/admin-client.ts` — the server being deployed.
- `packages/node-sdk/src/package-registry-client.ts` — the pack registry,
  overridable for a self-hosted one.

**Browser-side.** The socket belongs to the viewer's browser, not the server:
`packages/image-nodes/src/nodes/image-io.ts` (behind `!IS_NODE`),
`packages/core-nodes/src/nodes/fake-media.ts`,
`packages/automation-nodes/src/lib/browser-capture.ts` (inside `page.evaluate`).

## Adding a surface

When a change lets a new URL reach `fetch`, answer these in the PR and add the
row to `url-egress-inventory.ts` — the audit fails without it:

1. **Input source.** Who picks the URL: a model, a workflow, a chat client, a
   provider's response, or a constant in this repo? Anything but the last is
   caller data.
2. **Egress target.** Does this process open the socket, or does a browser, a
   subprocess, or the provider? Only the first is our SSRF.
3. **Authorization.** What credential rides along, and would it leak on a
   cross-origin redirect? `safeFetch` strips `Authorization`, `Cookie`,
   `Proxy-Authorization` and `X-Goog-Api-Key` when the origin changes.
4. **Redirects.** Who checks each hop? If the answer is "the runtime follows
   them", the URL had better be fixed.
5. **CSP.** If the fetched bytes end up rendered in the web app, check
   `web/src/__tests__/contentSecurityPolicy.test.ts` — a new media origin needs
   a `connect-src`/`img-src` entry, and stored media renders through
   `ResponsiveImage` / `VideoPlayer` / `AudioPlayback`, never a raw locator.
6. **DNS rebinding.** `deployment-egress` unless the URL leaves this process
   for another one, in which case resolve and check.
