Gateway-hosted loopback EGRESS PROXY — the one door a jailed shell child may use
to reach the network. Paired with the OS jail's net-off-except-loopback wall
(process-jail), it turns vis.yml :network (allowed/denied domains + verb/path
:rules) into REAL enforcement for shell children — curl, wget, a script,
subprocess — not a cooperative env-var hint.
How the wall + door fit together:
127.0.0.1:<this-port>, so a raw
socket() / /dev/tcp in a script has nowhere to go — the kernel, not
politeness, forces every byte through here.http_proxy/https_proxy/ALL_PROXY point the child's HTTP clients at this
port, so ordinary tools proxy voluntarily; the wall covers the rest.What it enforces (no MITM — no new deps, no CA):
CONNECT host:443): HOST allow/deny always; FULL method + path too when
a MITM capability is supplied and the policy asks for it (:mitm?) — the proxy
terminates the child's TLS with an ephemeral per-host leaf cert (see
internal.tls-mitm), reads the real verb/path, then re-encrypts to the real
upstream (whose real cert it still validates). Without MITM it is a raw byte
tunnel (verb opaque) — the documented CONNECT-only ceiling.The policy is a plain VALUE (per session), read fresh per connection via policy-fn
so /reload + config edits take effect with no restart. One request per upstream
connection (we force Connection: close), so HTTP keep-alive can't smuggle a second,
unfiltered verb onto an already-approved socket.
Gateway-hosted loopback EGRESS PROXY — the one door a jailed shell child may use
to reach the network. Paired with the OS jail's *net-off-except-loopback* wall
(`process-jail`), it turns vis.yml `:network` (allowed/denied domains + verb/path
`:rules`) into REAL enforcement for shell children — `curl`, `wget`, a script,
`subprocess` — not a cooperative env-var hint.
How the wall + door fit together:
- The jail denies ALL sockets except TCP to `127.0.0.1:<this-port>`, so a raw
`socket()` / `/dev/tcp` in a script has nowhere to go — the kernel, not
politeness, forces every byte through here.
- `http_proxy`/`https_proxy`/`ALL_PROXY` point the child's HTTP clients at this
port, so ordinary tools proxy voluntarily; the wall covers the rest.
What it enforces (no MITM — no new deps, no CA):
- Plain HTTP (absolute-form proxy request): FULL host + method + path, because
the request line is cleartext. GET-not-POST works here.
- HTTPS (`CONNECT host:443`): HOST allow/deny always; FULL method + path too when
a MITM capability is supplied and the policy asks for it (`:mitm?`) — the proxy
terminates the child's TLS with an ephemeral per-host leaf cert (see
`internal.tls-mitm`), reads the real verb/path, then re-encrypts to the real
upstream (whose real cert it still validates). Without MITM it is a raw byte
tunnel (verb opaque) — the documented CONNECT-only ceiling.
The policy is a plain VALUE (per session), read fresh per connection via `policy-fn`
so `/reload` + config edits take effect with no restart. One request per upstream
connection (we force `Connection: close`), so HTTP keep-alive can't smuggle a second,
unfiltered verb onto an already-approved socket.(apply-network-filters ctx)Run every registered network filter over ctx (a request or response map).
{:allow? true} when none deny.
Run every registered network filter over `ctx` (a request or response map).
`{:allow? true}` when none deny.(compile-policy {:keys [allowed-domains denied-domains exclude-domains]
:as net})Compile raw network-opts {:allowed-domains :denied-domains :rules}
into the value the proxy consults: canonical domain lists + normalized rules. Returns
nil when there is nothing to enforce (no domain restriction and no rules) — the caller
then need not route through the proxy at all.
Compile raw network-opts `{:allowed-domains :denied-domains :rules}`
into the value the proxy consults: canonical domain lists + normalized rules. Returns
nil when there is nothing to enforce (no domain restriction and no rules) — the caller
then need not route through the proxy at all.(decide policy method host path)(decide policy method host path port)Decide a request. method/path nil ⇒ HTTPS CONNECT / SOCKS (host+port only).
The 5-arity port is enforced against the matched host rule's :ports (when the
rule declares any) for EVERY protocol. Returns {:allow? bool :reason str}.
Decide a request. `method`/`path` nil ⇒ HTTPS CONNECT / SOCKS (host+port only).
The 5-arity `port` is enforced against the matched host rule's `:ports` (when the
rule declares any) for EVERY protocol. Returns `{:allow? bool :reason str}`.(decide+filter policy {:keys [method host path port] :as req})Tier-1 decide then, only if allowed, the Tier-2 registered request filters.
req = {:phase :method :host :path :port :headers}.
Tier-1 `decide` then, only if allowed, the Tier-2 registered request filters.
`req` = `{:phase :method :host :path :port :headers}`.(mitm-excluded? policy host)True when host matches the policy's :exclude-domains — a client MITM cannot
serve (cert-pinned tools, mTLS upstreams, Go/gh on macOS that ignores the CA
env). Such a host stays HOST-allowlisted but its TLS is tunnelled opaquely, so
verb/path is unenforced there. nil policy / empty list ⇒ never excluded.
True when `host` matches the policy's `:exclude-domains` — a client MITM cannot serve (cert-pinned tools, mTLS upstreams, Go/`gh` on macOS that ignores the CA env). Such a host stays HOST-allowlisted but its TLS is tunnelled opaquely, so verb/path is unenforced there. nil policy / empty list ⇒ never excluded.
(normalize-rules {:keys [rules]})Normalize the :rules list into a vector of
{"host" h "methods" #{UPPER…} "allow" [{"method" M "path" P}…] "ports" #{int…}}.
Multiple rules for one host merge (union) their methods, allow-exceptions, and ports.
Normalize the `:rules` list into a vector of
`{"host" h "methods" #{UPPER…} "allow" [{"method" M "path" P}…] "ports" #{int…}}`.
Multiple rules for one host merge (union) their methods, allow-exceptions, and ports.(probe policy {:keys [method host path port] :as ctx})DEV/DEBUG probe: run Tier-1 decide then EACH registered network filter
individually over a synthetic ctx, WITHOUT collapsing on the first deny —
so you see every filter's verdict AND its Python traceback. Pure: no socket,
no egress, no MITM. ctx = {:phase :method :host :path :port :headers :body}
(:body is the decrypted request body on the HTTP/MITM path when it is a small,
Content-Length-sized upload; nil for larger/chunked bodies and for SOCKS).
Returns
{:tier1 {:allow? … :reason …} ; host allow/deny + SSRF + port
:filters [{:owner o :allow? b :reason s :error {:message :trace}}] ; per filter
:final {:allow? b :reason s}} ; the verdict egress would use
DEV/DEBUG probe: run Tier-1 `decide` then EACH registered network filter
individually over a synthetic `ctx`, WITHOUT collapsing on the first deny —
so you see every filter's verdict AND its Python traceback. Pure: no socket,
no egress, no MITM. `ctx` = `{:phase :method :host :path :port :headers :body}`
(`:body` is the decrypted request body on the HTTP/MITM path when it is a small,
Content-Length-sized upload; nil for larger/chunked bodies and for SOCKS).
Returns
{:tier1 {:allow? … :reason …} ; host allow/deny + SSRF + port
:filters [{:owner o :allow? b :reason s :error {:message :trace}}] ; per filter
:final {:allow? b :reason s}} ; the verdict egress would use(register-network-filter! owner f)Register a Tier-2 network filter f (fn [ctx] -> decision) under owner.
Fires at BOTH phases of an exchange: on the way OUT ctx is the request
{:phase :method :host :path :headers}, on the way BACK it is the upstream
response {:phase :method :host :path :status :headers} (:phase
distinguishes them; headers a lower-cased name->value map). f returns nil /
{:allow? true} to allow, or {:allow? false :reason s} (or a vis.block
marker map) to DENY — a denied response yields a 403 so the child never sees
the body. Only fires on MITM'd HTTPS + plain HTTP (a tunnelled/excluded host
is encrypted and opaque).
Register a Tier-2 network filter `f` `(fn [ctx] -> decision)` under `owner`.
Fires at BOTH phases of an exchange: on the way OUT `ctx` is the request
`{:phase :method :host :path :headers}`, on the way BACK it is the upstream
response `{:phase :method :host :path :status :headers}` (`:phase`
distinguishes them; headers a lower-cased name->value map). `f` returns nil /
`{:allow? true}` to allow, or `{:allow? false :reason s}` (or a `vis.block`
marker map) to DENY — a denied response yields a 403 so the child never sees
the body. Only fires on MITM'd HTTPS + plain HTTP (a tunnelled/excluded host
is encrypted and opaque).(registered-network-filters)All currently registered network-filter fns, across owners.
All currently registered network-filter fns, across owners.
(run-filters filters req)Run filters (seq of (fn [req])) over request req; FIRST DENY WINS.
FAIL-CLOSED: a filter that throws denies. Returns {:allow? bool :reason str}.
Run `filters` (seq of `(fn [req])`) over request `req`; FIRST DENY WINS.
FAIL-CLOSED: a filter that throws denies. Returns `{:allow? bool :reason str}`.(safe-upstream-address host port policy)Resolve host and validate EVERY resolved address against the SSRF deny-floor, then
return {:addr InetAddress} to dial — the validated IP LITERAL, so the later connect
can't be re-resolved to an internal address (DNS-rebind TOCTOU). {:blocked reason}
when any resolved address is off-limits (or resolution fails).
policy supplies :allow-private? (opts into RFC1918/CGNAT/ULA) and
:reserved-loopback-ports (gateway ports that stay blocked). Link-local / metadata /
any-local / multicast stay blocked ALWAYS; loopback is ALLOWED by default EXCEPT the
reserved gateway/proxy ports; the internal LAN needs :allow-private?. nil host is blocked.
Resolve `host` and validate EVERY resolved address against the SSRF deny-floor, then
return `{:addr InetAddress}` to dial — the validated IP LITERAL, so the later connect
can't be re-resolved to an internal address (DNS-rebind TOCTOU). `{:blocked reason}`
when any resolved address is off-limits (or resolution fails).
`policy` supplies `:allow-private?` (opts into RFC1918/CGNAT/ULA) and
`:reserved-loopback-ports` (gateway ports that stay blocked). Link-local / metadata /
any-local / multicast stay blocked ALWAYS; loopback is ALLOWED by default EXCEPT the
reserved gateway/proxy ports; the internal LAN needs `:allow-private?`. nil host is blocked.(start! {:keys [policy-fn on-log mitm]})Start a loopback egress proxy. policy-fn is a 1-arg fn (fn [token] policy) — it
receives the per-connection session TOKEN (from Proxy-Authorization, nil when
absent) and returns the compiled policy (see compile-policy), nil (⇒ allow all),
or a :deny-all? sentinel (fail-closed). Binds 127.0.0.1 on an
ephemeral port. Returns {:port <int> :stop! (fn [])}. Idempotent stop.
:on-log (optional) receives a decision map per request for audit.
:mitm (optional) a 0-arg fn returning a tls-mitm/create! capability (or nil).
When present AND the per-connection policy carries :mitm?, HTTPS CONNECT is
TLS-terminated so method+path are enforced; otherwise CONNECT is a raw tunnel.
Start a loopback egress proxy. `policy-fn` is a 1-arg fn `(fn [token] policy)` — it
receives the per-connection session TOKEN (from `Proxy-Authorization`, nil when
absent) and returns the compiled policy (see `compile-policy`), nil (⇒ allow all),
or a `:deny-all?` sentinel (fail-closed). Binds `127.0.0.1` on an
ephemeral port. Returns `{:port <int> :stop! (fn [])}`. Idempotent stop.
`:on-log` (optional) receives a decision map per request for audit.
`:mitm` (optional) a 0-arg fn returning a `tls-mitm/create!` capability (or nil).
When present AND the per-connection policy carries `:mitm?`, HTTPS CONNECT is
TLS-terminated so method+path are enforced; otherwise CONNECT is a raw tunnel.(unregister-network-filters-for-owner! owner)Remove every network filter registered by owner (extension teardown).
Remove every network filter registered by `owner` (extension teardown).
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |