How can your Next.js Syntax affect the security of your application!

Ashish Agarwal

Published on Sunday, Apr 28, 2024

TLDR: Learn how websites built with React can be hacked and how to protect yours!

Today, on discord, someone named jadfoq replied to one of my message with a screenshot of lot spam on my guestbook. I was shocked by this, he did like 40 messages for no reason.

I'll be telling you how I added a rate limit to each user so that no one can spam on my guestbook.

Cleaning up

No way, there is nothing we can do.

You already know that I use Firestore as a database for my guestbook. If you are using another one like MongoDB or something, then you have to do it yourself manually (or, you can write a script for this, but I actually cleaned it somehow).

My data was back to 4 messages now like this:

Thinking the fix

To prevent spam, banning or blocking a IP or a account from using my guestbook will not be the solution (espically I am not going to take your info).

What we need to do is to add security. Security includes this rate limit feature as well as things like:

  • Do less client-side requests to prevent tampering and getting hacked

  • Use more API routes to safely add, view and remove data from the server

  • Pass data from server to client, don't directly fetch sensitive data from client

  • Do most code on the server

I was not doing most of these things. For example, I was:

  • Fetching the github account username from client-side

  • Fetching all the firestore database from client-side (although its sensitive as we are already going to show it rendered)

  • Inserting new message/sign from client-side (browser extensions can tamper these requests mid-stream)

So what can we do? Let me show you my code quick!

"use client";

import { useState } from "react";
import firebaseApp from "../../../lib/firebase";
import { useSession, signIn, signOut } from "next-auth/react";
import { addDoc, collection, getFirestore } from "firebase/firestore";
import { AuthButton } from "./AuthButton";

export const Sign = ({ onSignSubmit }) => {
  onSignSubmit = onSignSubmit || (() => {});
  const { data: session } = useSession();
  const [message, setMessage] = useState("");
  const [loading, setLoading] = useState(false);

  const getGithubData = async () => {
    try {
      const imageUrl = session?.user?.image;
      if (!imageUrl.startsWith("https://avatars.githubusercontent.com/u/"))
        return "#";
      const regex = /\/u\/(\d+)\?/;
      const match = imageUrl.match(regex);
      const userID = match ? match[1] : null;
      const response = await fetch(`https://api.github.com/user/${userID}`);
      const data = await response.json();
      return data.html_url;
    } catch (error) {
      console.error("Error fetching GitHub data: " + error);
    }
  };

  const handleWriteMessage = async (e) => {
    e.preventDefault();
    try {
      const db = getFirestore(firebaseApp);
      const id = await getGithubData();
      const messageRef = collection(db, "messages");
      if (message.trim() !== "") {
        await addDoc(messageRef, {
          name: session?.user?.name,
          image: session?.user?.image,
          message: message,
          time: new Date().getTime(),
          github: id,
        });
        onSignSubmit({
          name: session?.user?.name,
          image: session?.user?.image,
          message: message,
          time: new Date().getTime(),
          github: id,
        });
        setMessage("");
      }
    } catch (error) {
      console.error("Error submitting: " + error);
    }
  };
  return (
    <>
      {session ? (
        {/* Sign input, button & Sign out */}
      ) : (
        {/* Sign in buttons */}
      )}
    </>
  );
};

What I'm doing step-by-step (when you click the sign button):

  • If your message after being trimmed is "", do nothing and stop.

  • Get github user profile from `getGithubData`

    • If your image does not seems like a github one, return # as the profile URL to not go anywhere.
    • If your image seems like a github one and match regex, get the github user ID from the avatar

      • Fetch the info of account by the user ID and return the html_url (will become like https://github.com/ashishagarwal2023
  • Add document to the messages collection on the database

  • Fire up the onSubmit function to show your new message without reloading.

Although these steps are not lot, but if you see, the rendering:

The owner ID is the github user ID of the owner, thats me, like a number.

If the message's image is like the owner's, then show Creator badge. What I'm trying to explain is that someone who tampered with the response could easily recall them as owner!

Let's get into the fix quick.

Fixing the bug

No, adding rate limit.

Adding Rate Limit

Define a API route at src/app/api/sign/route.ts` like this:

import { NextResponse } from "next/server";
import { addDoc, collection, getFirestore, query, where, getDocs } from "firebase/firestore";
import firebaseApp from "../../../lib/firebase";

const db = getFirestore(firebaseApp);
const messageRef = collection(db, "messages");

export async function POST(req) {
  try {
    const { name, image, message, id } = await req.json();
    if (!name || !image || !message || !id) {
      return NextResponse.json({
        success: false,
        error: "Missing required fields",
      }, { status: 400 });
    }

    const currentTime = new Date().getTime();
    const rateLimitDuration = 60 * 60 * 1000;
    const rateLimitCount = 3;
    const userMessagesQuery = query(messageRef, where("github", "==", id), where("time", ">=", currentTime - rateLimitDuration));
    const userMessagesSnapshot = await getDocs(userMessagesQuery);
    const userSignCount = userMessagesSnapshot.size;

    if (userSignCount >= rateLimitCount) {
      return NextResponse.json({
        success: false,
        error: "Rate limited",
      }, { status: 429 });
    }

    // Save the sign
    await addDoc(messageRef, {
      name: name,
      image: image,
      message: message,
      time: currentTime,
      github: id,
    });

    return NextResponse.json({ success: true }, { status: 200 });
  } catch (error) {
    console.error(error);
    return NextResponse.json({
      success: false,
      error: "Internal Server Error",
    }, { status: 500});
  }
}

Understand how I do some query to see your last recent 1 hour's messages. If they are 3, then we do not allow more messages/signs till next hour.

Next, the rest handling is done on the client-side (show messages, etc).

Conculsion

I told you some good concepts to protect your site. What you should remember finally is

  • Do all client tasks that are possible to be done on server on server-side

  • Make more API routes for most things

  • Don't make a component >200 lines

  • Make a lot components (maybe?)

And last,

  • Reuse components, as always!

Anyways, I added better security to my guestbook!

Have a look at my guestbook too, here: https://ashishagr.vercel.app/guestbook

Subscribe to newsletter

All the blog news straight to your box!

Sign up for our newsletter

You will receive a confirmation email and you may unsubscribe at any time.