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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ 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_USERNAME: "ci-admin"
ADMIN_PASSWORD: "ci-placeholder-password"
9 changes: 9 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## 2024-06-11 - [CRITICAL] Fix hardcoded admin credentials
**Vulnerability:** Hardcoded admin credentials (`ADMIN_USERNAME` and `ADMIN_PASSWORD`) were found in `packages/web/src/lib/server/admin-auth.ts`.
**Learning:** Hardcoding credentials in source control can lead to unauthorized access if the repository is compromised or visible. The vulnerability was present in the auth logic, exposing the admin system. Moving credentials to environment variables (`.env`) via a schema validation tool (like Zod in `env.ts`) mitigates this issue while preserving API boundaries by exporting the env variables instead of hardcoded strings.
**Prevention:** Always use environment variables for sensitive credentials. Enforce strict checks for hardcoded strings resembling secrets during code review, and ensure environment variables are clearly defined with validation (e.g., Zod `min(1)` or similar length constraints) to prevent bypasses.

## 2024-06-12 - [CRITICAL] Fix insecure password hashing (CodeQL Alert)
**Vulnerability:** The initial fix used `createHmac` for `ADMIN_PASSWORD`, which triggered a CodeQL security alert for insecure password hashing (`js/insecure-password-hashing`). HMAC algorithms like SHA-256 are too fast and not designed for password hashing, making them susceptible to brute-force and dictionary attacks if the hashes leak or are targeted via timing attacks.
**Learning:** For password-like secrets, fast cryptographic functions or standard HMACs are insufficient. The `pbkdf2Sync` algorithm is required to generate a key securely at initialization, and `pbkdf2` via `util.promisify` should be used asynchronously to verify credentials without blocking the Node.js event loop (preventing DoS).
**Prevention:** Avoid using passwords directly as HMAC keys or hashing them with fast algorithms. Always use established cryptographic key derivation functions like `pbkdf2` with appropriate iteration counts (e.g., 100,000) for verifying secrets or passwords against stored hashes.
2 changes: 2 additions & 0 deletions packages/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ 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_USERNAME=admin
ADMIN_PASSWORD=replace-with-secure-admin-password
3 changes: 2 additions & 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,8 @@ const AdminLoginSchema = z.object({
export async function POST(req: Request) {
try {
const input = AdminLoginSchema.parse(await req.json())
if (!verifyAdminCredentials(input)) {
const isValid = await verifyAdminCredentials(input)
if (!isValid) {
return NextResponse.json({ error: 'Invalid username or password' }, { status: 401 })
}

Expand Down
34 changes: 25 additions & 9 deletions packages/web/src/lib/server/admin-auth.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
import 'server-only'

import { createHmac, randomBytes, timingSafeEqual } from 'crypto'
import { createHmac, randomBytes, timingSafeEqual, pbkdf2Sync, pbkdf2 } from 'crypto'
import { promisify } from 'util'
import { cookies } from 'next/headers'
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
const ADMIN_IMPERSONATION_TTL_MS = 60 * 1000
const ADMIN_IMPERSONATION_PREFIX = 'argos_imp'

// Pre-compute expected hashes at module initialization
const HASH_ITERATIONS = 100000
const HASH_KEYLEN = 64
const HASH_DIGEST = 'sha512'
// We use JWT_SECRET as a constant salt for the predefined credentials
const EXPECTED_USERNAME_HASH = pbkdf2Sync(ADMIN_USERNAME, env.JWT_SECRET, HASH_ITERATIONS, HASH_KEYLEN, HASH_DIGEST)
const EXPECTED_PASSWORD_HASH = pbkdf2Sync(ADMIN_PASSWORD, env.JWT_SECRET, HASH_ITERATIONS, HASH_KEYLEN, HASH_DIGEST)

const pbkdf2Async = promisify(pbkdf2)

function safeEqual(a: string, b: string): boolean {
const aHash = createHmac('sha256', env.JWT_SECRET).update(a).digest()
const bHash = createHmac('sha256', env.JWT_SECRET).update(b).digest()
Expand All @@ -24,14 +35,19 @@ 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> {
const [usernameHash, passwordHash] = await Promise.all([
pbkdf2Async(input.username, env.JWT_SECRET, HASH_ITERATIONS, HASH_KEYLEN, HASH_DIGEST),
pbkdf2Async(input.password, env.JWT_SECRET, HASH_ITERATIONS, HASH_KEYLEN, HASH_DIGEST)
])

const isUsernameCorrect = timingSafeEqual(usernameHash, EXPECTED_USERNAME_HASH)
const isPasswordCorrect = timingSafeEqual(passwordHash, EXPECTED_PASSWORD_HASH)

return isUsernameCorrect && isPasswordCorrect
}

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().min(1),
ADMIN_PASSWORD: z.string().min(1),
})

export const env = EnvSchema.parse(process.env)
Loading