Browser Devtools: The ultimate problem and solution?

Share on Twitter

If you are a web developer there is a 1 in 1 chance that you use the browser devtools. They are useful for debugging the 2MB of JS or 10MB of WASM you feed to it, but did you know they could create security vulnerabilities? pff yeah I am sure you do…


right??

What do I mean by “Security Vulnerabilities” ?

I think we all know the old saying to never trust the client. It could go both ways though, its either your client not paying you, or its your client trying to manipulate your app with malicious intents. I am taking about the latter. If you’re client is not paying the bills, just shoot him I mean, politely ask him.

Where do the devtools come in?

So here is the thing, lets say I have this simple React app which renders this <form>:

<form action={newEntry}>
  <label htmlFor="enable-backups">Enable Backups?</label>
  <input type="checkbox" name="enable-backups" id="enable-backups" />
  <label htmlFor="email">Account Email</label>
  <input type="checkbox" name="email" id="email" />
  {state.message && <p className="res">{state.message}</p>}
  {state.error && <p className="err">{state.error}</p>}
</form>

Now here is our server action.

async function newEntry(formData: FormData) {
  "use server";
  const email = formData.get("email");
  const enabledBackups = formData.get("enable-backups");

  if (!email || !enabledBackups) state = { message: "", error: "Email or backups missing" };
  // ...
}

But have you ever noticed, when you call formData.get(), it doesn’t return a string | null, instead it returns this FormDataEntryValue | null union. Lets take a look at the type definitions for the native APIs:

type FormDataEntryValue = File | string;

Here is the thing, the Native API definitions give you hints about the format of the data, this is important, do not ignore this FormDataEntryValue returned from form.get.

Obviously files can be uploaded from the browser, sent to the server, and then the server can just use it. So that is why every single time you call the get function, it returns that union. And… really anything can be a file.

You may question, “What do you mean? I have my email input set to type='email', and… this is where the devtools come in. I can obviously open the devtools, and ya know, change the type from email to maybe text? And then pass gibberish like “thisisnotanemail”. Now your server will throw once it adds the user to the db and is unable to find it.

The same argument can be made about the FormDataEntryValue, It returns string | File because I can just as easily change the type to file in the devtools and then upload it there. Then what is going to happen to your database? It doesn’t make sense the File object is correctly put in the database.

How it happened to YouTube

Around 2 years ago, a YouTube user found that the YouTube channel creation didn’t do server-side validation. Its input minlength in the HTML (Client) was set to 50 characters. He went into the devtools and removed the minlength. And well, he successfully got a username that was over 50 chars.

And they actually didn’t add any validation until weeks later but till then, it was too late and JackSucksAtLife already made a channel, and he even tried to get a playbutton.

So uh, yeah, all of this could have been fixed if they just added one more if-statment.

So, whats the solution?

validate.

All you need is a schema validation library. Personally I like to use Zod, but you can also use any other like Yup.

Here is some validation code I used for my project:

// The schema
export const loginSchema = z.object({
  email: z.string().email("Invalid email."),
  password: z.string().nonempty("Password is required."),
});

How I used the schema. I created a helper function that takes the input as a generic<T> and then return it. Even though zod does almost the same thing with its .safeParse method, I am using this special formatSchemaErrors function to format the zod issues object so the frontend can display errors for correct fields

export function validateLoginSchema<T>(loginData: T): AuthValidationResult<"login"> {
  const loginValidationResult = loginSchema.safeParse(loginData);

  if (loginValidationResult.success) return loginValidationResult;

  const errorResponse = formatSchemaErrors(loginValidationResult.error);

  return { success: false, errors: errorResponse };
}

Here is the formatSchemaErrors() function:

export default function formatSchemaErrors(error: ZodError) {
  return error.issues.reduce(
    (res, error) => {
      const {
        path: [path],
        message,
      } = error;

      if (path && typeof path === "string") res[path] = message;
      return res;
    },
    {} as Record<string, string>,
  );
}

What it basically does it take input the ZodError returned by .safeParse and transforms them into this format:

{
  "<field>": "<error-message>"
}

So if the email was invalid, it would return something like:

{
  "email": "Invalid email."
}

Notice how in this example, we specifically check with zod, whether the email is in a valid shape of an email which is name@domain.tld

Conclusion

Even though client-side validations can help, please for the love of god do server-side validation that is more complex than 2 if-statements. It isn’t that hard, like you saw, so there is no reason other than carelessness to not use it. Just make sure you are building secure software for everyone to use.