Problem
Admin authentication is a single shared password sent as a plaintext x-admin-password header on every request, and the check is copy-pasted into six route files:
app/api/admin/posts/route.ts (checkAdminAuth)
app/api/admin/posts/[id]/route.ts
app/api/admin/posts/[id]/edit/route.ts
app/api/admin/posts/[id]/resend-token/route.ts
app/api/admin/comments/route.ts
app/api/newsletter/send/route.ts
Three concrete weaknesses:
- The comparison is
auth === expected, a non constant-time string compare.
- There is no rate limiting or lockout on the admin endpoints, even though the project already has
consumeSharedRateLimit in lib/rate-limit/shared.ts and uses it on public write routes. The password can be brute forced against GET /api/admin/posts with no friction.
- The client (
app/admin/page.tsx) keeps the raw password in React state and a ref (authedPasswordRef) and replays it on every fetch, so it is present in memory, in every request, and in any proxy or edge log that records headers.
Why it matters
These endpoints can approve, edit, reject, and delete arbitrary submissions, resend edit tokens, and trigger newsletter sends. That is the highest value authz boundary in the app and it is currently the weakest.
Suggested approach
- Add
lib/auth/admin.ts exporting a single requireAdmin(req) helper, and delete the six copies.
- Compare with a constant-time function (
crypto.timingSafeEqual over equal-length buffers, or hash both sides first).
- Add a
POST /api/admin/session login route that verifies the password once and sets a signed, HttpOnly, Secure, SameSite=Strict session cookie with an expiry. Routes then validate the cookie, not the password.
- Rate limit the login route through
consumeSharedRateLimit keyed on hashed IP, and log failures with logEvent at warn.
- Update
app/admin/page.tsx to stop storing the password and to add an explicit logout.
Done when
- No route file contains its own
checkAdminAuth.
- Password material is never sent on requests after login.
- Failed logins are rate limited and logged.
- Unit tests cover: valid login, invalid login, missing cookie, expired cookie, and rate limit trip.
If you want to take this on, comment on the issue to claim it and it will be assigned. Please keep to a maximum of 2 open claims per person at a time so other contributors get a chance.
Problem
Admin authentication is a single shared password sent as a plaintext
x-admin-passwordheader on every request, and the check is copy-pasted into six route files:app/api/admin/posts/route.ts(checkAdminAuth)app/api/admin/posts/[id]/route.tsapp/api/admin/posts/[id]/edit/route.tsapp/api/admin/posts/[id]/resend-token/route.tsapp/api/admin/comments/route.tsapp/api/newsletter/send/route.tsThree concrete weaknesses:
auth === expected, a non constant-time string compare.consumeSharedRateLimitinlib/rate-limit/shared.tsand uses it on public write routes. The password can be brute forced againstGET /api/admin/postswith no friction.app/admin/page.tsx) keeps the raw password in React state and a ref (authedPasswordRef) and replays it on every fetch, so it is present in memory, in every request, and in any proxy or edge log that records headers.Why it matters
These endpoints can approve, edit, reject, and delete arbitrary submissions, resend edit tokens, and trigger newsletter sends. That is the highest value authz boundary in the app and it is currently the weakest.
Suggested approach
lib/auth/admin.tsexporting a singlerequireAdmin(req)helper, and delete the six copies.crypto.timingSafeEqualover equal-length buffers, or hash both sides first).POST /api/admin/sessionlogin route that verifies the password once and sets a signed,HttpOnly,Secure,SameSite=Strictsession cookie with an expiry. Routes then validate the cookie, not the password.consumeSharedRateLimitkeyed on hashed IP, and log failures withlogEventatwarn.app/admin/page.tsxto stop storing the password and to add an explicit logout.Done when
checkAdminAuth.If you want to take this on, comment on the issue to claim it and it will be assigned. Please keep to a maximum of 2 open claims per person at a time so other contributors get a chance.