securitynext.jsreact-server-componentsserver-action

How secure are NextJs Server Actions?


Can someone please explain me how do Server Actions work under the hood? In the following example, i first check if the viewer has access to the account and then create the Action. This works fine in a browser. But what happens if someone forges a request that fakes the 'submit' call? Do i need to do another check inside the Action? If i add another 'if' in the Action, TypeScript tells me that the variable is always true at that point and i don't need to worry about it. Is this true or is TypeScript trying to fool me? Documentation says that we need to check if the user has rights to perform the action but the example is outside of components. Does it work the same in Server Components?

async function DeleteUser({ targetUser }: Props) {
  if (!hasViewerAccess(targetUser)) return "403 forbidden";

  const submit = async () => {
    "use server";

    deleteUser(targetUser);
  };

  return (
    <form action={submit}>
      <input type="submit" value="Delete" />
    </form>
  );
}

Solution

  • Short answer: you always need to check the access inside Server Actions.

    First we need to understand how Server Action works. NextJs takes your function and turns it to an API route handler. Even though it seems to be inside your component it actually isn't and it's static — not creating for every clint separately. Let's look at an example:

    (Unsafe code)

    async function DeleteEverything() {
      if (!hasViewerAccess()) return
        
      const submit = async () => {
        "use server";
    
        dropDatabase();
      }
    
      return (
        <form action={submit}>
          <input type="submit" value="Delete" />
        </form>
      );
    }
    

    It's an equivalent to

    // /api/deleteEverything/route.ts
    export async function POST() {
      dropDatabase();
     
      return Response.json({ ok: true })
    }
    
    async function DeleteEverything() {
      if (!hasViewerAccess()) return
        
      const submit = async () => {
        await fetch('/api/deleteEverything', { method: "POST" });
      }
    
      return (
        <form>
          <input type="submit" value="Delete" onClick={submit} />
        </form>
      );
    }
    

    Everyone can just send the post request!

    But if we use scope variables, things change a little. Lets look at another example:

    (Unsafe code)

    async function ChangeUserPassword({ targetUser }: Props) {
      if (!hasViewerAccess(targetUser)) return "403 forbidden";
    
      const submit = async (data: FormData) => {
        "use server";
    
        changeUserPassword(targetUser, data.get('password'));
      };
    
      return (
        <form action={submit}>
          <input type="password" name="password" />
          <input type="submit" value="Delete" />
        </form>
      );
    }
    

    We're using the scope variable targetUser. To make this possible, NextJs adds a hidden input to your form with that value but most importantly it encrypts and signs the value, ensuring that the field value was set by the server and has not been modified. In this case it means that the targetUser variable cannot be modified. This code is still unsafe though. For instance, if the viewer currently has access and modifies the user's password, saves the request payload, and then subsequently loses access, they can still send the same request with a different password and it will still be processed.

    Even if we perform an irreversible change (like deleting a user, as in the original question) that cannot be performed more than once, there is still a possibility that the viewer may save the encrypted variables but not make the request immediately. They may make the request later, after they lose access.

    In conclusion there is no case where you can omit the access check.