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. 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; } 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,