From 9ac14b55a3d9c6f39e895320f45db95ec8513581 Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Fri, 7 Aug 2026 08:30:57 +0000 Subject: [PATCH] fix(#622): the config applier can no longer half-apply a secret-bearing feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `auth-config.json` is committed, so it holds no secrets — but two of the features it declares need one, and the Management API takes it as a sibling field: smtp_host, smtp_user, … need smtp_pass security_captcha_enabled needs security_captcha_secret `--apply` sent the visible fields and omitted the secrets, which is strictly worse than the drift it claims to fix. SMTP with no password sends NOTHING — a downgrade from the built-in fallback mailer. CAPTCHA with no secret REJECTS EVERY SIGNUP. This is not hypothetical. On 2026-08-07 the drift check told an operator to run exactly this command against a production project whose smtp_host was null. Now each secret is sourced from env (falling back to .env, since nothing preloads dotenv here), and when it is absent the feature's fields are withheld ENTIRELY rather than half-applied — a visible gap beats a config that looks applied and silently does nothing. If every proposed change gets withheld, exit 1. TURNSTILE_SECRET is the name scripts/check-captcha.mjs already uses; run that first — it validates the secret against Cloudflare siteverify. Verified against production: with TURNSTILE_SECRET unset, `--apply` withholds both captcha fields, applies nothing, and exits 1. The attach path runs only under --apply and is unverified until a real secret exists. Closes #622 --- scripts/supabase/set-auth-config.ts | 145 +++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 2 deletions(-) diff --git a/scripts/supabase/set-auth-config.ts b/scripts/supabase/set-auth-config.ts index d6601444..ee1d3453 100644 --- a/scripts/supabase/set-auth-config.ts +++ b/scripts/supabase/set-auth-config.ts @@ -15,8 +15,27 @@ * Env: * SUPABASE_ACCESS_TOKEN — Management API token (from dashboard) * NEXT_PUBLIC_SUPABASE_PROJECT_REF — short project ref, e.g. "abcd1234" + * RESEND_API_KEY — becomes smtp_pass when SMTP is configured + * TURNSTILE_SECRET_KEY — becomes security_captcha_secret * - * Both are in .env.local (gitignored). Script refuses to run without them. + * All are read from the environment, falling back to `.env` (gitignored). + * + * SECRET-BEARING FIELDS (#622). `auth-config.json` is committed, so it holds no + * secrets — but two of the features it declares are broken or dangerous without + * one, and the Management API takes the secret as a sibling field: + * + * smtp_host, smtp_user, … need `smtp_pass` + * security_captcha_enabled needs `security_captcha_secret` + * + * Before this was handled, `--apply` sent the visible fields and omitted the + * secrets. That is strictly worse than the drift it "fixes": SMTP with no + * password sends NOTHING (vs. a working fallback mailer), and CAPTCHA with no + * secret REJECTS EVERY SIGNUP. On 2026-08-07 the drift check told an operator to + * run exactly this command against a production project whose SMTP was unset. + * + * So: inject each secret from env when present, and when it is absent SKIP that + * feature's fields entirely rather than half-applying them. A gap you can see + * beats a config that looks applied and silently does not work. */ import { readFileSync } from 'node:fs'; @@ -27,6 +46,86 @@ import { dirname } from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +/** + * Read a var from the environment, falling back to `.env`. + * + * Nothing preloads dotenv for this script, so in a normal local shell every + * secret below would read as absent and silently take the "skip" path. CI passes + * them as real env vars and takes the first branch. + */ +function envOrDotenv(names: string[]): string | undefined { + for (const n of names) { + const v = process.env[n]; + if (v && v.length > 0) return v; + } + let raw = ''; + try { + raw = readFileSync(resolve(__dirname, '..', '..', '.env'), 'utf8'); + } catch { + return undefined; + } + for (const line of raw.split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('#')) continue; + const eq = t.indexOf('='); + if (eq < 1) continue; + const key = t.slice(0, eq).trim(); + if (!names.includes(key)) continue; + const val = t + .slice(eq + 1) + .trim() + .replace(/^["']|["']$/g, ''); + if (val) return val; + } + return undefined; +} + +/** + * A feature whose declared fields are inert or harmful without a companion + * secret that the committed config file deliberately does not carry. + */ +type SecretRule = { + label: string; + /** the Management API field that carries the secret */ + secretField: string; + /** env var names to source it from, in priority order */ + envNames: string[]; + /** true when the desired config actually turns this feature on */ + wanted: (desired: AuthConfig) => boolean; + /** fields to withhold entirely when the secret is missing */ + fields: string[]; + /** what goes wrong if applied without the secret */ + harm: string; +}; + +const SECRET_RULES: SecretRule[] = [ + { + label: 'SMTP', + secretField: 'smtp_pass', + envNames: ['SUPABASE_SMTP_PASS', 'RESEND_API_KEY'], + wanted: (d) => typeof d.smtp_host === 'string' && d.smtp_host.length > 0, + fields: [ + 'smtp_host', + 'smtp_port', + 'smtp_user', + 'smtp_admin_email', + 'smtp_sender_name', + ], + harm: 'a custom SMTP host with no password sends NOTHING — worse than the built-in fallback mailer', + }, + { + label: 'CAPTCHA', + secretField: 'security_captcha_secret', + // TURNSTILE_SECRET is the name scripts/check-captcha.mjs already uses; keep + // one name for one secret. Run that script BEFORE enabling — it validates the + // secret against Cloudflare siteverify and exits non-zero when it is unsafe. + envNames: ['TURNSTILE_SECRET', 'SUPABASE_AUTH_CAPTCHA_SECRET'], + wanted: (d) => d.security_captcha_enabled === true, + fields: ['security_captcha_enabled', 'security_captcha_provider'], + harm: 'CAPTCHA enabled with no secret REJECTS EVERY SIGNUP', + }, +]; + type CliArgs = { apply: boolean; check: boolean; @@ -241,12 +340,54 @@ async function main(): Promise { const patch: AuthConfig = {}; for (const { key, to } of diff) patch[key] = to; + // Attach each secret, or withhold the whole feature. See SECRET_RULES (#622). + const withheld = new Set(); + for (const rule of SECRET_RULES) { + if (!rule.wanted(desired)) continue; + // Nothing from this feature is being changed — no secret needed. + if (!rule.fields.some((f) => f in patch)) continue; + + const secret = envOrDotenv(rule.envNames); + if (secret) { + patch[rule.secretField] = secret; + console.log( + ` ${rule.label}: attaching ${rule.secretField} from ${rule.envNames[0]} (…${secret.slice(-4)})` + ); + } else { + for (const f of rule.fields) { + delete patch[f]; + withheld.add(f); + } + console.warn( + `\n ⚠ ${rule.label}: WITHHELD — no ${rule.secretField} found in ${rule.envNames.join(' or ')}.\n` + + ` Applying it anyway would mean: ${rule.harm}.\n` + + ` Set the secret and re-run; these stay drifted until you do: ${rule.fields.join(', ')}` + ); + } + } + + if (Object.keys(patch).length === 0) { + console.error( + '\n✗ Every proposed change was withheld for a missing secret. Nothing applied.' + ); + process.exit(1); + } + console.log('Applying PATCH...'); await patchAuthConfig(projectRef, token, patch); console.log('Verifying...'); const after = await getAuthConfig(projectRef, token); - const residual = computeDiff(after, desired); + // Withheld fields are still drifted BY DESIGN — verifying them would fail on + // the gap this run deliberately refused to paper over. + const residual = computeDiff(after, desired).filter( + (r) => !withheld.has(r.key) + ); + if (withheld.size > 0) { + console.warn( + `\n⚠ ${withheld.size} field(s) left drifted for want of a secret: ${[...withheld].join(', ')}` + ); + } if (residual.length > 0) { console.error('✗ Verification FAILED — some fields did not stick:'); for (const r of residual) {