* fix(server): keep the client port in X-Forwarded-Host The service proxy stripped the port when setting X-Forwarded-Host, so a workspace service that derives its public origin from forwarded headers emitted absolute URLs pointing at port 80. Opening a service at http://api--branch--repo.localhost:6767/ and letting it redirect gave back a Location without the port, which the browser followed to nothing. Host was already forwarded intact, so services reading Host were fine. Nothing rewrites Location on the way back, so the wrong URL reached the browser unchanged. The strip was not localhost-only: buildPublicServiceProxyUrl preserves an explicit port in publicBaseUrl, so a public alias on :8443 hit the same path. It was a no-op only on the default-port public case. Forward the authority verbatim instead, and add X-Forwarded-Port under a never-invent, never-clobber rule: report only a port observed in the Host header, and leave any value an upstream proxy already set alone. Deriving it from the scheme would overwrite nginx's port on a non-default listener, and the upgrade path's hardcoded "http" would turn a correct 443 into 80. An empty inbound value carries no port, so it does not count as a value to preserve; out-of-range ports are dropped rather than passed on, since the authority is client-supplied. The header block existed in four copies. Collapse them: the subsystem now delegates to createScriptProxyMiddleware and createScriptProxyUpgradeHandler (passing passthroughUnknown explicitly, since the factory defaults it to true), and both remaining call sites share one buildForwardedHeaders helper. Adds the first tests to cover any x-forwarded-* header on this path, driven over a real proxy with an upstream that echoes what it received. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(server): let the observed authority win over a forged port X-Forwarded-Port deferred to an inbound value, so a client connecting directly could send Host: svc.localhost:63735 with a forged X-Forwarded-Port: 443 and have services build URLs for 443. Frameworks apply X-Forwarded-Port after X-Forwarded-Host, so the forged value won. This was inconsistent with the line above it: X-Forwarded-Host is overwritten with the real authority unconditionally, and there is a test asserting that. Both headers carry the same trust, so both now follow the same rule. First-hand observation wins; an inbound value is a fallback, never an override. A port parsed from Host replaces any inbound X-Forwarded-Port. When Host carries no port there is nothing to observe, so a proxy's value survives untouched: the nginx-on-:8443 case, where $host drops the port and X-Forwarded-Port is the only source. The empty-inbound-value special case is gone; always overwriting when we have an observation covers it with one branch less. Reported by Greptile on #2288. Not a regression: before this branch an inbound X-Forwarded-Port passed through untouched, so the forged value already reached services. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(server): correct the forwarded-port comment and note the limit The comment claimed the parsed port was "the port the client really connected on". It is not: parseHostHeaderPort reads the Host header, which the client writes, and route lookup normalizes the port away before matching, so any value reaches the proxy. The only observed port would be req.socket.localPort, which this code does not use. Replaced it with the actual reason the Host port wins, which is keeping x-forwarded-host and x-forwarded-port from disagreeing: the former is set from Host, and frameworks apply the latter afterwards, so a mismatched inbound value would silently override the forwarded authority. Added a LIMITATION note recording that the forwarded authority is not authenticated, that inbound x-forwarded-port is not checked against trustedProxies, and that closing this needs that config threaded into the subsystem and the upgrade path, which has no Express trust context. Both predate this branch: Host has always carried a client-chosen port and an inbound x-forwarded-port has always passed straight through. Docs gain a matching section telling service authors to pin their public origin in configuration rather than derive it from request headers. No behavior change. Raised by Greptile on #2288. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
8.2 KiB
Service Proxy
Paseo proxies HTTP traffic to services running inside your workspaces. Localhost service URLs are always enabled; optional public aliases and a separate service-only listener can be layered on through config.
How it works
When a paseo.json script of "type": "service" starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose Host header matches the script's generated hostname are forwarded to that port.
The generated hostname is built from the script name, branch, and project:
<script>--<branch>--<project>.localhost
If the branch is main or master, the branch segment is omitted:
<script>--<project>.localhost
Example: a script named dev in the miniweb project on branch feature/auth would be reachable at:
dev--feature-auth--miniweb.localhost
Local and public routes use one combined leftmost label (script--branch--project). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
Managing workspace scripts
Configured paseo.json scripts can be managed without addressing their backing terminal directly:
paseo script ls [--cwd <path> | --workspace <workspace-id>]
paseo script start <name> [--cwd <path> | --workspace <workspace-id>]
paseo script stop <name> [--cwd <path> | --workspace <workspace-id>]
The commands return the same script metadata shown by the workspace: lifecycle, service port, proxy URLs, health, exit code, and supervised terminal ID. stop terminates the managed terminal rather than only removing the proxy route, so normal script lifecycle cleanup remains authoritative. MCP exposes matching list_workspace_scripts, start_workspace_script, and stop_workspace_script tools; those require an explicit workspace ID.
Configuration
Add a serviceProxy block under daemon in ~/.paseo/config.json:
{
"version": 1,
"daemon": {
"serviceProxy": {
"listen": "0.0.0.0:8080",
"publicBaseUrl": "https://paseoapps.my.domain.com"
}
}
}
| Field | Required | Description |
|---|---|---|
listen |
No | Starts a separate service-only listener at this address. If omitted, services are still reachable on the daemon listener via localhost hosts. |
publicBaseUrl |
No | Adds public service host aliases and public service links. If omitted, links use localhost addresses only. |
enabled is accepted for old configs but no longer enables a mode. enabled: false suppresses optional listen/publicBaseUrl layers only; localhost service proxying remains always enabled.
DNS and reverse proxy setup
For generated URLs to be reachable, you need wildcard DNS pointing to the machine running the Paseo daemon.
Example: to expose services at https://dev--miniweb.paseoapps.my.domain.com where the daemon host is 10.1.1.1:
-
Configure a wildcard DNS record:
*.paseoapps.my.domain.com → 10.1.1.1 -
Set
publicBaseUrltohttps://paseoapps.my.domain.comin your config. -
If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of Paseo, point it at either the daemon listener or the optional service-only listener and ensure it forwards the
Hostheader unchanged. The proxy uses theHostheader to route requests to the correct service — rewriting it will break routing.
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
If the same reverse proxy serves the daemon web UI over HTTPS, it must also set X-Forwarded-Proto so the web UI can auto-connect with wss://. The daemon trusts forwarded headers from loopback proxies by default. If your proxy reaches the daemon from another address, configure the proxy ranges explicitly:
{
"version": 1,
"daemon": {
"trustedProxies": ["loopback", "172.16.0.0/12"]
}
}
PASEO_TRUSTED_PROXIES accepts the same comma-separated values, for example loopback,172.16.0.0/12. Use true only when the final trusted proxy overwrites client-supplied X-Forwarded-* headers.
Nginx example:
server {
listen 443 ssl;
server_name *.paseoapps.my.domain.com;
location / {
proxy_pass http://10.1.1.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Nginx's $host drops the port. If you terminate on a non-default port, use $http_host instead so the port survives — that is what "forwards the Host header unchanged" means here.
Forwarded headers
Paseo sets these when it forwards a request to a workspace service:
| Header | Value |
|---|---|
X-Forwarded-Host |
The Host header verbatim, including the port when the client used one |
X-Forwarded-Proto |
The request scheme (http on the WebSocket upgrade path) |
X-Forwarded-For |
The immediate peer address. Replaces any existing chain, so behind your own reverse proxy this is the proxy's address, not the client's |
X-Forwarded-Port |
The port from the Host header when it has one, otherwise whatever your proxy already set |
X-Forwarded-Port follows the same trust rule as X-Forwarded-Host: the authority Paseo observed wins. When the Host header carries a port, that port is reported and replaces any inbound X-Forwarded-Port, so a client cannot forge one. When Host carries no port there is nothing to observe, so a value your reverse proxy set survives untouched — that is the case where nginx's $host drops the port and X-Forwarded-Port is the only source. Paseo never derives the port from the scheme. Any other X-Forwarded-* header your proxy sends is passed through untouched.
Services that build absolute URLs should prefer Host or X-Forwarded-Host.
The forwarded authority is not authenticated
Route lookup normalizes the port away before matching a service hostname, so a client can address the daemon with any port in Host and still reach the service. That port is what lands in X-Forwarded-Host and X-Forwarded-Port. Paseo also does not check whether an inbound X-Forwarded-Port came from a proxy in trustedProxies — when Host carries no port, a client-supplied value is passed through.
Treat the forwarded authority as client-influenced input. A service that builds password reset links, absolute redirects, or cached URLs from it should pin its own public origin in configuration rather than deriving one from request headers. This is not specific to X-Forwarded-Port: the Host header has always carried a client-chosen port.
Environment variables
The listen address and public base URL can also be set via environment variables, which take precedence over config.json:
| Variable | Description |
|---|---|
PASEO_SERVICE_PROXY_ENABLED |
Compatibility shim; false suppresses optional public/listen layers only |
PASEO_SERVICE_PROXY_LISTEN |
Starts the optional service-only listener, e.g. 0.0.0.0:8080 |
PASEO_SERVICE_PROXY_PUBLIC_BASE_URL |
Adds public service aliases and links |