From 954f8b7b8d63abfb0585baac2266a20bed236b6c Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sun, 21 Jun 2026 22:32:43 -0700 Subject: [PATCH] fix: puter self host guides + default bug (#3281) * fix: oss dev center html_encode hardening * fix: puter self host guides + default bug --- doc/self-hosting.md | 26 +++++++ install.sh | 32 +++++--- .../http/middleware/hostRedirects.test.ts | 73 +++++++++++++++++++ .../core/http/middleware/hostRedirects.ts | 17 +++++ src/dev-center/js/websites.js | 10 +-- src/dev-center/js/workers.js | 10 +-- 6 files changed, 148 insertions(+), 20 deletions(-) diff --git a/doc/self-hosting.md b/doc/self-hosting.md index bbcd57480..e677ce2fa 100644 --- a/doc/self-hosting.md +++ b/doc/self-hosting.md @@ -199,6 +199,32 @@ Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`. "s3": { "s3Config": { "publicEndpoint": "https://s3.puter.local", ... } } ``` +## Running behind your own reverse proxy + +The bundled `puter-nginx` mirrors production and is the supported default — it terminates TLS (Step 3) and forwards every Host to Puter. But plenty of self-hosters already run their own edge proxy (Caddy, Traefik, HAProxy, a cloud load balancer, another nginx). You can put Puter behind it instead; Puter doesn't terminate TLS itself in any setup, so "your proxy does TLS" is just a matter of pointing it at the Puter container and getting a handful of details right. + +You can keep the bundled nginx as the single hop your proxy talks to, or bypass it and forward straight to the Puter container on port `4100` (uncomment the `4100:4100` mapping under the `puter` service in [docker-compose.yml](../docker-compose.yml), or attach your proxy to the compose network). Either works — what matters is the rules below. + +**The rules that actually matter** (getting any of these wrong is what causes the redirect loops and "Invalid Host header" failures people hit): + +1. **Don't rewrite the `Host` header.** Puter routes entirely on Host — `api.`, `site.`, `app.` are all distinguished by the incoming host. Forward the original host through unchanged. Do **not** rewrite external hostnames to an internal name like `puter.local`; that forces you to remap every subdomain by hand and still breaks signed-URL and CORS checks. Most proxies preserve Host by default — just don't override it. + +2. **The external domain must equal `domain` in your `config.json`.** If users reach the box at `puter.example.com`, then `domain` must be `puter.example.com` and the hosting domains must be its real subdomains (`site.puter.example.com`, `app.puter.example.com`, …) — exactly as the installer/`config.json` lays them out. Puter rejects hosts it doesn't recognize with `Invalid Host header`. + +3. **Set `protocol` to match the public scheme.** When your proxy terminates TLS, set `"protocol": "https"` (installer: `PUTER_PROTOCOL=https`). Puter builds its origins, redirects, signed S3 URLs, and OIDC callback URLs from this — leave it `http` behind an HTTPS proxy and you get redirect loops, broken logins, and mixed-content errors. + +4. **Forward the standard proxy headers.** Puter needs: + - `Host` — the original request host (rule 1). + - `X-Forwarded-Proto` — the **external** scheme (`https`), so Puter knows TLS was terminated upstream. + - `X-Forwarded-For` — the real client IP (rate limiting + audit logs). + - `Upgrade` / `Connection` — passed through for WebSocket / socket.io upgrades, or the realtime connection silently fails. + +5. **Set `trust_proxy` to the number of hops in front of Puter.** One external proxy talking directly to Puter → `"trust_proxy": 1` (installer: `PUTER_TRUST_PROXY=1`). A second proxy in front (e.g. Cloudflare → your proxy → Puter) → `2`. This is the count of proxies between the client and Puter, **including** the bundled nginx if you keep it. Too low and `req.ip` becomes a proxy address (breaks rate limiting); never set `true` (it trusts every hop and makes `X-Forwarded-For` forgeable). + +6. **Route the wildcard to Puter.** Your proxy must forward `*.` (covering `api.*`, `app.*`, and `s3.`) and `*.site.` (and any other hosting domains) to Puter — same wildcard DNS as Step 2. Puter does the per-subdomain routing internally; the proxy just needs to hand it the traffic with the Host intact. + +With those in place the rest of the stack is unchanged — `docker compose up -d` as below. + ## Step 4 — Bring it up ```bash diff --git a/install.sh b/install.sh index 218a7f444..2008c9eb5 100755 --- a/install.sh +++ b/install.sh @@ -16,11 +16,20 @@ # the compose file + brings the stack up. Set PUTER_FORCE=1 to overwrite. # # Tunable env vars: -# PUTER_DIR install directory (default: ./puter-selfhosted) -# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch) -# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost) -# PUTER_PORT HTTP port for nginx (default: 80) -# PUTER_FORCE set to 1 to overwrite existing .env / config.json +# PUTER_DIR install directory (default: ./puter-selfhosted) +# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch) +# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost) +# PUTER_PORT HTTP port for nginx (default: 80) +# PUTER_PROTOCOL public scheme: http | https (default: http) +# Set https when a TLS-terminating reverse proxy (Caddy, +# Traefik, nginx, a cloud LB) sits in front — Puter then +# builds https:// origins, S3 URLs, and redirects. See +# "Running behind your own reverse proxy" in doc/self-hosting.md. +# PUTER_TRUST_PROXY number of reverse-proxy hops in front (default: 1) +# 1 = the bundled nginx (or a single external proxy); +# 2 = two hops (e.g. Cloudflare → your proxy → Puter). +# PUTER_ENV prod | dev (default: prod) +# PUTER_FORCE set to 1 to overwrite existing .env / config.json set -eu @@ -28,6 +37,9 @@ PUTER_DIR="${PUTER_DIR:-puter-selfhosted}" PUTER_URL="${PUTER_URL:-https://raw.githubusercontent.com/HeyPuter/puter/main}" PUTER_DOMAIN="${PUTER_DOMAIN:-puter.localhost}" PUTER_PORT="${PUTER_PORT:-80}" +PUTER_PROTOCOL="${PUTER_PROTOCOL:-http}" +PUTER_TRUST_PROXY="${PUTER_TRUST_PROXY:-1}" +PUTER_ENV="${PUTER_ENV:-prod}" PUTER_FORCE="${PUTER_FORCE:-0}" log() { printf '\033[1;36m[puter-install]\033[0m %s\n' "$*"; } @@ -117,9 +129,9 @@ EOF cat > puter/config/config.json < { ]); }); + const selfHosted = { + domain: 'puter.localhost', + static_hosting_domain: 'site.puter.localhost', + static_hosting_domain_alt: 'host.puter.localhost', + private_app_hosting_domain: 'app.puter.localhost', + private_app_hosting_domain_alt: 'dev.puter.localhost', + } as IConfig; + + it('still redirects a bare subdomain on the main domain to the hosting domain (self-hosted)', () => { + const { out, next } = run( + createUserSubdomainRedirect(selfHosted), + makeReq({ + subdomains: ['localhost', 'puter', 'foo'], + host: 'foo.puter.localhost', + originalUrl: '/bar?x=1', + protocol: 'http', + }), + ); + expect(out.redirectArgs).toEqual([ + 302, + 'http://foo.site.puter.localhost/bar?x=1', + ]); + expect(next).not.toHaveBeenCalled(); + }); + + it('passes through hosts already on the static hosting domain (no redirect loop)', () => { + const { out, next } = run( + createUserSubdomainRedirect(selfHosted), + makeReq({ + subdomains: ['localhost', 'puter', 'site', 'foo'], + host: 'foo.site.puter.localhost', + originalUrl: '/', + }), + ); + expect(out.redirectArgs).toBeUndefined(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('passes through hosts on the alt / private-app hosting domains too', () => { + for (const host of [ + 'foo.host.puter.localhost', + 'foo.app.puter.localhost', + 'foo.dev.puter.localhost', + ]) { + const { out, next } = run( + createUserSubdomainRedirect(selfHosted), + makeReq({ + subdomains: [ + 'localhost', + 'puter', + host.split('.')[1], + 'foo', + ], + host, + }), + ); + expect(out.redirectArgs).toBeUndefined(); + expect(next).toHaveBeenCalledTimes(1); + } + }); + + it('passes through the hosting-domain root itself (exact match, no loop)', () => { + const { out, next } = run( + createUserSubdomainRedirect(selfHosted), + makeReq({ + subdomains: ['localhost', 'puter', 'site'], + host: 'site.puter.localhost', + }), + ); + expect(out.redirectArgs).toBeUndefined(); + expect(next).toHaveBeenCalledTimes(1); + }); + it("passes through when the request host has a port the configured domain doesn't", () => { // Edge case worth pinning: the suffix check is exact-`endsWith`, // so a port mismatch silently bypasses the redirect. Documenting diff --git a/src/backend/core/http/middleware/hostRedirects.ts b/src/backend/core/http/middleware/hostRedirects.ts index 91f4039a5..ad2c4bc79 100644 --- a/src/backend/core/http/middleware/hostRedirects.ts +++ b/src/backend/core/http/middleware/hostRedirects.ts @@ -88,6 +88,15 @@ export const createUserSubdomainRedirect = ( if (!domain || !target) { return (_req, _res, next) => next(); } + + const hostingDomains = [ + config.static_hosting_domain, + config.static_hosting_domain_alt, + config.private_app_hosting_domain, + config.private_app_hosting_domain_alt, + ] + .map((d) => (d ?? '').toLowerCase().split(':')[0]) + .filter((d) => d.length > 0); return (req, res, next) => { const active = ( req.subdomains?.[req.subdomains.length - 1] ?? '' @@ -95,6 +104,14 @@ export const createUserSubdomainRedirect = ( if (active === '' || RESERVED_SUBDOMAINS.has(active)) return next(); const host = (req.headers.host ?? '').toLowerCase(); + const hostName = host.split(':')[0]; + if ( + hostingDomains.some( + (d) => hostName === d || hostName.endsWith(`.${d}`), + ) + ) { + return next(); + } if (!host.endsWith(domain)) return next(); // host ends in domain — swap the domain suffix for the hosting one, diff --git a/src/dev-center/js/websites.js b/src/dev-center/js/websites.js index df5aac091..21bfda869 100644 --- a/src/dev-center/js/websites.js +++ b/src/dev-center/js/websites.js @@ -253,12 +253,12 @@ function generate_website_card (website) { return ` - + - ${website.subdomain}.puter.site - ${website.root_dir ? website.root_dir.name : ''} - ${website.created_at} - + ${html_encode(website.subdomain)}.puter.site + ${website.root_dir ? html_encode(website.root_dir.name) : ''} + ${html_encode(website.created_at)} + `; } diff --git a/src/dev-center/js/workers.js b/src/dev-center/js/workers.js index ee299cca7..289c83e0b 100644 --- a/src/dev-center/js/workers.js +++ b/src/dev-center/js/workers.js @@ -271,12 +271,12 @@ function generate_worker_card (worker) { return ` - + - ${worker.name} - ${worker.file_path ? worker.file_path : ''} - ${worker.created_at} - + ${html_encode(worker.name)} + ${worker.file_path ? html_encode(worker.file_path) : ''} + ${html_encode(worker.created_at)} + `; }