Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 6 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion packages/web/src/app/api/admin/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}

Expand Down
31 changes: 22 additions & 9 deletions packages/web/src/lib/server/admin-auth.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<boolean> {
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<boolean> {
if (!safeEqual(input.username, ADMIN_USERNAME)) return false
return securePasswordEqual(input.password)
}

export function createAdminSessionCookieValue(): string {
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/lib/server/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading