Network permissions
networkDeclare the hosts your app may call; every other outbound call is blocked and reported, in CI and in production.
networkFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
network makes your app declare who it may talk to, like a mobile app's permissions. Anything not on your list in main.yaml, or declared by a driver, is blocked and reported.
At a glance
network:
allow:
- api.partner.com
- "*.pinecart.dev"
default: deny
mode: enforce
http:
timeout: 10s
connect: 3s
retries: { attempts: 3, backoff: 200ms, max: 5s, deadline: 30s }
breaker: { failures: 50%, window: 30s, min: 10, cooldown: 15s }
hosts:
api.partner.com: { timeout: 3s, retries: { attempts: 5 } }
exceptions:
- { rule: net.default_deny, reason: "partner VPN", owner: "@pinecart/platform", expires: 2026-11-30 }import { http } from "mainsdk";
// allowed only if api.partner.com is in network.allow
const res = await http.fetch("https://api.partner.com/v1/rates");lib/rates.test.ts
import assert from "node:assert/strict";
import { it } from "node:test";
import { createTestApp } from "@mainsdk/testing";
import { http } from "mainsdk";
it("network AC1 blocks an undeclared host", async () => {
const app = createTestApp({
yaml: "schema: 1\nproject: rates\nnetwork:\n allow: [api.partner.com]\n",
respond: () => Response.json({ price: 5 }), // what an allowed host answers
});
await assert.rejects(http.fetch("https://evil.example"), { code: "MAIN-NET-0002" });
assert.deepEqual(await http.json("https://api.partner.com/v1/rates"), { price: 5 });
assert.equal(app.sent.length, 1); // only the allowed call was sent
});Calls
http.fetch(url, init)http.get(url, init)http.post(url, body, init)http.json(url, init)http.events(url, init)http.websocket(url, protocols)http.assert(url)network.destinations()exportAwsSg(allow, opts)exportK8s(allow, opts)isPrivateIp(ip)parseRule(text)createHttp(net)network.assert(target)network.modenetwork.rulesnetwork.decisionsnetwork.entriesnetwork.breakerSlots()network.breakerStates()network.decide(url, reason?, caller?)network.decidePublic(url, caller?)network.fetch(input, init?)network.fetchPublic(input, init?)network.matchHost(host)network.matchIp(ip)network.reload(opts)network.settingsFor(host, call?)network.trim()
Guarantees
Whatever the driver.
- Deny by default; undeclared hosts blocked and reported
- DNS rebinding and redirects to undeclared hosts refused
- Every helper (get, post, json, events, websocket) passes the same check as http.fetch
- http.json and http.events refuse non-2xx answers and non-JSON bodies with MAIN-NET-0008
- Every outbound call has a timeout; retries and a per-host breaker are built in, with no hand-rolled loops
- Free on every plan
Drivers
1 drivers. Select one to see its line in main.yaml.
network:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-NET-0001 | Checkmainsdk check, before deploy | Raw socket, another HTTP client or direct fetch outside the SDK: new WebSocket, EventSource, node:http, node:https, node:dgram, ws, or git, curl or wget run as a child process |
MAIN-NET-0004 | Checkmainsdk check, before deploy | A third-party API offers less than TLS 1.2 (TLS 1.3 is the minimum for endpoints your app serves, under MAIN-NET-*) |
MAIN-NET-0007 | Checkmainsdk check, before deploy | The code connects to a host that network.allow doesn't list; the finding names the call (http, websocket, event-stream, database, git) |
MAIN-NET-0040 | Checkmainsdk check, before deploy | The code calls a non-local host over plain http:// |
MAIN-NET-0041 | Checkmainsdk check, before deploy | TLS below 1.2 is forced, or certificate checks are turned off |
MAIN-NET-0050 | Checkmainsdk check, before deploy | Info.plist or app.json turns off App Transport Security with NSAllowsArbitraryLoads, so the app can load plain http |
MAIN-NET-0051 | Checkmainsdk check, before deploy | AndroidManifest.xml or app.json allows cleartext traffic (usesCleartextTraffic), so the app can send data over plain http |
MAIN-NET-0002 | RuntimeError responses and logs | Call to a host not in network.allow (or not declared by a driver) |
MAIN-NET-0003 | RuntimeError responses and logs | Redirect to an undeclared host refused |
MAIN-NET-0005 | RuntimeError responses and logs | An entry in network.allow is not an IP, a CIDR range, an exact host or a *.domain (for example a prefix length out of range) |
MAIN-NET-0006 | RuntimeError responses and logs | A request was redirected more times than the SDK follows, or redirect: "error" met a redirect |
MAIN-NET-0008 | RuntimeError responses and logs | http.json or http.events got a non-2xx answer (429 when the provider rate-limits, else 502), or JSON that didn't parse; the body is never echoed |
MAIN-NET-0009 | RuntimeError responses and logs | An outbound call timed out: the connect or total timeout of its last attempt, or its deadline, passed (504; the error names which) |
MAIN-NET-0010 | RuntimeError responses and logs | The circuit breaker for the host is open, or half-open with its probe in flight; no request was sent (503, retryable, retryAfter is the rest of the cooldown) |
MAIN-NET-0011 | RuntimeError responses and logs | An outbound call failed with a network error (refused, reset, DNS failure) on its last attempt (502; states the number of attempts) |
MAIN-NET-0012 | RuntimeError responses and logs | A value under network.http is invalid: a bad duration, attempts outside 1 to 10, failures outside 1 to 100 %, min below 1, or an unknown key (fails at load; names the key) |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Allowlist in main.yaml with driver-declared hosts | Phase 1 | 0.3 | network#schema |
| Enforcement in the SDK network layer | Phase 1 | 0.3 | network#chokepoint |
| DNS and redirect checks | Phase 1 | 0.3 | network#dns-redirects |
| Blocked calls reported | Phase 1 | 0.3 | network#report |
| Check against raw sockets and other HTTP clients | Phase 1 | 0.3 | network#build-check |
| Export as platform firewall rules | Phase 1 | 0.3 | network#export |
| Helpers for GET, POST, JSON, server-sent events and WebSockets | Phase 1 | 0.3 | network#helpers |
| http.assert for database URLs and git remotes before connecting | Phase 1 | 0.3 | network#helpers |
| Discovery of every host the code uses (mainsdk discover) | Phase 1 | 0.3 | network#discover |
| Discovered hosts written as a build-time manifest (discover --out) | Phase 1 | 0.3 | network#discover |
| Timeouts per attempt and per call, with a connect timeout | Phase 1 | 0.3 | network#timeouts |
| network.http settings per host, with field-by-field overrides | Phase 1 | 0.3 | network#http-config |
| Retries for idempotent calls, with backoff and Retry-After | Phase 1 | 0.3 | network#retries |
| Per-host circuit breaker | Phase 1 | 0.3 | network#breaker |
| Every attempt and breaker change in the decision log | Phase 1 | 0.3 | network#attempt-log |
| Driver defaults for timeouts and idempotency keys | Phase 1 | 0.3 | network#driver-defaults |
| Measured overhead against the performance budget | Phase 1 | 0.3 | network#reliability-overhead |
MainSDK Cloud
Everything above is free in your app. Cloud adds these on top, by plan:
| Plan | What it adds |
|---|---|
| Fleet | Egress allowlists, approved providers and models enforced across every app |
Quick start
network:
allow:
- api.partner.com
default: deny
mode: enforcelib/rates.ts
import { http } from "mainsdk";
export async function loadRates() {
// allowed: api.partner.com is in network.allow
const res = await http.fetch("https://api.partner.com/v1/rates");
return res.json();
}use mainsdk::prelude::*;
let app = App::test();
let err = app.network().assert("https://example.com/x").unwrap_err();
assert_eq!(err.code, ErrorCode::MainNet0002);A call to a host that is not on the list, and not declared by a driver you use, is blocked with MAIN-NET-0002.
Configuration
Every key below lives under network:.
network:
allow:
- 8.8.8.8 # one IP
- 10.20.0.0/16 # a range (IPv4 or IPv6 CIDR)
- google.com # exactly this domain, not its subdomains
- "*.pinecart.dev" # subdomains of pinecart.dev only, not pinecart.dev itself
default: deny # everything else is blocked and reported
mode: enforce # observe | warn | enforce| Key | Type | What it does |
|---|---|---|
allow | list | Your own destinations: IPs, CIDR ranges, exact domains, or *.domain wildcards |
default | string | What happens to anything not on the effective list. The default is deny |
mode | string | observe logs a would-block and lets the call through, warn allows and reports, enforce blocks |
The effective list is your allow list plus the hosts of the drivers in use. The Resend driver declares api.resend.com, so you never list provider hosts yourself.
Matching rules:
- An exact domain does not match its subdomains.
google.comdoes not allowmail.google.com. *.domainmatches subdomains only.*.pinecart.devallowsapi.pinecart.devbut notpinecart.dev.- Prefer domains; IPs behind CDNs change. IPs suit fixed services such as a partner's VPN gateway.
- A plain entry covers HTTP, HTTPS and WebSocket only. Raw TCP and UDP need an entry with the protocol and port, such as
tcp://db.acme.dev:5432orudp://8.8.8.8:53, and listening needsnetwork.listen. See TCP and UDP sockets.
Inherited and overlay lists
main.org.yaml can set the list for every app. An app can remove hosts (tightening), but adding a host the org does not allow is weakening: it fails with MAIN-CONFIG-0010 unless an exception names the rule, a reason, an owner and an expiry:
exceptions:
- { rule: net.default_deny, reason: "partner VPN", owner: "@pinecart/platform", expires: 2026-11-30 }An expired exception fails like no exception. See main.yaml.
Helpers
http.fetch is the base. The helpers below cover the calls apps make most often. Each one goes through the same allow-list check, and the decision names the calling file and line.
| Call | Use it for | Returns |
|---|---|---|
http.fetch(url, init) | Any request, when you want to read the response yourself | Response |
http.get(url) | A plain GET | Response |
http.post(url, body) | A POST; an object body is sent as JSON | Response |
http.json(url, { body, schema }) | A request whose answer is JSON. Sends body as JSON when it is given, and checks the answer against schema when you pass one (MAIN-SCHEMA-0001 on a mismatch). Throws MAIN-NET-0008 on a non-2xx answer or a body that is not JSON | the parsed value |
http.events(url) | Server-sent events, one event per iteration | an async iterator of { event?, data } |
http.websocket(url, protocols?) | A WebSocket. The host is checked before the connection opens | WebSocket |
http.assert(url) | A database URL or git remote that the SDK does not carry for you. The host is checked before you connect | the decision |
import { http } from "mainsdk";
// allowed: api.partner.com is in network.allow
export async function quote(sku: string) {
return http.json<{ price: number }>("https://api.partner.com/v1/quotes", { body: { sku } });
}
export async function watchPrices() {
for await (const event of http.events("https://api.partner.com/v1/prices/stream")) {
console.log(event.data);
}
}
export async function openFeed() {
return http.websocket("wss://feed.partner.com/live");
}Use http.assert before a driver or library opens a connection the SDK cannot wrap, such as a database client. It throws MAIN-NET-0002 for a host that is not allowed, and returns the decision when it is:
import { http } from "mainsdk";
const url = process.env.DATABASE_URL ?? "";
await http.assert(url); // throws MAIN-NET-0002 if the database host is not allowedServer-side programs such as git or curl run through system.run (see Server facts and programs). Every URL in their arguments is checked against this list too.
Reliable calls
Every outbound call is bounded and retried when it is safe, and a host that keeps failing is cut off. You write http.get() and get the same behaviour as every driver, with no timeout or retry loop of your own.
network:
http:
timeout: 10s # one attempt, until the body is read; "off" for none
connect: 3s # one attempt, until the response headers arrive
retries: { attempts: 3, backoff: 200ms, max: 5s, deadline: 30s } # or retries: off
breaker: { failures: 50%, window: 30s, min: 10, cooldown: 15s } # or breaker: off
hosts: # an exact host or "*.domain", as in network.allow
api.partner.com: { timeout: 3s, retries: { attempts: 5 } }These are the defaults, except hosts. Settings resolve per field, most specific first: the call, then the host's entry in hosts, then the driver's defaults for that host, then network.http, then the built-in defaults.
Options on each call
| Option | What it does |
|---|---|
timeout | Total time for one attempt, from the request until the body is read. "off" disables it |
connect | Time for one attempt until the response headers arrive |
retries | false, a number of attempts, or { attempts, backoff, max, deadline } for this call |
idempotencyKey | Sent as Idempotency-Key on every attempt. It makes a POST retryable |
import { http } from "mainsdk";
// a slow report: give it more time, and do not retry it
export function report(id: string) {
return http.get(`https://api.partner.com/v1/reports/${id}`, { timeout: "30s", retries: false });
}
// a charge: the key makes the POST safe to retry
export function charge(orderId: string, body: { amount: number }) {
return http.post("https://api.partner.com/v1/charges", body, { idempotencyKey: orderId });
}Use one key per logical call and reuse it on every retry. A new key for a retry defeats the point.
What is retried
- Only
GET,HEAD,PUT,DELETEandOPTIONSare retried, and aPOSTthat carries anidempotencyKey. A request whose body is a stream is never retried. - A retry happens after a network error, a timed-out attempt, or a
408,429,502,503or504answer. Any other answer is returned as it is. - Waits use full jitter: a random wait up to
backoffdoubled each attempt, capped atmax. ARetry-Afterheader on a429or503replaces that wait, even abovemax. - A call stops after
attemptstries or when the next wait would passdeadline. It then returns the last answer, or fails withMAIN-NET-0009(timed out) orMAIN-NET-0011(network error). - A call blocked by the allowlist (
MAIN-NET-0002) is never retried and never counts against the breaker.
The circuit breaker
Each host has its own breaker in each app instance. When the share of failed attempts in the last window reaches failures, with at least min attempts in it, the breaker opens. A failure is a network error, a timeout, or a 500, 502, 503 or 504; a 429 or other 4xx is not.
While the breaker is open, a call to that host fails at once with MAIN-NET-0010 and sends nothing. After cooldown one probe call goes through; if it succeeds the breaker closes, and if it fails the breaker opens again. Calls to other hosts are unaffected. A breaker's state is per instance and starts closed on every start.
Every attempt and every breaker change is written to the decision log, with the attempt number, its wait and its outcome. Bodies, Authorization headers and Idempotency-Key values are never logged.
Find what your code calls
npx mainsdk discoverdiscover reads your source and main.yaml and lists every host the code connects to, with the file and line, the kind of connection and whether main.yaml declares it. It also lists the server folders, facts and programs the app uses, which are covered by Declare what your app uses. Each undeclared host is marked MISSING, and the output ends with the network.allow lines to add.
network
declared http https://api.partner.com lib/partner.ts:5
MISSING websocket wss://feed.partner.com lib/partner.ts:15Three more ways to use it:
npx mainsdk discover --jsonprints the full manifest for scripts and agents.npx mainsdk discover --out build/manifest.jsonwrites the manifest to a file at build time, so a review or a platform rule can read what the app uses without running it.npx mainsdk network export --to k8s --discoveredadds the hosts the code uses thatnetwork.allowdoesn't list yet.
mainsdk check fails with MAIN-NET-0007 for any host the code uses that network.allow doesn't declare. A URL that is only known when the app runs, such as one read from a variable, is listed as dynamic: discovery can't read its host, so the runtime check is what guards it.
Examples
Call a partner API from a route handler
import { auth, http } from "mainsdk";
export async function POST(req: Request) {
await auth.require(req);
const body = (await req.json()) as { sku: string; qty: number };
const res = await http.fetch("https://api.partner.com/v1/quotes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sku: body.sku, qty: body.qty }),
});
return Response.json(await res.json(), { status: res.status });
}http.fetch(url, init) is the client every driver and app call uses, so you write it like fetch.
Roll a new list out safely
Adopting network on an existing app: start in observe, read what would be blocked, add what is legitimate, then enforce.
network:
mode: observe # calls succeed; would-block decisions are loggedEvery block or would-block is written to the decision log with the destination, the rule and the calling file and line, and shows in mainsdk info:
npx mainsdk info --jsonOnce the log is clean, switch the overlay to warn, then enforce.
Internal services from an Express worker
Private ranges are reachable only when you declare them. A DNS answer that points an allowed name at a private address you did not declare is refused as rebinding.
network:
allow:
- "*.pinecart.dev"
- 10.20.0.0/16 # the internal billing service
default: denyimport { createServer } from "node:http";
import { http } from "mainsdk";
createServer(async (req, res) => {
const id = req.url?.split("/")[2] ?? ""; // /invoices/:id/sync
const upstream = await http.fetch(`http://10.20.4.12/invoices/${id}`); // inside 10.20.0.0/16
res.writeHead(upstream.status, { "content-type": "application/json" });
res.end(JSON.stringify(await upstream.json()));
}).listen(3000);A second wall at the platform
Export the same list as platform egress rules, so the network blocks undeclared hosts even if code finds a way around the SDK.
npx mainsdk network export --to k8sTargets are k8s (a NetworkPolicy) and aws-sg (security group egress rules). Exports for Vercel and Cloudflare are planned. Platform rules match IPs, so names are resolved when you export. For Kubernetes the policy holds your CIDR egress rules, such as 10.20.0.0/16 and 8.8.8.8/32. --discovered adds hosts the code uses that network.allow does not list yet.
Reference: the network layer
The Network instance behind http has calls of its own. They are for adapters, drivers, tests and tooling. Most apps use only the http helpers above.
| Call | What it does |
|---|---|
network.fetch(input, init?) | Fetch through the allow list with timeouts, retries and the host's circuit breaker. http.fetch calls it. |
network.assert(target) | Check a destination the SDK doesn't carry itself, such as a database URL, a WebSocket or a git remote. It throws MAIN-NET-0002 when enforce mode blocks it. http.assert calls it. |
network.decide(url, reason?, caller?) | Decide one URL: the host must be declared, and DNS answers must not point at an undeclared private range. Returns the decision, with the calling file and line. |
network.matchHost(host) | The allow rule that covers a host name, or null. |
network.matchIp(ip) | The CIDR rule that covers an address, or null. |
network.settingsFor(host, call?) | The timeouts, retry and breaker settings a call to this host resolves to, with the call's own options on top. For tests and mainsdk info. |
network.reload(opts) | Replace the allow list, mode and default while the app runs. Driver hosts stay allowed. opts takes allow, default and mode. |
network.breakerStates() | The circuit breaker state of each host, as { host, state }. health() reports them as remote dependencies. |
network.breakerSlots() | How many numbers the breakers hold in all. It grows with hosts, never with traffic. |
network.trim() | Drop the older half of the decision log. A pressure hook calls it under memory pressure. |
network.mode | The mode in force: observe, warn or enforce. network.reload() changes it. |
network.rules | The parsed allow rules, in the order network.allow lists them. |
network.decisions | The newest 1,000 decisions, each with the host, allow or deny, the rule that matched and the calling file and line. |
network.entries | Decisions, attempts and breaker changes in order, the newest 1,000. |
Customer-chosen URLs
A URL a customer typed, such as a webhook endpoint, must never reach a private address, and no network.allow entry can change that. These two calls take no allow-list entry.
| Call | What it does |
|---|---|
network.decidePublic(url, caller?) | Check that a URL is https, carries no credentials, and that its host and every address it resolves to now are public. Throws MAIN-HOOK-0004 without connecting. |
network.fetchPublic(input, init?) | Send one request to such a URL. The address is checked again at every connect, no redirect is followed and nothing is retried. The answer comes back whatever its status. |
Helpers
These functions are exported for tooling and tests.
| Function | What it does |
|---|---|
parseRule(text) | Parse one allow entry: an IP, a CIDR range, an exact host or *.domain. Throws MAIN-NET-0005 for anything else. |
isPrivateIp(ip) | true for loopback, private, carrier-grade NAT and link-local addresses. |
createHttp(net) | Build the http client on one network layer, given a function that returns it. Tests use it to run http on a separate layer. |
exportK8s(allow, opts) | Write the allow list as a Kubernetes NetworkPolicy. Names are resolved to /32 or /128 addresses first. |
exportAwsSg(allow, opts) | Write the allow list as AWS security group rules (IpPermissions JSON). |
Both exports take { name?, resolve?, listen? }: a name for the policy, a function that resolves a host to addresses, and the network.listen entries from main.yaml. They are what npx mainsdk network export runs.
What you get
- Every outbound call goes through the SDK's network layer and is checked against the list in
main.yaml, plus the hosts your drivers declare. - A poisoned dependency that tries to send your secrets to a new domain fails in CI and is stopped in production.
- Reach for
http.fetchwhenever your own code calls an outside API. - It runs inside your app and is free on every plan.
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-NET-0001 | mainsdk check found a raw socket, another HTTP client (for example node:net, axios), a direct fetch, new WebSocket or EventSource, or git, curl or wget run as a child process outside the SDK | Use an http helper, or move the call into a driver; run programs with system.run |
MAIN-NET-0002 | A call went to a host that is not on the effective list | Add the host to network.allow if it is legitimate; otherwise find the code that called it |
MAIN-NET-0003 | An allowed host redirected to an undeclared host | Declare the redirect target, or call the final URL directly |
MAIN-NET-0004 | A Main 27 network rule, for example a third-party API that offers only TLS 1.1 | Use an endpoint that offers TLS 1.2 or later, or record an exception with an owner and an expiry |
MAIN-NET-0005 | An entry in network.allow is not an IP, a CIDR range, an exact host or a *.domain | Fix the entry; the error names it |
MAIN-NET-0006 | A request was redirected more times than the SDK follows, or a redirect: "error" request met a redirect | Call the final URL directly, or fix the redirect loop |
MAIN-NET-0007 | mainsdk check found a host the code uses that network.allow doesn't list | Add the host, or remove the call; npx mainsdk discover prints the lines |
MAIN-NET-0008 | http.json or http.events got a non-2xx answer (429 when the provider rate-limits, otherwise 502) or a body that is not JSON. The body is never echoed | Check the URL, the credentials and the service status, or read the response with http.fetch |
MAIN-NET-0009 | An outbound call timed out: the connect or total timeout of its last attempt, or its deadline, passed (504; the error names which) | Raise the timeout for that host or call, or check the provider's latency |
MAIN-NET-0010 | The circuit breaker for the host is open, or half-open with its probe in flight. No request was sent (503, retryable, retryAfter is the rest of the cooldown) | Wait for retryAfter, and check the provider's status |
MAIN-NET-0011 | An outbound call failed with a network error (refused, reset, DNS failure) on its last attempt (502; states the number of attempts) | Check the host, DNS and the provider's status |
MAIN-NET-0012 | A value under network.http is invalid: a bad duration, attempts outside 1 to 10, failures outside 1 to 100 %, min below 1, or an unknown key. It fails at load and names the key | Fix the value the error names |
The decision log names the destination, the rule and the calling file and line for every block. The check codes for this interface are listed above.
Swapping drivers
network has one driver, built in. Other interfaces' drivers feed it: each driver declares the hosts it calls, and those join the effective list. When you swap email from Resend to SES, api.resend.com leaves the list and the SES host joins it, with no change to network.allow. network.destinations() shows the result.
Driver calls go through the same layer. A driver that reaches a host it did not declare is blocked with MAIN-NET-0002 like any other call. A kit for writing your own drivers (defineDriver, spec cohesion) is planned.
Testing
Route tests run on the in-memory fakes, so they make no real network calls. Assert the guarantee itself with a test named by its criterion:
import assert from "node:assert/strict";
import { it } from "node:test";
import { createTestApp } from "@mainsdk/testing";
import { http } from "mainsdk";
it("network AC1 blocks an undeclared host", async () => {
const app = createTestApp({
yaml: "schema: 1\nproject: rates\nnetwork:\n allow: [api.partner.com]\n",
respond: () => Response.json({ price: 5 }), // what an allowed host answers
});
await assert.rejects(http.fetch("https://evil.example"), { code: "MAIN-NET-0002" });
assert.deepEqual(await http.json("https://api.partner.com/v1/rates"), { price: 5 });
assert.equal(app.sent.length, 1); // only the allowed call was sent
});respond answers every call the list lets through; without it the fake answers 204. dns maps names to fake addresses for the rebinding check. See the Testing kit.
Security and privacy
- Deny by default. Undeclared hosts are blocked and reported.
- The decision runs in process, with no network round trip.
- DNS answers are checked against the list, so a name cannot be rebound to a private range you did not declare. Redirects to undeclared hosts are refused.
- Raw sockets and other HTTP clients fail the check at build time.
- Free on every plan: an app protecting itself is never a paid action.
What Cloud adds
Your allowlist and its enforcement are free in your app. On Fleet, a security team enforces egress allowlists, approved providers and approved models across every app in the estate. Fleet can tighten your list, never loosen it. See free in-app and Cloud.
FAQ
Do I list Stripe, Resend and my database host? No. Drivers declare their own hosts. You list only your own destinations.
Does *.pinecart.dev allow pinecart.dev? No. Wildcards match subdomains only. List pinecart.dev separately if you call it.
Does this cover inbound traffic or CORS? No. network is outbound only. Secure headers, CSRF and CORS are inbound protection; rate limits are in rate limits and quotas.
Can I try it without breaking production? Yes. Set mode: observe and read the would-block decisions first.
List the destinations
network.destinations() returns every destination the app may reach: your allow list plus the hosts your drivers declare. It is what mainsdk info prints.
import { network } from "mainsdk";
const hosts = network.destinations(); // ["api.partner.com", "api.resend.com", ...]Next
- Declare what your app uses: hosts, folders, facts and programs in one place.
- Local files and Server facts and programs: the server's own disk and programs, under the same permission model.
- Policy decisions: the network list compiles into policy you can explain.
- Secrets: keep the keys a poisoned dependency would try to send out.
- The check: what
mainsdk checkruns before merge.
Was this page helpful?