TLS and reverse proxy¶
PEART's own HTTP servers never terminate TLS. A deployment sits behind a reverse proxy that terminates
TLS in front of the two surfaces and forwards to their loopback ports. Setting tls: true in the
configuration tells PEART it is behind such a proxy and turns on the Strict-Transport-Security header.
The two surfaces¶
The reverse proxy fronts two independent surfaces, ideally on two hostnames:
- the internal surface (
peart) on:8080, reachable only from the consultancy's restricted network or VPN, - the portal (
peart-portal) on:8081, reachable by the client over the internet.
Keeping them on separate hostnames (and separate exposure) matches the trust boundary the architecture is built on: the internal surface should not be reachable from where the client reaches the portal.
tls is one switch for both surfaces¶
tls is a single, deployment-wide setting, not a per-surface one. Turning it on says "this deployment
sits behind something that terminates TLS", and it applies to the internal surface and to the portal
alike: both send Strict-Transport-Security, and each public_url you set has to be https. There is no
way to front the portal with TLS and leave the internal surface on plain HTTP, and that is deliberate:
the internal surface carries the unpublished findings, which is the more sensitive half of the system.
A tunnel is a valid front, for the portal¶
The proxy does not have to be a host you run. A tunnel that terminates TLS at an edge provider and reaches
the loopback port from inside (a Cloudflare tunnel, for instance) is a legitimate front for the portal:
the client reaches an edge over HTTPS, and the host needs no inbound port open at all. Set tls: true and
the portal's public_url to the public hostname, exactly as with a local proxy.
Do not tunnel the internal surface the same way. Publishing it to the internet, by tunnel or by open port, moves the pentester's surface to where the client's browser reaches the portal, which is the one boundary the two-process architecture exists to keep. Reach the internal surface over the consultancy's VPN or restricted network instead.
With tls: true both surfaces sit behind a terminator, and the internal one's is local -- nginx or Caddy
on the host itself, reachable only from the restricted network.
Behind a terminator, say which address is the client's¶
A proxy or a tunnel terminates the connection, so every request reaches PEART from the terminator: the connector's own loopback address for a tunnel, the proxy's for a local one. Left alone, that is the address the access log records, the address written on every session row, the key the rate limiter counts against, and the origin half of the account lockout. Every client looks like the same one.
The most serious of those is the quietest. An investigation starts from the trail, and behind a tunnel the trail says the connector every time -- which nobody notices until the day somebody needs it.
Two keys per surface fix it, and both are off by default:
portal:
listen_addr: ":8081"
trusted_proxies: ["127.0.0.1"]
trusted_proxy_header: "CF-Connecting-IP"
trusted_proxies lists the peers whose forwarded header is believed, as single addresses or CIDR
ranges. List addresses you control, not addresses you trust in general. Anything on this list can
name any client address it likes, so a peer that is not in fact your terminator hands that power back
to the client -- which is worse than leaving the whole thing off.
trusted_proxy_header is the name to read, and PEART never guesses it. A Cloudflare tunnel sends
CF-Connecting-IP; an nginx sends whatever you told it to send, commonly X-Forwarded-For. Setting
one key without the other is refused at start-up, because either alone is configuration that looks
switched on and is not.
The header is read only from a peer on the list, and never otherwise. A request arriving from anywhere else keeps its own address no matter what header it carries, which is what stops a client from simply declaring itself to be somebody else.
If a peer on the list sends no readable address, PEART uses the peer's own and logs a warning naming the peer and the header. It does not refuse the request: a misconfigured proxy should not become an outage. The warning is what keeps that state from looking exactly like success.
Turning this on changes what new rows in the session table mean: rows written before carry the connector's address, rows written after carry the client's. There is no migration, only that reading.
Example: a reverse proxy¶
A minimal reverse proxy terminates TLS and forwards to the loopback port. With Caddy:
portal.example.com {
reverse_proxy 127.0.0.1:8081
}
internal.example.com {
reverse_proxy 127.0.0.1:8080
}
Caddy obtains and renews certificates on its own. With nginx, terminate TLS in a server block per
hostname and proxy_pass to 127.0.0.1:8081 and 127.0.0.1:8080.
PEART does not learn from the request that TLS was terminated in front of it, and that is deliberate: a
forwarded header is client-supplied, and a process that took its own security posture from one would hand
an attacker the switch. tls: true in the configuration is the whole of it, and it is what makes session
cookies Secure and sends HSTS. So forward Host and whatever your proxy needs for its own logs, and
know that X-Forwarded-Proto is not read: HTTPS at the edge with tls: false behind it produces
non-secure cookies over a connection that looks encrypted, which is the one combination worth checking
after you change a proxy.
An internal hostname that only resolves on the VPN¶
The example above assumes both names are reachable from the public internet, which is how Caddy obtains certificates for them on its own. An internal hostname that resolves only on the consultancy's VPN cannot answer an ACME HTTP challenge, because the certificate authority has no route to it: either issue that name's certificate through a DNS challenge, or use a wildcard you already hold for the internal zone and point the proxy at the files. Nothing else about the internal surface changes, and in particular it still listens on loopback only and is still reached through a terminator on the restricted network.
Running without TLS¶
tls: false refuses to start unless accept_insecure_http: true is also set. That combination exists
for a deliberate plain-HTTP deployment (a closed lab, a local test), never as an accidental default. Do
not run a real deployment that way; put the proxy in front and set tls: true.