Skip to content

Latest commit

 

History

History
213 lines (176 loc) · 11 KB

File metadata and controls

213 lines (176 loc) · 11 KB

Threat Model

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.

The actors

   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-client process 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.

Trust boundaries

1. Visitor → Server (the public edge)

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-For unless a proxy is configured (APERIO_TRUSTED_PROXIES (yaml trusted_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.

2. Visitor / anyone → Admin surface

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 (yaml admin_allowed_ips) restricts the dashboard and its API to operator CIDRs, answering 403 otherwise, while leaving the login page and visitor-auth endpoints reachable so password-gated proxied services keep working.
  • The metrics endpoint always requires a token.

3. Server → Client (the tunnel)

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_ip fires when a token connects from an unseen address, and canary tokens fire canary_tripped on any use, so a token lifted from a CI log or a dump surfaces quickly.

4. Client → Backend

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.

5. Server → Callback destinations

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 unless APERIO_SCALING_ALLOW_PRIVATE, no redirects followed, response body never read.
  • APERIO_OUTBOUND_ALLOWLIST names 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_PRIVATE is 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 Location would mean the destination it vetted is not the destination that receives the request: an allowed receiver could answer 302 and aim the server wherever the fence exists to refuse. This covers webhook deliveries, autoscaling hooks, the key-set fetch a jwt visitor-auth method makes, the endpoint a forward method 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.

6. Server → a client's target (server_side:)

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 of outbound.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_side on 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/8 entry 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 Host header 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.

What Aperio does not defend against

  • 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.

Tamper-evidence

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.

Runnable examples

Copy-and-adapt config pairs for this topic: