From b00f1064ae30dcee50b5872b850dfabe5da9c957 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Wed, 16 Sep 2026 23:10:22 +0300 Subject: [PATCH] fix(api,auth)!: bind loopback by default, and let a container ask for more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both servers bound `0.0.0.0`. That was harmless while the only deployment was docker-compose.yml, which publishes these ports on 127.0.0.1 and makes the container's own bind irrelevant. As ordinary processes on a rented machine there is no such wrapper, and `0.0.0.0` is a listener on the internet — in front of an issuer that sets cookies without `Secure` and mints sign-in codes, because it expects something else to be terminating TLS. So the default is `127.0.0.1` and `HOST` is there for the deployment that genuinely needs every interface. compose now sets `HOST: 0.0.0.0` explicitly, which is not a workaround: inside a container, binding loopback is what would make the published port unreachable. The right answer differs by deployment, which is why it is a variable rather than a constant. Both now log the address they bound, not the one they hoped for. BREAKING CHANGE: api and auth no longer listen on every interface by default. A deployment that relied on that must set HOST=0.0.0.0. --- apps/api/app/server.ts | 18 ++++++++++++++++-- apps/auth/src/server.ts | 20 ++++++++++++++++++-- docker-compose.yml | 12 ++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/api/app/server.ts b/apps/api/app/server.ts index 32cede63..6f3d697b 100644 --- a/apps/api/app/server.ts +++ b/apps/api/app/server.ts @@ -14,9 +14,23 @@ import handler, { type ApiEnv } from './index.js'; const port = Number(process.env.PORT ?? 3000); +// Loopback by default, and that is a security setting rather than a +// convenience. Under a Cloudflare Tunnel nothing reaches this process except +// `cloudflared` on the same machine, and this address is half of what makes +// that true -- the machine's firewall is the other half. +// +// It was `0.0.0.0` until 2026-09-16, which was harmless while the only +// deployment was `docker-compose.yml`, because that publishes these ports on +// `127.0.0.1` and the container's own bind never mattered. As an ordinary +// process there is no such wrapper: `0.0.0.0` is a public listener. +// +// `HOST` exists for the deployment that genuinely wants one -- a container, +// where binding loopback would make the port unpublishable. +const hostname = process.env.HOST ?? '127.0.0.1'; + Bun.serve({ port, - hostname: '0.0.0.0', + hostname, // A Worker runtime hands the handler a context whose `waitUntil` keeps the // invocation alive past the response. A process does not need convincing to // stay alive, so the equivalent is to let the promise run — with a catch, @@ -37,4 +51,4 @@ Bun.serve({ ) }); -console.log(`[api] listening on http://0.0.0.0:${port}`); +console.log(`[api] listening on http://${hostname}:${port}`); diff --git a/apps/auth/src/server.ts b/apps/auth/src/server.ts index 123b3d1a..1ce57df8 100644 --- a/apps/auth/src/server.ts +++ b/apps/auth/src/server.ts @@ -16,9 +16,25 @@ import handler from './index.js'; const port = Number(process.env.PORT ?? 1337); +// Loopback by default, and that is a security setting rather than a +// convenience. Under a Cloudflare Tunnel nothing reaches this process except +// `cloudflared` on the same machine, and this address is half of what makes +// that true -- the machine's firewall is the other half. +// +// It was `0.0.0.0` until 2026-09-16, which was harmless while the only +// deployment was `docker-compose.yml`, because that publishes these ports on +// `127.0.0.1` and the container's own bind never mattered. As an ordinary +// process there is no such wrapper: `0.0.0.0` is a public listener, and this one +// issues sign-in codes and sets cookies without `Secure`, because it expects +// something in front of it to be the thing terminating TLS. +// +// `HOST` exists for the deployment that genuinely wants one -- a container, +// where binding loopback would make the port unpublishable. +const hostname = process.env.HOST ?? '127.0.0.1'; + Bun.serve({ port, - hostname: '0.0.0.0', + hostname, // A Worker runtime hands the handler a context whose `waitUntil` keeps the // invocation alive past the response. A process does not need convincing to // stay alive, so the equivalent is to let the promise run — with a catch, @@ -39,4 +55,4 @@ Bun.serve({ ) }); -console.log(`[auth] listening on http://0.0.0.0:${port}`); +console.log(`[auth] listening on http://${hostname}:${port}`); diff --git a/docker-compose.yml b/docker-compose.yml index 58a985ae..bec8c96f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,12 @@ services: # unconfigured and this unset, the issuer refuses to send rather than # logging codes, which is the failure a self-hoster should get. EMAIL_DEV_LOG: ${EMAIL_DEV_LOG:-} + # Every interface *inside the container*, which is what makes the + # loopback publication below reachable. The processes default to + # 127.0.0.1 because a bare process on a host has no such wrapper and a + # public bind there is a listener on the internet; a container's own + # loopback is not, and binding it would make the port unpublishable. + HOST: 0.0.0.0 # Loopback. This listener speaks plain HTTP and sets no `Secure` on the # cookies it issues, because it expects to be behind something that # terminates TLS. Published on every interface it would be a way to reach @@ -101,6 +107,12 @@ services: # A shared secret that turns any request carrying it into an operator. # Required, with no default, for that reason. ADMIN_SHARED_SECRET: ${ADMIN_SHARED_SECRET:?set ADMIN_SHARED_SECRET in .env to a value you generated} + # Every interface *inside the container*, which is what makes the + # loopback publication below reachable. The processes default to + # 127.0.0.1 because a bare process on a host has no such wrapper and a + # public bind there is a listener on the internet; a container's own + # loopback is not, and binding it would make the port unpublishable. + HOST: 0.0.0.0 # Loopback, for the same reason as the issuer above. ports: - '127.0.0.1:3000:3000'