Skip to content

Commit 7cb35f1

Browse files
carderneclaudenicktrn
authored
fix(webapp,helm): require control-plane auth secrets and remove weak bundled-datastore defaults (#57)
* fix(webapp): require control-plane auth secrets * fix(helm): remove weak default credentials for bundled datastores Empty the shipped postgres/clickhouse/minio/registry credential defaults so an unconfigured install fails closed instead of booting with publicly-known passwords. Add fail-closed validation guards for the bundled (deploy=true) datastores and feed CI throwaway render-time values so chart linting stays green. * fix(webapp): reject known-insecure control-plane secret defaults Add a blocklist refine to PROVIDER_SECRET, COORDINATOR_SECRET and MANAGED_WORKER_SECRET so the webapp rejects the publicly-known placeholder strings at boot, matching the value the coordinator already refuses at startup. Add the three now-required vars to the root .env.example and align the supervisor MANAGED_WORKER_SECRET example with the Docker example so local setups authenticate consistently. * fix(helm): fail closed on missing control-plane secrets and fix datastore guard guidance Blank the bundled provider/coordinator/managed-worker secret defaults and add a render-time guard so an unconfigured install fails closed instead of shipping repo-published tokens, matching the datastore fail-closed pattern. Require both postgres.auth.postgresPassword and postgres.auth.password, and point the Postgres/ClickHouse guard messages at the key paths the webapp actually consumes so operators are not sent to a crash loop. Add placeholder credentials to values-production-example.yaml and the CI lint values so the documented install paths still render, and update the README to reflect that a values file with credentials is now required. * test(webapp): provide required control-plane secrets in e2e harness The e2e webapp harness (startTestServer) spawns the production webapp bundle, which now fails closed when PROVIDER_SECRET / COORDINATOR_SECRET / MANAGED_WORKER_SECRET are unset. Supply strong test values (not the blocklisted known-insecure defaults) so the webapp boots and the E2E Tests: Webapp check passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(webapp,hosting): fail closed on empty app secrets and stop shipping working ones Extend the fail-closed convention to the two remaining app secrets and the Docker Compose self-host path: - env.server: SESSION_SECRET and MAGIC_LINK_SECRET now require .min(1), so an empty value fails the schema parse at boot rather than running with an empty secret (SESSION_SECRET is the JWT signing key) or falling through to the emailAuth runtime guard. - hosting/docker/.env.example and apps/supervisor/.env.example: ship the crypto/auth secrets empty with a short 'generate with openssl rand -hex 16' comment instead of the previous working public values. A bare 'cp .env.example .env && docker compose up' now fails closed on the first missing secret; the documented setup already generates all six. - Tests: cover empty and unset rejection for all five required secrets. Addresses SEC-387 / GHSA-pqxw-g93w-hj9x (the Docker path the earlier commits in this PR did not cover). * fix(webapp,helm): reject published application secret defaults * fix(helm): reject unsupported bundled datastore secret references * format * .env * feat(helm): auto-generate app and control-plane secrets instead of failing closed Leave secrets.* empty to have the chart generate a strong value on first install, retained across upgrades via lookup so ENCRYPTION_KEY/SESSION_SECRET are never rotated. Explicit values and existingSecret still win. Removes the now-redundant fail-closed guards for these secrets (the webapp still rejects known-insecure values at startup). * feat(hosting): add docker generate-secrets.sh to fill self-hosting secrets Fills empty required secrets in .env with openssl-random values. Safe to re-run: never overwrites an already-set value, so restarts and re-runs don't rotate ENCRYPTION_KEY/SESSION_SECRET. Docs updated to run it during setup. * ci(helm): lint and render release-helm with ci lint-values The bundled-datastore passwords are now fail-closed, so a values.yaml-only lint/template render fails. Supply ci/lint-values.yaml like the prerelease workflow already does, keeping the chart release pipeline green. * feat(webapp): allow opting out of the insecure-default secret blocklist ALLOW_INSECURE_DEFAULT_SECRETS lets a deployment boot while still using a known-published default it cannot safely rotate yet (e.g. ENCRYPTION_KEY protects existing data, SESSION_SECRET rotation logs everyone out). It only bypasses the known-insecure blocklist; the min-length and 32-byte ENCRYPTION_KEY checks still apply. A loud warning is logged at boot naming any secret still on a published default. * feat(hosting): auto-generate bundled-datastore passwords for docker self-hosting Remove the shipped weak defaults for postgres/clickhouse/minio/registry. generate-secrets.sh now fills each datastore password (openssl rand), and for the registry writes a matching bcrypt htpasswd via docker. Connection URLs are derived from the single password var by compose interpolation, so server and client always match; datastore services fail closed with a helpful message if a password is unset. Never clobbers an existing value - use --force to rotate. * docs(self-hosting): document secret auto-generation, rotation gotchas, and the insecure-default opt-out Note that generate-secrets.sh also fills the bundled datastore passwords; add ALLOW_INSECURE_DEFAULT_SECRETS to the webapp env list; add a Helm secret generation/rotation section covering upgrade retention, the GitOps regeneration caveat, and the lack of a clean ENCRYPTION_KEY migration. * feat(helm): auto-generate bundled-datastore passwords Generate the postgres/clickhouse/minio passwords once into a chart-managed datastore Secret (retained across upgrades via lookup) and point each bundled subchart at it via auth.existingSecret; the webapp reads them back through secretKeyRef and $(VAR) URL interpolation, so server and client always match. The registry password is generated and retained inside secrets.yaml (consumed at render time by htpasswd + dockerconfigjson). Drops the deploy=true datastore password guards - a bare helm install now renders and deploys with strong, unique credentials, and explicit values or an existingSecret still win. * fix(helm): start the s2 container via args, not command The s2 image ENTRYPOINT is ["./s2"] with the subcommand/flags as CMD. Setting Kubernetes command overrode the entrypoint and tried to exec "lite" directly, so the pod failed with StartError and realtime streams v2 (the default) never came up. Passing them as args preserves the entrypoint (./s2 lite ...). Verified live in kind: the s2 pod now reaches Running. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
1 parent 3cbbaed commit 7cb35f1

29 files changed

Lines changed: 602 additions & 111 deletions

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
SESSION_SECRET=abcdef1234
33
MAGIC_LINK_SECRET=abcdef1234
44
ENCRYPTION_KEY=ae13021afef0819c3a307ad487071c06 # Must be a random 16 byte hex string. You can generate an encryption key by running `openssl rand -hex 16` in your terminal
5+
MANAGED_WORKER_SECRET=abcdef1234 # Must match the supervisor's MANAGED_WORKER_SECRET
56
LOGIN_ORIGIN=http://localhost:3030
67
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres?schema=public
78
# This sets the URL used for direct connections to the database and should only be needed in limited circumstances

.github/workflows/helm-prerelease.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,14 @@ jobs:
5252
5353
- name: Lint Helm Chart
5454
run: |
55-
helm lint ./hosting/k8s/helm/
55+
helm lint ./hosting/k8s/helm/ \
56+
--values ./hosting/k8s/helm/ci/lint-values.yaml
5657
5758
- name: Render templates
5859
run: |
5960
helm template test-release ./hosting/k8s/helm/ \
6061
--values ./hosting/k8s/helm/values.yaml \
62+
--values ./hosting/k8s/helm/ci/lint-values.yaml \
6163
--output-dir ./helm-output
6264
6365
- name: Validate manifests

.github/workflows/release-helm.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,14 @@ jobs:
4747
4848
- name: Lint Helm Chart
4949
run: |
50-
helm lint ./hosting/k8s/helm/
50+
helm lint ./hosting/k8s/helm/ \
51+
--values ./hosting/k8s/helm/ci/lint-values.yaml
5152
5253
- name: Render templates
5354
run: |
5455
helm template test-release ./hosting/k8s/helm/ \
5556
--values ./hosting/k8s/helm/values.yaml \
57+
--values ./hosting/k8s/helm/ci/lint-values.yaml \
5658
--output-dir ./helm-output
5759
5860
- name: Validate manifests
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: breaking
4+
---
5+
6+
Self-hosted deployments no longer ship shared default credentials; fresh installs generate their own. If yours still uses a previously published default, set a unique value before upgrading, or set `ALLOW_INSECURE_DEFAULT_SECRETS=true` to keep booting while you migrate.

apps/supervisor/.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# This needs to match the token of the worker group you want to connect to
22
TRIGGER_WORKER_TOKEN=
33

4-
# This needs to match the MANAGED_WORKER_SECRET env var on the webapp
5-
MANAGED_WORKER_SECRET=managed-secret
4+
# Must match the webapp's MANAGED_WORKER_SECRET. Generate with: openssl rand -hex 16
5+
MANAGED_WORKER_SECRET=
66

77
# Point this at the webapp in prod
88
TRIGGER_API_URL=http://localhost:3030

apps/webapp/app/env.server.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,29 @@ const S2EnvSchema = z.preprocess(
8585
])
8686
);
8787

88+
// Previously published secret values must never be accepted, including when
89+
// an existing deployment or external secret manager still supplies one.
90+
const INSECURE_SECRET_VALUES = [
91+
"managed-secret",
92+
"2818143646516f6fffd707b36f334bbb",
93+
"44da78b7bbb0dfe709cf38931d25dcdd",
94+
"f686147ab967943ebbe9ed3b496e465a",
95+
"447c29678f9eaf289e9c4b70d3dd8a7f",
96+
];
97+
98+
// Escape hatch for deployments that can't rotate a published default yet (e.g.
99+
// ENCRYPTION_KEY protects existing data). Read raw: a refine can't see the
100+
// sibling parsed flag.
101+
const allowInsecureDefaultSecrets = ["true", "1"].includes(
102+
(process.env.ALLOW_INSECURE_DEFAULT_SECRETS ?? "").toLowerCase().trim()
103+
);
104+
105+
const isNotInsecureSecret = (value: string) =>
106+
allowInsecureDefaultSecrets || !INSECURE_SECRET_VALUES.includes(value);
107+
108+
const INSECURE_SECRET_MESSAGE =
109+
"must not be a known-insecure published default; set a strong, unique value. If you cannot rotate it yet (e.g. it protects existing encrypted data or active sessions), set ALLOW_INSECURE_DEFAULT_SECRETS=1 to boot while you migrate.";
110+
88111
const EnvironmentSchema = z
89112
.object({
90113
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
@@ -188,14 +211,15 @@ const EnvironmentSchema = z
188211
// Control-plane cache relax knobs. Unset -> defaults (DEFAULT_CP_CACHE_TTL_MS / _MAX_ENTRIES).
189212
CONTROL_PLANE_CACHE_TTL_MS: z.coerce.number().int().optional(),
190213
CONTROL_PLANE_CACHE_MAX_ENTRIES: z.coerce.number().int().optional(),
191-
SESSION_SECRET: z.string(),
192-
MAGIC_LINK_SECRET: z.string(),
214+
SESSION_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
215+
MAGIC_LINK_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
193216
ENCRYPTION_KEY: z
194217
.string()
195218
.refine(
196219
(val) => Buffer.from(val, "utf8").length === 32,
197220
"ENCRYPTION_KEY must be exactly 32 bytes"
198-
),
221+
)
222+
.refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
199223
WHITELISTED_EMAILS: z
200224
.string()
201225
.refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.")
@@ -684,7 +708,11 @@ const EnvironmentSchema = z
684708
EVENTS_LOAD_SHEDDING_THRESHOLD: z.coerce.number().int().default(100000),
685709
EVENTS_LOAD_SHEDDING_ENABLED: z.string().default("1"),
686710

687-
MANAGED_WORKER_SECRET: z.string().default("managed-secret"),
711+
MANAGED_WORKER_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
712+
713+
// Allow booting with a known-insecure published default secret. Temporary
714+
// bridge for deployments that can't rotate yet; rotate as soon as possible.
715+
ALLOW_INSECURE_DEFAULT_SECRETS: BoolEnv.default(false),
688716

689717
// Tenant scoping on worker actions is header-driven (folded into the engine snapshot read) and
690718
// needs no flag. This is only the no-header fallback: when "1", a worker action on a run created
@@ -2113,3 +2141,24 @@ const EnvironmentSchema = z
21132141

21142142
export type Environment = z.infer<typeof EnvironmentSchema>;
21152143
export const env = EnvironmentSchema.parse(process.env);
2144+
2145+
if (env.ALLOW_INSECURE_DEFAULT_SECRETS) {
2146+
const insecure = (
2147+
[
2148+
["SESSION_SECRET", env.SESSION_SECRET],
2149+
["MAGIC_LINK_SECRET", env.MAGIC_LINK_SECRET],
2150+
["ENCRYPTION_KEY", env.ENCRYPTION_KEY],
2151+
["MANAGED_WORKER_SECRET", env.MANAGED_WORKER_SECRET],
2152+
] as const
2153+
)
2154+
.filter(([, value]) => INSECURE_SECRET_VALUES.includes(value))
2155+
.map(([name]) => name);
2156+
2157+
if (insecure.length > 0) {
2158+
console.warn(
2159+
`⚠️ ALLOW_INSECURE_DEFAULT_SECRETS is enabled and these secrets still use a known-insecure published default: ${insecure.join(
2160+
", "
2161+
)}. This is insecure - rotate them as soon as you can.`
2162+
);
2163+
}
2164+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
const originalEnv = process.env;
4+
5+
const requiredEnv = {
6+
NODE_ENV: "test",
7+
DATABASE_URL: "postgresql://test:test@localhost:5432/test",
8+
DIRECT_URL: "postgresql://test:test@localhost:5432/test",
9+
SESSION_SECRET: "test-session-secret",
10+
MAGIC_LINK_SECRET: "test-magic-link-secret",
11+
ENCRYPTION_KEY: "test-encryption-keeeeey-32-bytes",
12+
CLICKHOUSE_URL: "http://localhost:8123",
13+
DEPLOY_REGISTRY_HOST: "registry.example.com",
14+
MANAGED_WORKER_SECRET: "test-managed-worker-secret",
15+
};
16+
17+
describe("webapp environment secrets", () => {
18+
afterEach(() => {
19+
process.env = originalEnv;
20+
vi.resetModules();
21+
});
22+
23+
it.each(["SESSION_SECRET", "MAGIC_LINK_SECRET", "ENCRYPTION_KEY", "MANAGED_WORKER_SECRET"])(
24+
"requires %s to be explicitly set",
25+
async (key) => {
26+
process.env = { ...requiredEnv };
27+
delete process.env[key];
28+
29+
await expect(import("../app/env.server")).rejects.toThrow(key);
30+
}
31+
);
32+
33+
it.each(["SESSION_SECRET", "MAGIC_LINK_SECRET", "ENCRYPTION_KEY", "MANAGED_WORKER_SECRET"])(
34+
"rejects an empty %s",
35+
async (key) => {
36+
process.env = { ...requiredEnv, [key]: "" };
37+
38+
await expect(import("../app/env.server")).rejects.toThrow(key);
39+
}
40+
);
41+
42+
it.each([
43+
["SESSION_SECRET", "2818143646516f6fffd707b36f334bbb"],
44+
["MAGIC_LINK_SECRET", "44da78b7bbb0dfe709cf38931d25dcdd"],
45+
["ENCRYPTION_KEY", "f686147ab967943ebbe9ed3b496e465a"],
46+
["MANAGED_WORKER_SECRET", "managed-secret"],
47+
["MANAGED_WORKER_SECRET", "447c29678f9eaf289e9c4b70d3dd8a7f"],
48+
])("rejects the known-insecure default value for %s", async (key, insecureValue) => {
49+
process.env = { ...requiredEnv, [key]: insecureValue };
50+
51+
await expect(import("../app/env.server")).rejects.toThrow(key);
52+
});
53+
54+
it("accepts explicitly configured secrets", async () => {
55+
process.env = { ...requiredEnv };
56+
57+
const { env } = await import("../app/env.server");
58+
59+
expect(env.SESSION_SECRET).toBe(requiredEnv.SESSION_SECRET);
60+
expect(env.MAGIC_LINK_SECRET).toBe(requiredEnv.MAGIC_LINK_SECRET);
61+
expect(env.ENCRYPTION_KEY).toBe(requiredEnv.ENCRYPTION_KEY);
62+
expect(env.MANAGED_WORKER_SECRET).toBe(requiredEnv.MANAGED_WORKER_SECRET);
63+
});
64+
65+
it("allows a known-insecure default when ALLOW_INSECURE_DEFAULT_SECRETS is set", async () => {
66+
process.env = {
67+
...requiredEnv,
68+
ALLOW_INSECURE_DEFAULT_SECRETS: "1",
69+
ENCRYPTION_KEY: "f686147ab967943ebbe9ed3b496e465a",
70+
MANAGED_WORKER_SECRET: "managed-secret",
71+
};
72+
73+
const { env } = await import("../app/env.server");
74+
75+
expect(env.ENCRYPTION_KEY).toBe("f686147ab967943ebbe9ed3b496e465a");
76+
expect(env.MANAGED_WORKER_SECRET).toBe("managed-secret");
77+
});
78+
79+
it("still rejects an empty secret even with ALLOW_INSECURE_DEFAULT_SECRETS", async () => {
80+
process.env = { ...requiredEnv, ALLOW_INSECURE_DEFAULT_SECRETS: "1", SESSION_SECRET: "" };
81+
82+
await expect(import("../app/env.server")).rejects.toThrow("SESSION_SECRET");
83+
});
84+
});

apps/webapp/test/registryConfig.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ describe("getRegistryConfig", () => {
1010
MAGIC_LINK_SECRET: "test-magic-link-secret",
1111
ENCRYPTION_KEY: "test-encryption-keeeeey-32-bytes",
1212
CLICKHOUSE_URL: "http://localhost:8123",
13+
PROVIDER_SECRET: "test-provider-secret",
14+
COORDINATOR_SECRET: "test-coordinator-secret",
15+
MANAGED_WORKER_SECRET: "test-managed-worker-secret",
1316
};
1417

1518
beforeEach(() => {

apps/webapp/test/setup.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ config({ path: path.resolve(__dirname, "../.env") });
1313
// the pair — the ioredis mock below forces lazyConnect, so nothing ever dials.
1414
process.env.REDIS_HOST ??= "localhost";
1515
process.env.REDIS_PORT ??= "6379";
16+
process.env.PROVIDER_SECRET ??= "test-provider-secret";
17+
process.env.COORDINATOR_SECRET ??= "test-coordinator-secret";
18+
process.env.MANAGED_WORKER_SECRET ??= "test-managed-worker-secret";
1619

1720
// Worker singletons construct a RedisWorker at import time whose ioredis client
1821
// connects eagerly, so any test importing the service graph opens real Redis

docs/self-hosting/docker.mdx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,23 @@ git clone --depth=1 https://github.com/triggerdotdev/trigger.dev
7575
cd trigger.dev/hosting/docker
7676
```
7777

78-
2. Create a `.env` file
78+
2. Create a `.env` file and generate secrets
7979

8080
```bash
8181
cp .env.example .env
82+
83+
# Fills the required secrets in .env with strong, unique values.
84+
# Safe to re-run - it never overwrites a secret you've already set.
85+
./generate-secrets.sh
8286
```
8387

88+
<Note>
89+
The stack ships no working default credentials. `generate-secrets.sh` fills the
90+
application secrets and the bundled datastore passwords with strong, unique values.
91+
Keep them safe - rotating the encryption key or session secret later will invalidate
92+
existing sessions and encrypted data.
93+
</Note>
94+
8495
3. Start the webapp
8596

8697
```bash
@@ -130,6 +141,13 @@ docker compose up -d
130141

131142
4. Configure the supervisor using the [environment variables](/self-hosting/env/supervisor) in your `.env` file, including the [worker token](#worker-token).
132143

144+
<Warning>
145+
For a split webapp/worker setup, set `MANAGED_WORKER_SECRET` on the worker to
146+
the **same** value as the webapp's `MANAGED_WORKER_SECRET`. Don't run
147+
`generate-secrets.sh` on the worker host - it would create a mismatched value
148+
and the worker would fail to authenticate.
149+
</Warning>
150+
133151
5. Apply the changes:
134152

135153
```bash

0 commit comments

Comments
 (0)