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: "admin"
ADMIN_PASSWORD: "ci-placeholder-admin-password"
9 changes: 9 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## 2025-02-14 - Hardcoded Admin Credentials
**Vulnerability:** A critical security vulnerability was found in `packages/web/src/lib/server/admin-auth.ts` where the admin username (`admin`) and admin password (`og9oRajx7h88v1RIj3eDgdrh9jgLYVV3`) were hardcoded in the source code. This is dangerous because anyone with access to the source code repository can easily extract these secrets and compromise the admin interface.
**Learning:** Hardcoding credentials exposes sensitive data to unauthorized parties via source control systems. This practice circumvents the principle of least privilege.
**Prevention:** Always define secrets in environment configuration systems (e.g. `.env`) and use validation libraries like `zod` to securely import them during module initialization.

## 2025-02-14 - Insecure Password Hashing (js/insecure-password-hashing)
**Vulnerability:** A high severity CodeQL alert was triggered because passwords were hashed insecurely using fast HMAC functions before being compared via `timingSafeEqual`. This exposes the passwords to timing attacks or fast offline brute-forcing if the hashes are ever leaked or accessed.
**Learning:** Using simple or fast hash functions (like raw SHA256/HMAC) on passwords is an anti-pattern. Timing-safe equality checks using `crypto.timingSafeEqual()` still require the hash inputs to be generated by a strong, computationally expensive Key Derivation Function (KDF) like `pbkdf2` to resist brute-force attacks and prevent DoS vulnerabilities during comparison.
**Prevention:** Pre-compute target hashes at module initialization using synchronous `pbkdf2Sync`. When hashing incoming passwords within request handlers, use the asynchronous `crypto.pbkdf2` (via `util.promisify`) to prevent blocking the Node.js event loop and avoid DoS vulnerabilities.
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=change-this-to-a-secure-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
29 changes: 20 additions & 9 deletions packages/web/src/lib/server/admin-auth.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
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
const ADMIN_IMPERSONATION_TTL_MS = 60 * 1000
const ADMIN_IMPERSONATION_PREFIX = 'argos_imp'

const pbkdf2Async = promisify(pbkdf2)
const SALT = env.JWT_SECRET

// Pre-compute target hashes to avoid timing attacks on password length
const TARGET_USERNAME_HASH = pbkdf2Sync(ADMIN_USERNAME, SALT, 100000, 64, 'sha512')
const TARGET_PASSWORD_HASH = pbkdf2Sync(ADMIN_PASSWORD, SALT, 100000, 64, 'sha512')

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 +32,17 @@ 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 inputUsernameHash = await pbkdf2Async(input.username, SALT, 100000, 64, 'sha512')
const inputPasswordHash = await pbkdf2Async(input.password, SALT, 100000, 64, 'sha512')

const isUsernameCorrect = timingSafeEqual(inputUsernameHash, TARGET_USERNAME_HASH)
const isPasswordCorrect = timingSafeEqual(inputPasswordHash, TARGET_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().trim().min(8),
})

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