Real World Code Review Vulnerability in a Next.js App

I recently worked on a Next.js codebase that had a vulnerability in an API endpoint called update-profile that would allow any authenticated user to modify the details for any other users in the database.

I've removed some code to make it more concise.

The relevant portions of the stack are Next.js, NextAuth, Postgres, and Prisma ORM.

The purpose of this API endpoint is to update an existing user's profile.

On the front-end an authenticated user has a form with an email field and a name field. They can fill out each field inline and then click save. Once saved it calls this endpoint.

Here's a rundown of what the code does:

  1. Checks that the data submitted in the POST matches the format of the data expected using Zod (my favorite JS validator). If not, it sends a 400 response with an error message.
  2. Checks that the request came from an authorized session via getServerAuthSession(). If not authenticated it sends a 400 response and error message.
  3. If we pass those checks then we go ahead and update the database with the new information.

Can you spot the vulnerability? It's subtle. I don't think I would have noticed it had I not been actually making edits to the file and running the code.

export const updateProfileInput = z.object({
  userId: z.string(),
  name: z.string().optional(),
  email: z.string().email().optional(),
});

const updateProfile = async (req: NextApiRequest, res: NextApiResponse) => {
  // Some code has been removed for clarity

  const validInput = updateProfileInput.safeParse(req.body);

  if (!validInput.success) {
    return res.status(400).send({ success: false, message: 'Incorrect data' });
  }

  const session = await getServerAuthSession({ req, res });

  if (!session) {
    return res.status(400).send({
      success: false,
      message: 'You must be signed in.',
    });
  }

  const user = await prisma.user.update({
    where: {
      id: validInput.data.userId,
    },
    data: {
      name: validInput.data.name,
      email: validInput.data.email,
    },
  });

  return res.status(200).send({
    success: true,
    message: 'success',
    data: {
      user: {
        name: user.name,
        email: user.email,
      },
    },
  });
  // Some code has been removed for clarity
};

export default updateProfile;

Let me narrow this down a bit. Here are the 2 pieces of code that work together to make this security issue happen:

export const updateProfileInput = z.object({
  userId: z.string(), // Issue 1
  name: z.string().optional(),
  email: z.string().email().optional(),
});

// additional code here...

const user = await prisma.user.update({
  where: {
    id: validInput.data.userId, // Issue 2
  },
  data: {
    name: validInput.data.name,
    email: validInput.data.email,
  },
});

The userId received from the POST request is a string, which will basically accept anything, and is untrusted data (IE it can be modified by any user with the knowledge). We then use that data to search for a user based on that userId and update the email or name.

An authenticated user could modify their request to be a different user's id and then change their email, thus allowing for things like a password reset sent to a different email address and then resetting the password to take over the account.

This particular issue tends to be difficult for automated tools to catch because most of the time it wouldn't work. But if you know just a little bit about how the application works, it's possible to steal accounts.

So what is the fix?

Rule of thumb

Endpoints that require authentication shouldn't expect User IDs (or Account IDs) from the client.

This is because you can get the User ID from the authenticated session validation that happens on the server-side endpoint.

Here's what that would look like in this project:

export const updateProfileInput = z.object({
  // Fix: removed userId from client
  name: z.string().optional(),
  email: z.string().email().optional(),
});

const updateProfile = async (req: NextApiRequest, res: NextApiResponse) => {
  // Some code has been removed for clarity

  const validInput = updateProfileInput.safeParse(req.body);

  if (!validInput.success) {
    return res.status(400).send({ success: false, message: 'Incorrect data' });
  }

  const session = await getServerAuthSession({ req, res });

  if (!session) {
    return res.status(400).send({
      success: false,
      message: 'You must be signed in.',
    });
  }

  const user = await prisma.user.update({
    where: {
      id: session.user.id, // Fix: using user id from authenticated session
    },
    data: {
      name: validInput.data.name,
      email: validInput.data.email,
    },
  });

  return res.status(200).send({
    success: true,
    message: 'success',
    data: {
      user: {
        name: user.name,
        email: user.email,
      },
    },
  });
  // Some code has been removed for clarity
};

export default updateProfile;

Additional Security Layers

Security needs to happen in layers. We all know that...right?

Use UUIDs

It's always good to make any user or account IDs sent to the client difficult to guess. So instead of using incremental IDs for the user, use a UUID. Using UUIDs, or something similar, allows you to make a vulnerability like this harder to actually pull off. Additionally you make it much more difficult to accidentally leak data from the DB through your endpoints.

Add rate limits

Limit the number of requests an endpoint can take from a particular IP address or session. Doing so prevents an attacker from using automated tools to brute force your endpoints easily.

Use an API gateway

An API gateway helps you in 2 main areas.

Separate security contexts
Platforms like Next.js are great, but mashing the client-side JS and server-side JS code into the same codebase can make it difficult to keep the security contexts separate making it easier to accidentally introduce a vulnerability that lingers for years and years.

Separating out the client-side and server-side code bases help clarify what security context a developer is working and cuts down on security issues.

Hand off the hard stuff
Features like rate limiting, auth, logging, security roles, and load balancing are significantly different than building a registration form, managing state, or handling UI/UX.

Hand that off to a platform that is engineered to handle these unique challenges.