How can your Next.js Syntax affect the security of your application!
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 does not seems like a github one, return
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
- Fetch the info of account by the user ID and return the html_url (will become like
Add document to the
messages
collection on the databaseFire 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