diff --git a/deploy/validate-staging-database-env.sh b/deploy/validate-staging-database-env.sh index e14ece9c..1fd056d1 100755 --- a/deploy/validate-staging-database-env.sh +++ b/deploy/validate-staging-database-env.sh @@ -43,7 +43,7 @@ fi definition_count() { local key="$1" - grep -Ec "^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=" "$ENV_FILE" || true + grep -Ec "^[[:space:]]*(export[[:space:]]+)?${key}([[:space:]]*=|[[:space:]]*$)" "$ENV_FILE" || true } environment_value() { @@ -51,13 +51,20 @@ environment_value() { sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=[[:space:]]*(.*)$/\\2/p" "$ENV_FILE" } +is_safe_literal() { + local value="$1" + # Required values are generated as single-line hex-safe literals. This also + # permits percent-encoded schema URLs without evaluating dotenv syntax. + [[ "$value" =~ ^[A-Za-z0-9._~%:@/+,-]+$ ]] +} + require_once_non_empty() { local key="$1" local count local value count="$(definition_count "$key")" value="$(environment_value "$key")" - if [ "$count" -ne 1 ] || [ -z "$value" ]; then + if [ "$count" -ne 1 ] || ! is_safe_literal "$value"; then echo "required staging database environment variable is missing or duplicated: $key" >&2 exit 1 fi diff --git a/frontend/tests/database-env-validator.test.ts b/frontend/tests/database-env-validator.test.ts index 290ac8ba..fbb1959b 100644 --- a/frontend/tests/database-env-validator.test.ts +++ b/frontend/tests/database-env-validator.test.ts @@ -25,7 +25,7 @@ const validEnvironment = [ "MIGRATION_RUNNER_PASSWORD=migration-runner-test-password", "BACKUP_READER_PASSWORD=backup-reader-test-password", "STAGING_BACKUP_ENCRYPTION_KEY=staging-backup-test-password", - "SCHEMA_DATABASE_URL=postgresql://schema_owner:schema-owner-test-password@postgres:5432/jyotisha", + "SCHEMA_DATABASE_URL=postgresql://schema_owner:schema-owner%2Dtest-password@postgres:5432/jyotisha", ]; test("database env validator rejects Compose-compatible duplicate selectors without printing values", () => { @@ -83,6 +83,60 @@ test("database env validator rejects Compose-compatible duplicate selectors with } }); +test("database env validator rejects bare Compose-compatible duplicate definitions", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-")); + const envFile = join(root, ".env.staging.database"); + + try { + writeFileSync( + envFile, + `${[...validEnvironment, " POSTGRES_DB"].join("\n")}\n`, + { mode: 0o600 }, + ); + chmodSync(envFile, 0o600); + + const result = spawnSync("bash", [validator, envFile], { encoding: "utf8" }); + assert.notEqual(result.status, 0); + assert.doesNotMatch( + `${result.stdout}${result.stderr}`, + /postgres-test-password|schema-owner-test-password|staging-backup-test-password/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("database env validator rejects quoted and interpolated required secrets", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-")); + const envFile = join(root, ".env.staging.database"); + + try { + for (const password of ['""', "''", "${UNSET}"]) { + writeFileSync( + envFile, + `${validEnvironment + .map((line) => + line.startsWith("POSTGRES_PASSWORD=") + ? `POSTGRES_PASSWORD=${password}` + : line, + ) + .join("\n")}\n`, + { mode: 0o600 }, + ); + chmodSync(envFile, 0o600); + + const result = spawnSync("bash", [validator, envFile], { encoding: "utf8" }); + assert.notEqual(result.status, 0, password); + assert.doesNotMatch( + `${result.stdout}${result.stderr}`, + /postgres-test-password|schema-owner-test-password|staging-backup-test-password/, + ); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("database env validator rejects symlinks and unsafe modes", () => { const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-")); const envFile = join(root, ".env.staging.database");