diff --git a/.env b/.env index 9852a1b..4618290 100644 --- a/.env +++ b/.env @@ -7,6 +7,7 @@ DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@postgres:5432/skym BACKUP_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5432/skymoney RESTORE_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5432/skymoney_restore_test ADMIN_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5432/postgres +TEST_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@postgres:5432/skymoney_test APP_ORIGIN=https://skymoneybudget.com CORS_ORIGINS=https://skymoneybudget.com diff --git a/.env.example b/.env.example index 5c2d091..8de0913 100644 --- a/.env.example +++ b/.env.example @@ -19,10 +19,13 @@ DATABASE_URL=postgres://skymoney_app:change-me@postgres:5432/skymoney BACKUP_DATABASE_URL=postgres://skymoney_app:change-me@127.0.0.1:5432/skymoney RESTORE_DATABASE_URL=postgres://skymoney_app:change-me@127.0.0.1:5432/skymoney_restore_test ADMIN_DATABASE_URL=postgres://postgres:change-me@127.0.0.1:5432/postgres +TEST_DATABASE_URL=postgres://skymoney_app:change-me@127.0.0.1:5432/skymoney_test EXPECTED_PROD_DB_HOST=postgres EXPECTED_PROD_DB_NAME=skymoney EXPECTED_BACKUP_DB_HOST=127.0.0.1 EXPECTED_BACKUP_DB_NAME=skymoney +PROTECTED_DB_NAMES=skymoney,postgres,template0,template1 +REQUIRE_TEST_DB_NAME=1 PROD_DB_VOLUME_NAME=skymoney_pgdata ALLOW_EMPTY_PROD_VOLUME=0 ARCHIVE_EXISTING_RESTORE_DB=1 diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml index 84e7969..d9cf2a1 100644 --- a/.gitea/workflows/security.yml +++ b/.gitea/workflows/security.yml @@ -46,9 +46,21 @@ jobs: cd api npm ci + - name: Guard TEST_DATABASE_URL target + env: + TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} + EXPECTED_PROD_DB_NAME: skymoney + PROTECTED_DB_NAMES: skymoney,postgres,template0,template1 + REQUIRE_TEST_DB_NAME: "1" + run: | + chmod +x ./scripts/validate-test-db-target.sh + bash ./scripts/validate-test-db-target.sh + - name: Run OWASP security suite (DB-backed) env: TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} + PROTECTED_DB_NAMES: skymoney,postgres,template0,template1 + REQUIRE_TEST_DB_NAME: "1" run: | cd api SECURITY_DB_TESTS=1 npx vitest run -c vitest.security.config.ts diff --git a/api/tests/setup.ts b/api/tests/setup.ts index 6c7cb19..aac34f5 100644 --- a/api/tests/setup.ts +++ b/api/tests/setup.ts @@ -30,11 +30,48 @@ function resolveDatabaseUrl(): string { if (dbUrl) return dbUrl.replace("@postgres:", "@127.0.0.1:"); } - return "postgres://app:app@127.0.0.1:5432/skymoney"; + return "postgres://app:app@127.0.0.1:5432/skymoney_test"; +} + +function parseDbName(url: string): string { + const parsed = new URL(url); + const dbName = parsed.pathname.replace(/^\/+/, ""); + if (!dbName) throw new Error(`DATABASE_URL has no database name: ${url}`); + return dbName; +} + +function assertSafeDbTarget(url: string): void { + const requireTestDbName = process.env.REQUIRE_TEST_DB_NAME === "1"; + const protectedNamesRaw = + process.env.PROTECTED_DB_NAMES ?? + process.env.EXPECTED_PROD_DB_NAME ?? + "skymoney,postgres,template0,template1"; + const protectedNames = new Set( + protectedNamesRaw + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + ); + const dbName = parseDbName(url); + + if (protectedNames.has(dbName)) { + throw new Error( + `Refusing to run DB tests against protected database '${dbName}'. ` + + "Set TEST_DATABASE_URL to a dedicated test database." + ); + } + + if (requireTestDbName && !/(test|ci|sandbox|staging|shadow|tmp)/i.test(dbName)) { + throw new Error( + `Refusing to run DB tests against '${dbName}' because it does not look like a test database. ` + + "Set REQUIRE_TEST_DB_NAME=0 only for intentional local exceptions." + ); + } } process.env.NODE_ENV = process.env.NODE_ENV || "test"; process.env.DATABASE_URL = resolveDatabaseUrl(); +assertSafeDbTarget(process.env.DATABASE_URL); process.env.PORT = process.env.PORT || "8081"; process.env.HOST ??= "127.0.0.1"; process.env.CORS_ORIGIN = process.env.CORS_ORIGIN || ""; diff --git a/api/tests/software-supply-chain-failures.test.ts b/api/tests/software-supply-chain-failures.test.ts index f74a3a3..a4cc6f0 100644 --- a/api/tests/software-supply-chain-failures.test.ts +++ b/api/tests/software-supply-chain-failures.test.ts @@ -31,4 +31,16 @@ describe("A03 Software Supply Chain Failures", () => { expect(deployWorkflow).toMatch(/uses:\s*actions\/checkout@v\d+\.\d+\.\d+/); }); + + it("guards DB-backed security tests from targeting production database", () => { + const repoRoot = resolve(__dirname, "..", ".."); + const securityWorkflow = readFileSync( + resolve(repoRoot, ".gitea/workflows/security.yml"), + "utf8" + ); + + expect(securityWorkflow).toContain("name: Guard TEST_DATABASE_URL target"); + expect(securityWorkflow).toContain("bash ./scripts/validate-test-db-target.sh"); + expect(securityWorkflow).toContain("TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}"); + }); }); diff --git a/api/vitest.security.config.ts b/api/vitest.security.config.ts index 20b9179..225c578 100644 --- a/api/vitest.security.config.ts +++ b/api/vitest.security.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ setupFiles: dbSecurityTestsEnabled ? ["tests/setup.ts"] : [], env: { NODE_ENV: "test", - DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney", + DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney_test", AUTH_DISABLED: "1", SEED_DEFAULT_BUDGET: "1", JWT_SECRET: "test-jwt-secret-32-chars-min-abcdef", diff --git a/docs/production-db-recovery-runbook.md b/docs/production-db-recovery-runbook.md index 5ff05f4..d8d219f 100644 --- a/docs/production-db-recovery-runbook.md +++ b/docs/production-db-recovery-runbook.md @@ -1,6 +1,6 @@ # Production DB Recovery and Safety Runbook -Last updated: March 2, 2026 +Last updated: March 10, 2026 ## Purpose @@ -191,6 +191,8 @@ psql "postgres://:@127.0.0.1:5432/skymoney" \ 4. Deploy workflow runs `scripts/guard-prod-volume.sh` and blocks deploy when prod volume is missing/empty. 5. Deploy workflow runs pre-migration `scripts/backup.sh`. 6. Deploy workflow uses `prisma migrate deploy` only. +7. Security DB workflow runs `scripts/validate-test-db-target.sh` and refuses protected DB names (`skymoney`, `postgres`, `template*`). +8. DB-backed test runtime (`api/tests/setup.ts`) refuses protected DB targets before any `deleteMany` cleanup runs. ### Intentional rebuild override diff --git a/scripts/validate-test-db-target.sh b/scripts/validate-test-db-target.sh new file mode 100644 index 0000000..0e834cc --- /dev/null +++ b/scripts/validate-test-db-target.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -z "${TEST_DATABASE_URL:-}" ]]; then + echo "TEST_DATABASE_URL is required." + exit 1 +fi + +EXPECTED_PROD_DB_NAME="${EXPECTED_PROD_DB_NAME:-skymoney}" +PROTECTED_DB_NAMES="${PROTECTED_DB_NAMES:-$EXPECTED_PROD_DB_NAME,postgres,template0,template1}" +REQUIRE_TEST_DB_NAME="${REQUIRE_TEST_DB_NAME:-1}" + +extract_db() { + local url="$1" + sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://[^/]+/([^?]+).*$#\1#' <<< "$url" +} + +TEST_DB_NAME="$(extract_db "$TEST_DATABASE_URL")" +if [[ "$TEST_DB_NAME" == "$TEST_DATABASE_URL" || -z "$TEST_DB_NAME" ]]; then + echo "Unable to parse TEST_DATABASE_URL database name." + exit 1 +fi + +if [[ -n "${DATABASE_URL:-}" && "$TEST_DATABASE_URL" == "$DATABASE_URL" ]]; then + echo "TEST_DATABASE_URL must not equal DATABASE_URL." + exit 1 +fi + +IFS=',' read -r -a protected <<< "$PROTECTED_DB_NAMES" +for name in "${protected[@]}"; do + trimmed="$(echo "$name" | xargs)" + if [[ -n "$trimmed" && "$TEST_DB_NAME" == "$trimmed" ]]; then + echo "Refusing to run DB security tests against protected database '$TEST_DB_NAME'." + echo "Set TEST_DATABASE_URL to a dedicated test database (for example: skymoney_test)." + exit 1 + fi +done + +if [[ "$REQUIRE_TEST_DB_NAME" == "1" ]]; then + if ! [[ "$TEST_DB_NAME" =~ (test|ci|sandbox|staging|shadow|tmp) ]]; then + echo "Refusing TEST_DATABASE_URL db '$TEST_DB_NAME': name must include test/ci/sandbox/staging/shadow/tmp." + echo "If intentional, set REQUIRE_TEST_DB_NAME=0 for this run." + exit 1 + fi +fi + +echo "TEST_DATABASE_URL target check passed (db=$TEST_DB_NAME)." diff --git a/tests-results-for-OWASP/post-deployment-verification-checklist.md b/tests-results-for-OWASP/post-deployment-verification-checklist.md index bf6002a..1c9edae 100644 --- a/tests-results-for-OWASP/post-deployment-verification-checklist.md +++ b/tests-results-for-OWASP/post-deployment-verification-checklist.md @@ -11,6 +11,8 @@ Use this after every deploy (staging and production). - `ALLOW_INSECURE_AUTH_FOR_DEV=false` 4. Test DB preflight (for DB-backed suites): - `TEST_DATABASE_URL` points to a reachable PostgreSQL instance. +- `TEST_DATABASE_URL` database name is not `skymoney` and is clearly test-only (for example `skymoney_test`). +- `bash ./scripts/validate-test-db-target.sh` passes before DB-backed suites run. - Example quick check: ```bash echo "$TEST_DATABASE_URL"