Aperio sits between the public internet and a service you did not want to expose directly. This document names the trust boundaries in that path, states what each side is trusted to do, and lists the controls that defend each boundary. It is deliberately explicit about the project's guiding assumption: the client does not trust the server, and the server does not trust the visitor.
visitor ──HTTP──▶ server ◀──outbound WS── client ──local──▶ backend
(untrusted) (semi-trusted) (trusted) (trusted)
- Visitor, anyone on the internet sending requests to a published hostname. Fully untrusted.
- Server, the Aperio server: the public front door, router, and admin surface. Trusted to route and to enforce policy, but treated by the client as a potentially-hostile relay (it never receives the client's local credentials, only proxied traffic).
- Client, the
aperio-clientprocess running next to a service. Trusted; it dials outbound to the server, so nothing on the client's side accepts inbound connections. - Backend, the local service the client forwards to. Trusted; reached only over the loopback/private address the client was pointed at.
The primary attack surface. A visitor is assumed to be malicious: probing for open hostnames, spoofing headers, flooding requests, and attacking the login.
Controls:
- Per-IP token-bucket rate limiting, a global concurrency cap, and a request body-size limit.
- Client-IP resolution that does not trust
X-Forwarded-Forunless a proxy is configured (APERIO_TRUSTED_PROXIES(yamltrusted_proxies)), so a visitor cannot spoof its IP to dodge rate limits. - Visitor authentication (server password / share links / OIDC) in front of protected services; unauthenticated hostnames answer only what routing allows.
- Escalating login lockout against brute force.
The /aperio dashboard and /aperio/api/* endpoints are the highest-value
target: they manage tokens, users, and settings.
Controls:
- Session authentication with role-based access (viewer / operator / admin) and optional TOTP / passkey second factor.
- A visitor's session is not an admin session. The credentials that gate
proxied traffic, the server's visitor password and a client's own
auth:, create a session for viewing sites and nothing else. Only the master token, a named user, a passkey or OIDC open this surface. The reverse is deliberate: an admin session does carry its holder past the visitor gate, fenced to the hostnames their own organization serves. - Optional network fence:
APERIO_ADMIN_ALLOWED_IPS(yamladmin_allowed_ips) restricts the dashboard and its API to operator CIDRs, answering403otherwise, while leaving the login page and visitor-auth endpoints reachable so password-gated proxied services keep working. - The metrics endpoint always requires a token.
The client treats the server as an untrusted relay. A compromised or malicious server should not be able to reach the client's backend beyond the request path the client already agreed to serve, nor replay a leaked token from elsewhere.
Controls:
- The client dials outbound, and the server does not connect back to it. The
one exception is deliberate and named by the operator: with
server_side_targets:set, a service may ask the server to reach its target directly rather than through the tunnel (see boundary 6). That is a destination the operator listed, never the client's tunnel connection, and it is off unless they list one. - Token scoping: a dynamic token only binds the hostnames/paths it was granted, with optional source-IP allowlists, TTLs, rate limits, and quotas.
- Leak detection:
token_new_ipfires when a token connects from an unseen address, and canary tokens firecanary_trippedon any use, so a token lifted from a CI log or a dump surfaces quickly.
The narrowest boundary. The client forwards proxied requests to exactly the local address it was configured with; the backend is assumed trusted and is never exposed to the internet directly.
The first of two boundaries where the server makes the outbound call; the other is boundary 6, which is a wider capability and has its own rules. A webhook URL is supplied by an Operator and an autoscaling URL by a client, both lower-trust credentials than the operator running the server, and the delivery log then reports how the destination answered. Left open, that is a blind SSRF probe: aim a webhook at an internal address, fire an event, and read back whether the port answered.
It is not simply forbidden because internal receivers are the normal case, most deployments point webhooks at a service on the same network. Controls:
- Autoscaling URLs are fenced on their own terms: https unless
APERIO_SCALING_ALLOW_HTTP, every resolved address checked against loopback, private, link-local (including the metadata address) and their IPv6 forms unlessAPERIO_SCALING_ALLOW_PRIVATE, no redirects followed, response body never read. APERIO_OUTBOUND_ALLOWLISTnames the host/CIDR patterns the server may call at all, for webhooks and scaling hooks. Once set it is the whole policy: anything unmatched is refused, at webhook creation and again at every delivery, so a policy introduced later also covers existing webhooks.APERIO_OUTBOUND_BLOCK_PRIVATEis the weaker form for deployments that cannot enumerate their receivers: refuse destinations that are, or resolve to, internal addresses.- No redirects are followed on any of these calls. The policy vets the URL
that was configured, so following a
Locationwould mean the destination it vetted is not the destination that receives the request: an allowed receiver could answer302and aim the server wherever the fence exists to refuse. This covers webhook deliveries, autoscaling hooks, the key-set fetch ajwtvisitor-auth method makes, the endpoint aforwardmethod asks, and the OIDC calls: discovery, token exchange and userinfo. A redirect is reported as the status it is, which is also what tells an operator their receiver has moved. - The OIDC issuer is a callback destination too, and is fenced like the rest: the discovery URL comes from configuration (or from an organization's stored settings), and the token and userinfo endpoints come out of the document that URL serves, which means they are chosen by something outside the deployment. They are checked against the policy at each login rather than once at startup, so a fence tightened later covers a runtime already built. Every answer is size-bounded while it is read.
Both default to off, so this boundary is only as tight as the operator makes it. Tighten it wherever webhook creators are not fully trusted, which in practice means any multi-tenant deployment.
The second boundary where the server makes the outbound call, and the wider of
the two. A service may set server_side: true, and then the server sends the
visitor's request straight to that service's target instead of dispatching it
over the tunnel, saving the two hops a relayed request makes.
This is not the blind probe boundary 5 describes; it is a request and response channel a tenant steers. Any method, any path, any headers, and the answer comes back. Aimed at an internal address it is not "did the port answer" but "here is the page". That is why its rules are stricter than the callback policy's rather than the same:
- The destination is the operator's to name.
server_side_targets:(APERIO_SERVER_SIDE_TARGETS) lists the host/CIDR patterns a client may ask for. Unset permits nothing, which is the opposite ofoutbound.allowlist's empty-means-permissive default, and deliberately: an operator who never configured this cannot be pointed anywhere by a client. - The token must allow the asking.
allow_server_sideon the tunnel token, separate from the list, so an operator can permit a destination without permitting every tenant to reach it. - The list is judged on the target as written, never on what a name
resolves to. A
10.0.0.0/8entry admits an address, not a hostname that might resolve into the range: the server dials the same target for every request for as long as the client stays connected, so a name admitted at declaration could point elsewhere a second later. - Nothing a visitor sends can move the request. The path is only ever
appended to the target, and the
Hostheader is stripped, so a visitor cannot pick a different virtual host on an address the operator allowed for something else. The same hop-by-hop strip the relayed path performs applies here, so this is not a way around it. - A target outside the list is refused, not relayed. The service is left out of routing and the server says which target and which setting. Falling back to the tunnel would be worse than useless: a client asks for this precisely when it cannot reach the target itself.
The outbound calls this boundary makes leave through the same egress proxy and verify against the same certificate store as boundary 5's, which Architecture describes.
- A compromised backend or client host: if the machine running the client is owned, the attacker already has what the tunnel would reach.
- The master token leaking: it is unrestricted by design. Rotate it, keep it out of clients (use scoped tokens), and fence the admin surface.
- Outbound callbacks to your internal network, unless you configure the policy above: the default is permissive, because refusing private destinations would break the ordinary webhook deployment.
- Application-layer bugs in the backend: Aperio proxies requests; it is not a substitute for securing the service behind it.
- Denial of service at the network layer: absorb volumetric floods at the CDN/reverse-proxy tier in front of Aperio.
The audit log is an append-only, hash-chained record: each line commits to the
previous one, so a deleted or altered event breaks the chain. Verify it with
aperio-server --verify-audit or GET /aperio/api/audit/verify, and ship the
log off-box so an attacker who reaches the server cannot quietly rewrite history.
See the Production Hardening Checklist to put these controls in place.
Copy-and-adapt config pairs for this topic:
encrypted_tunnels: end-to-end encrypted tunnelsallowed_ips: per-service visitor IP allowlistsbehind_proxy: behind a reverse proxy / CDN