diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc24903..4887be8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,4 @@ jobs: JWT_SECRET: "ci-placeholder-jwt-secret-min-32-chars" DATABASE_URL: "postgresql://placeholder:placeholder@localhost:5432/placeholder" DIRECT_URL: "postgresql://placeholder:placeholder@localhost:5432/placeholder" + ADMIN_PASSWORD: "ci-placeholder-admin-password-min-8-chars" diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..aaa1d34 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,6 @@ +## 2024-06-04 - Fix Hardcoded Admin Credentials and Insecure Password Hashing +**Vulnerability:** The `ADMIN_PASSWORD` was hardcoded as a constant string in `packages/web/src/lib/server/admin-auth.ts`. In addition, simple strings or fast hashes (like `createHmac('sha256')`) were historically used or potentially vulnerable if used to check passwords, which could either be leaked via source code or become vulnerable to brute-forcing. +**Learning:** Hardcoding secrets exposes them directly in version control. Using fast HMACs or raw string comparison for passwords in Node.js exposes them to brute-forcing or timing attacks. The correct approach when comparing a password input to a stored/expected secret is to use a slow hash like `pbkdf2Sync` to mitigate brute-forcing, but because `pbkdf2Sync` is synchronous and blocks the event loop, pre-computing the target hash at module initialization prevents DoS vulnerabilities during individual HTTP requests. +**Prevention:** +1. Always store credentials in environment variables (e.g., using `.env` and parsing them with Zod in `env.ts`). +2. When performing secure password equality checks in Node.js against a known secret password, avoid using `createHmac` directly on passwords. Pre-compute the target hash with `pbkdf2Sync` at module load time. When verifying, hash the input using `pbkdf2Sync` and compare with `crypto.timingSafeEqual` to avoid timing attacks and mitigate event loop blocking. \ No newline at end of file diff --git a/packages/web/.env.example b/packages/web/.env.example index e99d51e..73368e6 100644 --- a/packages/web/.env.example +++ b/packages/web/.env.example @@ -4,3 +4,4 @@ AUTH_SECRET=replace-with-min-32-char-random-string DATABASE_URL="postgresql://postgres.[project]:[password]@aws-0-ap-northeast-1.pooler.supabase.com:6543/postgres?pgbouncer=true" DIRECT_URL="postgresql://postgres.[project]:[password]@db.uwxfseowdzuuepeeudrx.supabase.co:5432/postgres" JWT_SECRET="replace-with-32-char-minimum-random-string" +ADMIN_PASSWORD=replace-with-secure-admin-password diff --git a/packages/web/src/app/api/admin/login/route.ts b/packages/web/src/app/api/admin/login/route.ts index dbf5d2e..24fabdb 100644 --- a/packages/web/src/app/api/admin/login/route.ts +++ b/packages/web/src/app/api/admin/login/route.ts @@ -20,7 +20,7 @@ const AdminLoginSchema = z.object({ export async function POST(req: Request) { try { const input = AdminLoginSchema.parse(await req.json()) - if (!verifyAdminCredentials(input)) { + if (!(await verifyAdminCredentials(input))) { return NextResponse.json({ error: 'Invalid username or password' }, { status: 401 }) } diff --git a/packages/web/src/lib/server/admin-auth.ts b/packages/web/src/lib/server/admin-auth.ts index 5390fa4..d212f3e 100644 --- a/packages/web/src/lib/server/admin-auth.ts +++ b/packages/web/src/lib/server/admin-auth.ts @@ -1,13 +1,14 @@ import 'server-only' -import { createHmac, randomBytes, timingSafeEqual } from 'crypto' +import { createHmac, pbkdf2, pbkdf2Sync, randomBytes, timingSafeEqual } from 'crypto' import { cookies } from 'next/headers' +import { promisify } from 'util' import { NextRequest, NextResponse } from 'next/server' import { env } from './env' -export const ADMIN_USERNAME = 'admin' -export const ADMIN_PASSWORD = 'og9oRajx7h88v1RIj3eDgdrh9jgLYVV3' +export const ADMIN_USERNAME = env.ADMIN_USERNAME +export const ADMIN_PASSWORD = env.ADMIN_PASSWORD const ADMIN_SESSION_COOKIE = 'argos_admin_session' const ADMIN_SESSION_TTL_MS = 12 * 60 * 60 * 1000 @@ -20,18 +21,30 @@ function safeEqual(a: string, b: string): boolean { return timingSafeEqual(aHash, bHash) } +// Pre-compute the admin password hash at module initialization to avoid blocking the event loop +// during requests. A fixed salt derived from JWT_SECRET is used for consistency. +const PBKDF2_ITERATIONS = 100000 +const PBKDF2_KEYLEN = 64 +const PASSWORD_SALT = createHmac('sha256', env.JWT_SECRET).update('admin-password-salt').digest() +const ADMIN_PASSWORD_HASH = pbkdf2Sync(ADMIN_PASSWORD, PASSWORD_SALT, PBKDF2_ITERATIONS, PBKDF2_KEYLEN, 'sha512') + +const pbkdf2Async = promisify(pbkdf2) + +async function securePasswordEqual(inputPassword: string): Promise { + const inputHash = await pbkdf2Async(inputPassword, PASSWORD_SALT, PBKDF2_ITERATIONS, PBKDF2_KEYLEN, 'sha512') + return timingSafeEqual(inputHash as Buffer, ADMIN_PASSWORD_HASH) +} + function sign(payload: string): string { return createHmac('sha256', env.JWT_SECRET).update(payload).digest('base64url') } -export function verifyAdminCredentials(input: { +export async function verifyAdminCredentials(input: { username: string password: string -}): boolean { - return ( - safeEqual(input.username, ADMIN_USERNAME) && - safeEqual(input.password, ADMIN_PASSWORD) - ) +}): Promise { + if (!safeEqual(input.username, ADMIN_USERNAME)) return false + return securePasswordEqual(input.password) } export function createAdminSessionCookieValue(): string { diff --git a/packages/web/src/lib/server/env.ts b/packages/web/src/lib/server/env.ts index 86ed9a3..9be301c 100644 --- a/packages/web/src/lib/server/env.ts +++ b/packages/web/src/lib/server/env.ts @@ -5,6 +5,8 @@ const EnvSchema = z.object({ DATABASE_URL: z.string().min(1), DIRECT_URL: z.string().min(1), JWT_SECRET: z.string().min(32), + ADMIN_USERNAME: z.string().trim().min(1).default('admin'), + ADMIN_PASSWORD: z.string().trim().min(8), }) export const env = EnvSchema.parse(process.env)