One of the most notorious hacks happened to a well-known fast-food chain’s mobile app. The story goes that the app had a promotion offering free meals for customers who downloaded it.
However, a clever hacker discovered a loophole in the app’s frontend code that allowed them to modify the promotional offer.
Instead of just getting one free meal, the hacker could generate an unlimited number of free meal vouchers.
They shared this “trick” on social media, and soon, thousands of people were generating endless free meal coupons.
The situation quickly spiraled out of control. People were lining up at the restaurants with dozens of free meal vouchers, much to the confusion and frustration of the staff.
The company’s backend systems were not designed to handle such a flood of coupon redemptions, and the promotion became a chaotic mess.
The company eventually had to shut down the app temporarily and issue an apology. While they were undoubtedly frustrated by the breach, the incident became a memorable lesson in the importance of a secure frontend and robust backend verification.
This article both explains the common vulnerabilities and provides practical solutions with code examples.
1. Cross-Site Scripting (XSS)
Understanding and Preventing XSS Attacks
-
Injection of Malicious Scripts: Attackers exploit user inputs that aren’t properly sanitized, inserting scripts into web pages. For instance, a script entered in a blog comment field could be stored and rendered in every visitor’s browser.
-
Execution in User Context: The script executes within the browser of any user visiting the compromised page, acting under the user’s session.
-
Stealing Information: Such scripts can access sensitive data like cookies or session tokens, leading to potential session hijacking.
-
Manipulating DOM: The attack can alter the webpage’s Document Object Model (DOM), leading to website defacement, phishing, or redirects to malicious sites.
Types of XSS Attacks
-
Stored XSS: The malicious script is stored on the server and delivered to all users viewing the infected page.
-
Reflected XSS: The script is reflected from a web server, typically via a malicious link.
-
DOM-based XSS: The payload executes by modifying the DOM in the client-side environment, like a web browser, without altering the server response.
Framework Vulnerabilities
Even frameworks like React, Vue, and Angular, which take measures against XSS (e.g., escaping HTML), can be vulnerable due to improper feature use, third-party libraries, or non-standard implementations.
Sanitizing User Input in React
import React, { useState } from 'react';
import DOMPurify from 'dompurify';
const UserComments = () => {
const [comments, setComments] = useState([
// Assume these comments are fetched from a server
{ id: 1, content: '<script>alert("XSS Attack!")</script>Great article!' },
{ id: 2, content: 'Really enjoyed this post.' },
// More comments...
]);
const createMarkup = (htmlContent) => {
return { __html: DOMPurify.sanitize(htmlContent) };
};
return (
<div>
<h2>User Comments</h2>
<ul>
{comments.map((comment) => (
<li key={comment.id} dangerouslySetInnerHTML={createMarkup(comment.content)} />
))}
</ul>
</div>
);
};
export default UserComments;
State Initialization: useState is used to simulate a list of user comments. In a real-world scenario, this data would likely come from an external source like a database.
Sanitizing Function: createMarkup is a helper function that takes raw HTML content and returns an object with a sanitized version of that HTML. DOMPurify.sanitize is used to clean the HTML content, removing any potentially malicious scripts.
Rendering Comments: Comments are rendered using the dangerouslySetInnerHTML property, which is React’s way of inserting raw HTML into the DOM. However, since we sanitize the content first, the risk of XSS is mitigated.
Keep in mind, you shouldn’t have to use dangerouslySetInnerHTML in most cases, as the name suggest. Most modern frontend frameworks are safe by default. As long as you don’t use dangerouslySetInnerHtml.
Points to Note
Using dangerouslySetInnerHTML: React advises caution when using dangerouslySetInnerHTML because of the risk of XSS attacks. However, when combined with a sanitizer like DOMPurify, it becomes a safer option. (dangerouslySetInnerHtml NOT RECOMMEND TO USE IN PRODUCTION)
Sanitization Library: DOMPurify is a widely used and trusted library for sanitizing HTML content against XSS attacks. It removes dangerous parts of the HTML and keeps the safe ones.
Real-World Implementation: In a real application, you would fetch user-generated content from a server or database. It’s crucial to sanitize this content before rendering it to the browser.
2. Content Security Policy (CSP) Headers
Implementing a Content Security Policy (CSP) is a crucial step in securing your web application against various types of attacks, including Cross-Site Scripting (XSS).
A CSP allows you to specify which resources can be loaded and executed by the browser, significantly reducing the risk of malicious content execution.
# CSP Header
header Content-Security-Policy "script-src 'self' 'unsafe-inline' http://localhost:5173 https://static.cloudflareinsights.com https://www.clarity.ms https://accounts.google.com https://upload-widget.cloudinary.com https://maps.googleapis.com https://www.googletagmanager.com;"
-
‘self’: Allows scripts hosted on the same origin as the web page.
-
‘unsafe-inline’: Allows the use of inline scripts, though it’s generally recommended to avoid this for better security.
-
Specified URLs: Only scripts from these whitelisted sources are allowed to run.
Best Practices and Considerations
-
Avoid ‘unsafe-inline’ if Possible: While it’s included in my policy, it’s generally safer to avoid inline scripts. Instead, move inline scripts to external files hosted on your server or a trusted domain.
-
Dynamic Hashes for Inline Scripts: If inline scripts are necessary, consider using hashes or nonces to allow specific scripts.
-
Regularly Review and Update Your CSP: As your application evolves, regularly review your CSP to ensure it aligns with your current resources and third-party integrations.
-
Testing: Before deploying changes to your CSP in a production environment, thoroughly test them to ensure they don’t inadvertently break any functionality.
-
Report-Only Mode: Initially, you might want to use the CSP in report-only mode (Content-Security-Policy-Report-Only) to observe its impact without enforcing it. This helps in identifying and fixing any issues before enforcing the policy.
3. Cross-Site Request Forgery (CSRF)
Cross-Site Request Forgery (CSRF) is a type of security attack on web applications. In a CSRF attack, the attacker tricks a legitimate user into submitting a request that they did not intend to.
This is typically done by embedding malicious requests into a website or email the user interacts with.
The attack exploits the fact that the user’s browser is already authenticated with a site (e.g., logged into a bank or email account), and the site can’t distinguish the illegitimate request from a legitimate one.
Here’s a brief rundown:
-
User Authentication: A user logs into a banking website and their browser stores authentication cookies.
-
Malicious Request: The user then visits a malicious site, which contains a hidden form that triggers a fund transfer on the banking site.
-
Execution of Request: When the user clicks something on the malicious site, the hidden form is submitted to the banking site. Because the user’s browser is still authenticated with the bank, the bank processes the request as if it were legitimate.
To protect against CSRF attacks, use anti-CSRF tokens in your forms.
These tokens are unique to each user session and are validated on the server-side. This ensures that the request is coming from the site’s own form and not from an external, malicious source.
Mitigating CSRF Attacks
- Anti-CSRF Tokens: Use anti-CSRF tokens in forms and validate them on the server side. This method is applicable across all frameworks.
Frontend implementation (React)
The following react code defines a web form that helps prevent Cross-Site Request Forgery (CSRF) attacks.
import React, { useEffect, useState } from 'react';
import axios from 'axios';
function CSRFProtectedForm() {
const [csrfToken, setCsrfToken] = useState('');
useEffect(() => {
// Fetch the CSRF token when the component mounts
const fetchCsrfToken = async () => {
try {
const response = await axios.get('/api/get-csrf-token');
setCsrfToken(response.data.csrfToken);
} catch (error) {
console.error('Error fetching CSRF token:', error);
}
};
fetchCsrfToken();
}, []);
const submitForm = async (event) => {
event.preventDefault(); // Prevent the default form submission
const formData = new FormData();
formData.append('_csrf', csrfToken);
// Append other form data here
try {
const response = await axios.post('/api/submit-form', formData);
// Handle success (e.g., show a success message or redirect)
} catch (error) {
// Handle error (e.g., show an error message)
console.error('Error submitting form:', error);
}
};
return (
<form onSubmit={submitForm}>
<input type="hidden" name="_csrf" value={csrfToken} />
{/* Your form fields go here */}
<button type="submit">Submit</button>
</form>
);
}
export default CSRFProtectedForm;
-
The csrfToken state is initialized to an empty string.
-
When the component mounts, useEffect is used to fetch the CSRF token from the server.
-
The fetched token is stored in the csrfToken state.
-
The submitForm function handles form submission. It prevents the default form submission behavior using `event.preventDefault().```
-
It creates a FormData object, appends the CSRF token and other form data, and sends a POST request to the server. Backend implementation (Express.js)
On the server side, you can use a package like helmet to create and validate CSRF tokens.
const express = require('express');
const helmet = require('helmet');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const crypto = require('crypto');
const app = express();
app.use(helmet());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
const generateCsrfToken = () => {
return crypto.randomBytes(100).toString('base64'); // Random token
};
// Middleware to set CSRF token
const csrfMiddleware = (req, res, next) => {
if (!req.cookies.csrfToken) {
const csrfToken = generateCsrfToken();
res.cookie('csrfToken', csrfToken, { httpOnly: true });
req.csrfToken = csrfToken;
} else {
req.csrfToken = req.cookies.csrfToken;
}
next();
};
// Endpoint to get CSRF token
app.get('/api/get-csrf-token', csrfMiddleware, (req, res) => {
res.json({ csrfToken: req.csrfToken });
});
// Endpoint to submit form with CSRF token validation
app.post('/api/submit-form', csrfMiddleware, (req, res) => {
if (req.body._csrf !== req.csrfToken) {
return res.status(403).send('CSRF token mismatch');
}
// Handle your form submission
res.json({ message: 'Form submitted successfully' });
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
A custom csrfMiddleware is created to generate and validate CSRF tokens. The token is stored in a cookie and sent back to the client. When the client submits a form, the server checks if the CSRF token in the form matches the one stored in the cookie. If they don’t match, it returns a 403 error, indicating a CSRF attempt.
4. Insecure Direct Object References (IDOR)
Insecure Direct Object References (IDOR) refer to a security weakness where an application provides direct access to objects based on user-supplied input.
This issue occurs when an application exposes internal implementation objects, like files, database records, or key-indexes, to users without proper authorization checks.
An IDOR vulnerability allows attackers to bypass authorization and access resources in the system directly, for example, by modifying the value of a parameter used to directly point to an object (such as database record IDs in URLs).
Example Scenario: Consider an application where users can view their profiles by visiting a URL like example.com/user/123, where 123 is the user’s ID in the database.
Vulnerability: If there are no proper access controls, a user could change the URL to example.com/user/124 and access another user’s profile.
Server-Side Validation
Always validate that the user requesting the resource is authorized to access it.
This can be done by checking if the user ID of the session matches the user ID of the resource requested.
const express = require('express');
const app = express();
const session = require('express-session');
// Assuming we have a function to get user data
const getUserById = require('./getUserById');
app.use(session({
secret: 'your-secret',
resave: false,
saveUninitialized: true
}));
app.get('/user/:id', (req, res) => {
const userId = req.params.id;
const loggedInUserId = req.session.userId;
// Check if logged-in user ID matches the requested user ID
if (userId === loggedInUserId) {
getUserById(userId, (err, user) => {
if (err) {
return res.status(500).send('Internal Server Error');
}
if (!user) {
return res.status(404).send('User not found');
}
res.json(user);
});
} else {
res.status(403).send('Access Denied');
}
});
app.listen(3000, () => console.log('Server is running on port 3000'));
5. Environment Variables
One of the key aspects of frontend application security is protecting sensitive information like API keys, database credentials, and other confidential data.
Use env variables so they don’t get leaked into the git repository. Especially when the repository is open source and public.
// Create a .env file at your project root:
// add your variables:
VITE_API_KEY=your_api_key_here
// Accessing Environment Variables:
// In your Vite project, you can access the environment variable directly:
const apiKey = import.meta.env.VITE_API_KEY;
// webpack:
REACT_APP_API_KEY=your_api_key_here
// accessing variables:
const apiKey = process.env.REACT_APP_API_KEY;
In production, serve environment-specific values from the server rather than hardcoding them into your frontend code. This can be done by configuring your server to inject environment variables into your application at runtime. Here’s a general approach:
Backend (Node.js)
// Express server example to send environment variables
app.get('/config', (req, res) => {
res.json({
apiKey: process.env.API_KEY
});
});
Nginx
location /config {
return 200 '{"apiKey":"'"$API_KEY"'"}';
add_header Content-Type application/json;
}
Here’s a breakdown of each part
location /config { ... }:
This block defines a location directive in Nginx. It specifies that the configuration within the curly braces should be applied to any HTTP request that matches the /config URL path. In other words, when a request is made to the /config endpoint, the following actions will be taken.
return 200 '{"apiKey":"'"$API_KEY"'"}';
This line tells Nginx to return a response with a status code of 200, which means the request was successful. The content of the response is a JSON string containing the apiKey.
The ’“$API_KEY”’ portion is a way to inject the value of the API_KEY environment variable into the JSON string.
Nginx evaluates the $API_KEY variable and replaces it with its actual value, resulting in a response like {“apiKey”:“your_actual_api_key”}
add_header Content-Type application/json;
Sets the Content-Type header of the HTTP response to application/json. This header indicates that the content being returned is in JSON format, which is useful for clients (like frontend applications) to correctly interpret the data.
Frontend
// Fetching the config from the server in your frontend app
async function fetchConfig() {
try {
const response = await fetch('/config');
const config = await response.json();
const apiKey = config.apiKey;
// Use apiKey in your application
} catch (error) {
console.error('Error fetching config:', error);
}
}
fetchConfig();
Ensure your environment variables are securely stored. Sensitive tokens should be stored in secure, HTTP-only cookies rather than local storage.
Secure API Requests: Always ensure that API requests that use sensitive data are secured, using HTTPS and, when applicable, additional encryption mechanisms. This prevents interception of sensitive data during transmission.