Sending requests out
Every request your app sends to the internet goes through our outbound proxy. That is where secrets are added, and where you can hold an app to a list of hosts. Both are on every plan.
A request that fails on the way still gets an answer, so check the status before reading the body. See When a request fails.
When a request fails
Section titled “When a request fails”Your app gets one of these answers rather than an error from fetch. Workers and containers get the same, except for a body that breaks, as below.
- The name doesn’t resolve. Cloudflare answers
530, with the bodyerror code: 1016. - Nothing answers at the host’s port. Cloudflare answers with one of its own 5xx codes, such as
522when the connection times out. Cloudflare’s 5xx errors lists them. - We can’t send it. We answer
502, withOutbound requests can't be sent right now, so nothing was sent. Try again in a moment. - It fails on its way, as when a connection drops. We answer
502, withThe request failed on its way, and the host may have taken it. Check before you send it again.The host may have had all of it, so look before you send it again, or send it with an idempotency key if the host takes one. - The request body breaks partway, and we can tell. We cut the request off, so the host never takes the part that came for the whole body. The host may still have seen that part. Your app gets a
502withThe request's body didn't arrive whole, so the request was cut off partway and the host may have seen part of it., or the error its own code stopped with.
Cloudflare’s answers come to your app as Cloudflare gave them. They aren’t ours, so they don’t look like our 502.
A body that breaks partway
Section titled “A body that breaks partway”We never send part of a body as if it were all of it, when we can tell it broke. What we can tell depends on how your app sends it.
In a Worker:
- Your app gives up on the request before its body is sent, by aborting it with an
AbortController. We cut it off, and yourfetchthrows the abort, as it always does. - The body says its length and doesn’t match it: a stream sent with a
Content-Lengthheader, or aFixedLengthStream, that comes up short of that length or runs past it. We cut it off, and your app gets the502for a request cut off partway. We send a body that matches with the length it says. To avoid it, setContent-Lengthonly to the body’s exact size in bytes, which for text isnew TextEncoder().encode(text).byteLength, not its length in characters. Or leave the header off and send a string, anArrayBufferor aBlob, which the runtime sends with its own length. - A string, an
ArrayBuffer, aBloborFormDatais whole before it’s sent, so it can’t break. - A stream your code made fails, with no length said: a
ReadableStream, the readable side of aTransformStream, or one piped or teed from those. The Workers runtime sends what was read as if it were the whole body, as it does for any Worker on Cloudflare, so nothing after it can tell. The host takes the part that came for the whole body, and your app gets the host’s answer. To be sure it can’t happen, setContent-Lengthwhen you know the length, or read the stream whole first withawait new Response(stream).arrayBuffer()and send that: a stream that fails then throws in your code, and nothing is sent.
This is the same for a Worker deployed with any version of forculus.
In a container, an HTTP client like Node’s fetch closes the connection when its request body fails, and so does a process that dies partway. We cut the request off, and your code gets the client’s own error, not an answer. A container app gets this the next time it’s deployed. Until then, a body it sends with no length can reach the host as if it were whole, and one with a Content-Length is cut off.
A request with a company AI key is read whole before it’s sent, to price it. One that doesn’t arrive whole isn’t priced or sent.
Our proxy’s own log never holds what a request carried, or the words of the error its body failed with. Your app’s log holds what your code writes and the exceptions it doesn’t catch, its own error text included.
See where an app sends requests
Section titled “See where an app sends requests”An app’s Activity tab has Sends requests to: the hosts the app sent requests to in the last 30 days, busiest first, with refused requests in red: ones its list didn’t allow, ones to an AI provider the company key doesn’t cover, and ones to an IP address, which Cloudflare refuses. The app’s editors and owners can read the same with forculus egress usage acme/payroll, or through the API at GET /v1/workspaces/<workspace>/apps/<app>/egress/usage, along with the workspace’s owners and admins. Owners and admins also see each app’s busiest hosts in the workspace’s list of apps and its CSV, and the workspace export carries each app’s list and AI grants and which AI keys the workspace has, never a key.
Counts are kept by the hour and can take a few minutes to show. A request is counted as sent once the host has answered, so a name that doesn’t resolve isn’t counted, and neither is a request we cut off. One sent from a workspace’s fixed addresses that fails on its way once it has left them is counted as sent, since it may have reached the host. Cloudflare’s other 5xx answers, like 522, are counted as sent, since a host behind Cloudflare can give them too. A request to an IP address that Cloudflare refuses is counted as refused. An app that sends requests to more than 2,000 different hosts in an hour has the rest counted together as other hosts, with how many hosts went into that line, and so does a name no real host could have. Counts are kept for 90 days. We count only the host: never the path, the headers or the body.
Allow only some hosts
Section titled “Allow only some hosts”By default an app can send requests to any host except our own, and except a private address like 10.0.0.1, 127.0.0.1 or 169.254.169.254, which no public host has. A request to one is refused with 403 and Refused: apps can't send requests to a private address., list or no list, so no app can reach another app’s container, or anything else on our network, by its address. To allow only the hosts you name, add them to the app’s list and turn the list on:
forculus egress allow acme/payroll api.stripe.comforculus egress allow acme/payroll "*.googleapis.com"forculus egress on acme/payrollacme/payroll can send requests only to these hosts:*.googleapis.comapi.stripe.comA request to any other host is refused with a 403 that says Refused: example.org isn't on this app's list of hosts it can send requests to., and nothing is sent. To an AI provider’s host it comes in that provider’s error shape, so its SDK shows it. The refusal carries an x-forculus-egress-list header with the version of the list that refused it, which goes up with every change.
forculus egress list acme/payroll shows the list and whether it is on. forculus egress deny acme/payroll api.stripe.com takes a host off, and forculus egress off acme/payroll lets the app reach any host again. Changes apply within 10 seconds.
In the dashboard, the list is under the app’s Settings, in Sending requests out.
Only the workspace’s owners and admins can let an app reach more: add a host, or turn the list off. The app’s editors and owners can hold it in further: turn the list on, or take a host off. Every change is in the workspace’s audit log as egress.allow, egress.deny, egress.restrict or egress.unrestrict.
What a host can be
Section titled “What a host can be”- An exact host, like
api.stripe.com. It matches that host only, notx.api.stripe.com. *.and a domain, like*.stripe.com. It matches every host understripe.com, at any depth, but notstripe.comitself. Add both if you need both. A suffix anyone can register a name under, like*.github.io,*.workers.dev,*.amazonaws.comor*.co.uk, can’t be listed: it would let the app reach hosts anybody can make. We refuse the ones on the Public Suffix List, and some common ones that aren’t, so still check that a pattern covers only hosts you trust.- Not one of ours, like
*.forculuslabs-apps.tech: an app’s requests to our own hosts are always refused, so listing one would only mislead. - Not a name nobody can register, like
foo.internalorprinter.local: no request could reach it.
Hosts are matched without regard to case, a trailing dot or the port: listing api.stripe.com allows it on any port. An international name can be given in either form, bücher.de or xn--bcher-kva.de, and is kept in the second. An IP address can’t be listed, so with the list on, a request to an address is always refused. A list holds up to 200 hosts. A service on your private network that an owner or admin has granted the app is reached whatever its list says, by name or address: the grant is the allow. So the list doesn’t show those services. The Private network page and forculus network list show which apps each one is granted to.
With the list on and nothing on it, the app can’t send requests anywhere. The key set your app verifies sign-ins with is the one exception: it is always reachable, so the SDKs keep working.
How it’s checked
Section titled “How it’s checked”The proxy checks the host in the URL it is about to fetch, after the URL is parsed. So:
- Credentials in a URL don’t count:
https://api.stripe.com@evil.example/goes toevil.example, and is refused. - A
Hostheader your app sets is dropped. The URL decides where a request goes. - The proxy never follows a redirect. Your app gets the redirect back, and when it follows one, that request is checked like any other. A listed host can’t send your app on to one that isn’t listed.
CONNECTis refused, so nothing can open a tunnel to a host that isn’t listed.
The same holds for Workers apps and container apps, with one difference: a container app can’t send an HTTPS request to an IP address at all. The connection is reset during the TLS handshake, before anything is sent, because a connection to an address carries no host name for our proxy to answer as. Node’s fetch says ECONNRESET, and curl exits with (35) Recv failure: Connection reset by peer. Use the host’s name. Over http, a request to a public address passes our checks, and then Cloudflare refuses it with 403 and the body error code: 1003, since Workers can’t send a request to an address.
What a list can’t stop
Section titled “What a list can’t stop”The list says which hosts your app’s requests go to, not what those hosts do with them. A listed API that fetches a URL you give it, sends a webhook or a message, or stores what you send, can pass data on to places the list doesn’t name. List the hosts you need and no more, and know what each of them can do on your behalf.