From bd5315a69ec2ef15493bbb10dd93491bb4598a37 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 13:29:17 -0700 Subject: [PATCH 1/3] Add tests for ALLOW_PLAINTEXT_SECRETS boot guard Assert the hub refuses to boot with ALLOW_PLAINTEXT_SECRETS set unless BASE_URL is a loopback address, and still boots fine locally or with a real CREDENTIAL_ENCRYPTION_KEY. --- apps/hub/test/config.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/hub/test/config.test.ts b/apps/hub/test/config.test.ts index 7e113ffd..df27ff07 100644 --- a/apps/hub/test/config.test.ts +++ b/apps/hub/test/config.test.ts @@ -322,6 +322,42 @@ describe("readHubConfig", () => { expect(message).toContain("ALLOW_PLAINTEXT_SECRETS"); }); + test("ALLOW_PLAINTEXT_SECRETS boots fine against a loopback BASE_URL", () => { + expect( + readHubConfig({ + ...validEnv, + BASE_URL: "http://localhost:3000", + ALLOW_PLAINTEXT_SECRETS: "1", + }).allowPlaintextSecrets, + ).toBe(true); + expect( + readHubConfig({ + ...validEnv, + BASE_URL: "http://127.0.0.1:3000", + ALLOW_PLAINTEXT_SECRETS: "1", + }).allowPlaintextSecrets, + ).toBe(true); + }); + + test("ALLOW_PLAINTEXT_SECRETS refuses to boot against a non-loopback BASE_URL", () => { + const message = readExpectingError({ + ...validEnv, + BASE_URL: "https://workbench.example.com", + ALLOW_PLAINTEXT_SECRETS: "1", + }); + expect(message).toContain("ALLOW_PLAINTEXT_SECRETS"); + expect(message).toContain("BASE_URL"); + }); + + test("a non-loopback BASE_URL boots fine without ALLOW_PLAINTEXT_SECRETS", () => { + const config = readHubConfig({ + ...validEnv, + BASE_URL: "https://workbench.example.com", + CREDENTIAL_ENCRYPTION_KEY: "a".repeat(64), + }); + expect(config.allowPlaintextSecrets).toBe(false); + }); + test("accepts postgresql:// and https:// URL forms", () => { const config = readHubConfig({ ...validEnv, From ec2c995856fddfeccd38b200bcfb54e7525fe366 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 13:29:23 -0700 Subject: [PATCH 2/3] Refuse ALLOW_PLAINTEXT_SECRETS outside a loopback BASE_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real deployment that inherits .env.example's dev-friendly ALLOW_PLAINTEXT_SECRETS=1 would silently store secrets (including real provider keys) unencrypted at rest. Rather than trust an operator to have overridden the file, the hub now refuses to boot when the flag is set and BASE_URL isn't localhost/127.0.0.1/::1 — so the flag can only ever take effect against a developer's own machine. --- apps/hub/src/config.ts | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index 96cba219..061f66d5 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -48,6 +48,19 @@ import type { SupportedCredentialProvider } from "@workbench/hub-client"; const HTTP_URL = /^https?:\/\/.+$/; +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]); + +/** + * Whether `baseUrl` names a loopback address — the one fact that + * distinguishes a developer's own machine from a real deployment + * without trusting an operator to have overridden anything. Used to + * refuse `ALLOW_PLAINTEXT_SECRETS` (see `readHubConfig`) rather than + * relying on an operator to have removed it from an inherited `.env`. + */ +function isLoopbackBaseUrl(baseUrl: string): boolean { + return LOOPBACK_HOSTNAMES.has(new URL(baseUrl).hostname); +} + const HubEnv = type({ DATABASE_URL: type(/^postgres(ql)?:\/\/.+$/).describe( "a Postgres connection URL, e.g. postgres://workbench:workbench@localhost:5432/workbench", @@ -167,7 +180,7 @@ const HubEnv = type({ "a 64-character hex-encoded 32-byte AES-256 key (openssl rand -hex 32) encrypting secrets at rest through Interchange's CredentialCipher seam — webhook-trigger signing secrets and onboarding's OAuth PKCE connect state; boot fails without it unless ALLOW_PLAINTEXT_SECRETS opts into dev/test's unencrypted fallback", ), "ALLOW_PLAINTEXT_SECRETS?": type("'1' | 'true'").describe( - "dev/test-only opt-in to boot without CREDENTIAL_ENCRYPTION_KEY, storing secrets at rest unencrypted with a boot warning; never set this for a real deployment", + "dev/test-only opt-in to boot without CREDENTIAL_ENCRYPTION_KEY, storing secrets at rest unencrypted with a boot warning; refused unless BASE_URL is a loopback address, so a real deployment can never inherit it by accident", ), "ALLOW_UNVERIFIED_EMAILS?": type("'1' | 'true'").describe( "dev/test-only opt-in to let @workbench/access-policy trust an email that better-auth has not verified — self-signup domain checks and pending-invite redemption normally require emailVerified; never set this for a real deployment", @@ -667,5 +680,24 @@ export function readHubConfig( if (sidecarProvisioners.defaultProvisionerId !== undefined) hubConfig.defaultSidecarProvisionerId = sidecarProvisioners.defaultProvisionerId; + + if ( + hubConfig.allowPlaintextSecrets && + !isLoopbackBaseUrl(hubConfig.baseUrl) + ) { + throw new Error( + [ + `ALLOW_PLAINTEXT_SECRETS is set, but BASE_URL (${hubConfig.baseUrl}) is not a loopback address.`, + "Storing secrets unencrypted at rest is a dev/test-only fallback for", + "http://localhost — refused here because this looks like a real", + "deployment. Generate a real key instead and add it to .env:", + "", + " openssl rand -hex 32", + "", + "then set CREDENTIAL_ENCRYPTION_KEY to it and remove ALLOW_PLAINTEXT_SECRETS.", + ].join("\n"), + ); + } + return hubConfig; } From b3a2e2d3883e2a79a73fb62acb9945c760e23b29 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 13:29:28 -0700 Subject: [PATCH 3/3] Update docs: ALLOW_PLAINTEXT_SECRETS boot guard in .env.example Note that the shipped ALLOW_PLAINTEXT_SECRETS=1 is now safe to copy verbatim into any deployment's .env: it can only take effect when BASE_URL is a loopback address. --- .env.example | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 53b5ac54..f3ab41db 100644 --- a/.env.example +++ b/.env.example @@ -261,7 +261,10 @@ HUB_STATIC_DIR=../web/dist # Dev/test-only opt-out of the CREDENTIAL_ENCRYPTION_KEY requirement above: # set to boot without a key, storing those secrets unencrypted with a boot # warning. Uncommented here so a fresh checkout's `bun run dev` keeps -# working out of the box — never set this for a real deployment. +# working out of the box. Safe to inherit as-is: the hub refuses to boot +# with this set unless BASE_URL is also a loopback address (localhost / +# 127.0.0.1 / ::1), so it can never take effect against a real deployment +# even if this file is copied verbatim. ALLOW_PLAINTEXT_SECRETS=1 # Opt-out of @workbench/access-policy's email-verification requirement.