Common JavaScript Vulnerabilities and How to Avoid Them

In this article:
Subscribe to our blog:

JavaScript versatility and ease of use have made it a staple in web development, from client-side scripts to server-side applications. However, with its widespread adoption comes an increased risk of security vulnerabilities.

JavaScript tends to be particularly susceptible to attacks because of its dynamic nature, allowing for the execution of code on the fly, which attackers can exploit. 

JavaScript's integration with the Document Object Model (DOM) also makes it a prime target. The DOM is an essential part of web development, representing the structure of a web page and allowing scripts to access and manipulate its content dynamically. This interaction enables the dynamic and interactive experiences we enjoy on modern websites but can be dangerous if not handled correctly.

Let’s delve into some of the most common JavaScript vulnerabilities to better understand these threats and how to avoid security pitfalls when developing JavaScript software. 

1. Cross-Site Scripting (XSS)


Cross-site scripting (XSS) is a security vulnerability that allows attackers to inject malicious scripts into pages that other users will see. These scripts can then be executed in the context of the victim's browser, leading to various harmful outcomes, such as stealing cookies, session tokens, or other sensitive information.

Some of the most common types of XSS Attacks include: 

  • Stored XSS (Persistent XSS): An attack in which the malicious script is stored on the target server, such as in a database. When a user retrieves the stored information, the script is executed in their browser.

  • Reflected XSS (Non-Persistent XSS): The malicious script is reflected off a web server, such as in an error message, search result, or any other response that includes the input sent to the server as part of the request.

  • DOM-Based XSS: The vulnerability exists in the client-side code rather than the server-side code. The malicious script is executed by modifying the DOM environment in the victim’s browser.

Here’s an example of what an XSS attack could look like. Let's take a simple web application that allows users to submit comments, which are then displayed on the page:

<!DOCTYPE html>
<html>
<head>
    <title>Comment Section</title>
</head>
<body>
    <h1>Leave a Comment</h1>
    <form id="commentForm">
        <textarea id="comment" placeholder="Your comment here"></textarea>
        <button type="button" onclick="submitComment()">Submit</button>
    </form>
    <h2>Comments</h2>
    <div id="comments"></div>

    <script>
        function submitComment() {
            let comment = document.getElementById('comment').value;
            let commentsDiv = document.getElementById('comments');
            commentsDiv.innerHTML += '<p>' + comment + '</p>';
        }
    </script>
</body>
</html>

In this example, the submitComment function takes user input from a text area and directly injects it into the DOM using innerHTML. An attacker could exploit this by submitting a comment that contains a malicious script:

<script>alert('XSS Attack!');</script>

When this comment is submitted, it will be rendered as:

<p><script>alert('XSS Attack!');</script></p>

The script will execute, showing an alert box with the message "XSS Attack!"

One of the ways to remediate such an issue is by sanitizing the user input. Use a library or function to escape special characters in user input to prevent scripts from being executed.

function escapeHtml(str) {
    return str.replace(/&/g, "&amp;")
              .replace(/</g, "&lt;")
              .replace(/>/g, "&gt;")
              .replace(/"/g, "&quot;")
              .replace(/'/g, "&#039;");
}

function submitComment() {
    let comment = document.getElementById('comment').value;
    let escapedComment = escapeHtml(comment);
    let commentsDiv = document.getElementById('comments');
    commentsDiv.innerHTML += '<p>' + escapedComment + '</p>';
}

You should also use safe methods to insert data into the DOM. Instead of using innerHTML, for example, use textContent.

function submitComment() {
    let comment = document.getElementById('comment').value;
    let commentsDiv = document.getElementById('comments');
    let p = document.createElement('p');
    p.textContent = comment;
    commentsDiv.appendChild(p);
}

Don't forget to validate user input on both the client side and server side to ensure it conforms to expected formats.

function isValidComment(comment) {
    // Example: Check for non-empty, reasonable length, etc.
    return comment.length > 0 && comment.length < 500;
}
function submitComment() {
    let comment = document.getElementById('comment').value;
    if (!isValidComment(comment)) {
        alert('Invalid comment');
        return;
    }
    let commentsDiv = document.getElementById('comments');
    let p = document.createElement('p');
    p.textContent = comment;
    commentsDiv.appendChild(p);
}

2. Cross-Site Request Forgery (CSRF)


Cross-Site Request Forgery (CSRF) is a type of security attack where an attacker tricks a user into performing actions on a web application where they are authenticated without their knowledge. This can lead to unauthorized actions like changing account details, making purchases, or other sensitive operations.

Here’s how a CSRF attack typically works: 

  • The user logs into a web application and receives a session cookie. 

  • The attacker crafts a malicious request that performs some action on the web application where the user is authenticated.

  • The attacker tricks the user into visiting a malicious site or clicking a link that sends the crafted request to the target web application.

  • The web application processes the request as if the authenticated user made it since the session cookie is automatically sent with the request.

Here’s what a CSRF attack might look like in the wild. Say you have a simple web application that allows users to change their email addresses. It could look something like this:

<form id="changeEmailForm" action="https://example.com/change-email" method="POST">
    <input type="email" name="email" value="user@example.com">
    <button type="submit">Change Email</button>
</form>

An attacker could create a malicious page designed to exploit this form, which would look something like this:

<!DOCTYPE html>
<html>
<head>
    <title>Win a Prize!</title>
</head>
<body>
    <h1>Click here to win a prize!</h1>
    <form id="csrfForm" action="https://example.com/change-email"
method="POST">
        <input type="hidden" name="email" value="attacker@example.com">
        <button type="submit">Click here!</button>
    </form>
    <script>
        document.getElementById('csrfForm').submit();
    </script>
</body>
</html>

If a logged-in user visits this malicious page, the form automatically submits a POST request to https://example.com/change-email, changing the user's email address to attacker@example.com.

One of the best ways to prevent these attacks is by including a unique, unpredictable token in forms and verifying it on the server side. Here’s how you can generate and include a CSRF token in JavaScript:

<form id="changeEmailForm" action="https://example.com/change-email" method="POST">
    <input type="email" name="email" value="user@example.com">
    <input type="hidden" name="csrf_token" id="csrf_token" value="">
    <button type="submit">Change Email</button>
</form>

<script>
// Function to fetch the CSRF token
function getCsrfToken() {
    // Example token fetch from the server
    return fetch('https://example.com/get-csrf-token')
        .then(response => response.text());
}

// Add CSRF token to form
getCsrfToken().then(token => {
    document.getElementById('csrf_token').value = token;
});
</script>

Another common preventive measure is setting the SameSite attribute on cookies to prevent them from being sent with cross-site requests:

document.cookie = "sessionid=1234567890abcdef; SameSite=Strict; Secure";

3. Insecure Deserialization


Insecure deserialization is a security vulnerability that occurs when untrusted data is used to reconstruct objects or data structures. During the deserialization process, data is converted from a format suitable for storage or transmission back into its original format. If this process is not handled securely, it can lead to serious security issues.

Some types of attacks that can result from insecure deserialization include: 

  • Remote Code Execution (RCE): If the application deserializes data that contains malicious payloads, the attacker can execute arbitrary code on the server.

  • Data Tampering: An attacker can modify the serialized data to alter the state or behavior of the application. This could lead to unauthorized access, data corruption, or application logic bypass.

  • Denial of Service (DoS): Maliciously crafted serialized data can cause the application to consume excessive resources or crash, leading to a denial of service.

  • Authentication and Authorization Bypass: If session objects or authentication tokens are deserialized without validation, attackers can manipulate these to gain unauthorized access or escalate privileges.

Insecure deserialization can provide attackers with a direct means to interact with the internal workings of an application. Since deserialization often involves converting complex objects, any vulnerabilities within this process can expose the application to significant risks. The complexity of object structures and the lack of visibility into deserialized data can make these vulnerabilities challenging to detect and mitigate.

Here's a simple example where a web application deserializes JSON data received from a user:

const express = require('express');
const app = express();

app.use(express.json());

app.post('/submit', (req, res) => {
    try {
        // Deserializing user data without validation
        const userData = JSON.parse(req.body.data);

        // Processing user data
        console.log(`User Data: ${userData.name}, ${userData.role}`);

        res.send('Data received successfully');
    } catch (error) {
        res.status(400).send('Invalid JSON');
    }

});

app.listen(3000, () => {
    console.log('Server running on port 3000');
});

In this example, the server receives JSON data from the user and directly deserializes it using JSON.parse(). If an attacker sends a malicious payload, they could manipulate the application's behavior.

To prevent insecure deserialization, it is essential to validate and sanitize the incoming data before deserializing it. Some best practices include validating input data to ensure the data matches the expected format and type and sanitizing the data to remove or escape any potentially harmful content.

Here's how you can modify the previous example to include these prevention measures:

const express = require('express');
const app = express();

app.use(express.json());

// Function to validate user data
function validateUserData(userData) {
    if (typeof userData !== 'object' || !userData) return false;
    if (typeof userData.name !== 'string' || typeof userData.role !==
'string') return false;
    return true;
}


app.post('/submit', (req, res) => {
    try {
        // Safely parse JSON data
        const userData = JSON.parse(req.body.data);

        // Validate the deserialized data
        if (!validateUserData(userData)) {
            return res.status(400).send('Invalid data format');
        }

        // Processing user data
        console.log(`User Data: ${userData.name}, ${userData.role}`);

        res.send('Data received successfully');
    } catch (error) {
        res.status(400).send('Invalid JSON');
    }
});
app.listen(3000, () => {
    console.log('Server running on port 3000');
});

4. Prototype Pollution


 Prototype pollution occurs when an attacker can manipulate the prototype of base objects, such as Object, Array, etc., by injecting properties into them. This can lead to unexpected behavior in the application, security flaws, and potentially severe impacts on the functionality and security of the application.

Prototype pollution can occur if an attacker can assign properties directly to an object's prototype, which then affects all instances of that object. Also,  functions that merge or extend objects without properly sanitizing input can be exploited to modify the prototype.

Some of the most common types of prototype pollution attacks include: 

  • Denial of service (DoS): Adding properties to prototypes can lead to unexpected behavior, infinite loops, or crashes, causing denial of service.

  • Remote code execution (RCE): In some cases, prototype pollution can lead to remote code execution if the polluted properties are used in a way that executes code.

  • Data corruption: Polluting the prototype can corrupt data across the application, leading to inconsistent behavior and potential data breaches.

  • Security bypass: Security mechanisms that rely on object properties can be bypassed if an attacker can manipulate those properties via prototype pollution.

Since JavaScript relies heavily on object inheritance, changing the prototype of base objects can have far-reaching consequences, making it a critical vulnerability to address.

In this example, an application merges user-provided objects into a default configuration object. An attacker can exploit this to perform prototype pollution.

const express = require('express');
const app = express();

app.use(express.json());

app.post('/merge', (req, res) => {
    const userConfig = req.body.config;


    // Default configuration object
    const defaultConfig = {
        setting1: true,
        setting2: false
    };

    // Merging user configuration with default configuration (vulnerable to prototype pollution)
    const finalConfig = { ...defaultConfig, ...userConfig };

    res.send(`Configuration: ${JSON.stringify(finalConfig)}`);
});

app.listen(3000, () => {
    console.log('Server running on port 3000');
});

If an attacker sends a request with a payload like this:

{
    "config": {
        "__proto__": {
            "polluted": "polluted value"
        }
    }
}

The prototype of all objects will be polluted, adding the property polluted to Object.prototype, affecting all objects inherited from Object.

To prevent prototype pollution, sanitizing and validating the input before merging objects is essential. Prevent the addition of properties to Object.prototype or any other prototype. Use libraries that have protections against prototype pollution. Use deep cloning methods that do not copy prototype properties.

Here’s how you can modify the previous example to prevent prototype pollution using Object.create(null) to create a null-prototype object:

const express = require('express');
const app = express();

app.use(express.json());

// Function to create a safe, null-prototype object
function createSafeObject(obj) {
    const safeObj = Object.create(null);
    for (const key in obj) {
        if (Object.prototype.hasOwnProperty.call(obj, key)) {
            safeObj[key] = obj[key];
        }
    }
    return safeObj;
}

app.post('/merge', (req, res) => {
    const userConfig = createSafeObject(req.body.config);

    // Default configuration object
    const defaultConfig = {
        setting1: true,
        setting2: false
    };

    // Merging user configuration with default configuration securely
    const finalConfig = { ...defaultConfig, ...userConfig };


    res.send(`Configuration: ${JSON.stringify(finalConfig)}`);
});

app.listen(3000, () => {
    console.log('Server running on port 3000');
});

5. Insecure Use of eval()


Insecure Use of eval() refers to a security vulnerability that occurs when the eval() function in JavaScript is used to execute dynamically generated code, especially when this code is derived from untrusted input.

 The eval() function in JavaScript allows executing a string as JavaScript code. For example, eval("2 + 2") would return 4.

The primary security concern with eval() is that it can execute any JavaScript code, including code provided by an attacker. This makes applications that use eval() particularly vulnerable to injection attacks, where an attacker can manipulate input to execute arbitrary code on the client or server.

If an attacker can control the input passed to eval(), they can execute arbitrary JavaScript code. This can lead to full compromise of the application, including access to sensitive data, alteration of application behavior, and further exploitation.

If eval() is used to process user input on the client side, it can lead to XSS attacks where malicious scripts are executed in the user's browser. eval() can bypass security mechanisms, such as input validation or access controls, if the attacker can inject code that alters the application's expected behavior.

Here's a simple example of a web application uing the eval() function to calculate a mathematical expression provided by the user:

const express = require('express');
const app = express();

app.use(express.json());

app.post('/calculate', (req, res) => {
    const userExpression = req.body.expression;

    try {
        // Insecure: Using eval() to evaluate user-provided expression
        const result = eval(userExpression);

        res.send(`Result: ${result}`);
    } catch (error) {
        res.status(400).send('Invalid expression');
    }
});

app.listen(3000, () => {
    console.log('Server running on port 3000');
});

In this example, the application accepts a string containing a mathematical expression from the user and uses eval() to evaluate it. This is insecure because an attacker can inject JavaScript code into the expression field, leading to severe security issues such as remote code execution.

{
    "expression": "process.exit()"
}

This malicious input would cause the server to terminate immediately.

To prevent such attacks, you should avoid using eval() whenever possible, especially when dealing with user input. Instead of using eval(), use safer alternatives like a dedicated library to evaluate mathematical expressions or implement custom parsing logic.

If you must evaluate user input, strictly validate and sanitize it to ensure only safe, expected expressions are allowed. In most cases, you can refactor the code to eliminate the need for eval().

Here’s how you can rewrite the previous example using a safer alternative like the mathjs library:

const express = require('express');
const math = require('mathjs');
const app = express();

app.use(express.json());

app.post('/calculate', (req, res) => {
    const userExpression = req.body.expression;

    try {
        // Secure: Using a dedicated library to evaluate mathematical expressions
        const result = math.evaluate(userExpression);

        res.send(`Result: ${result}`);
    } catch (error) {
        res.status(400).send('Invalid expression');
    }
});

app.listen(3000, () => {
    console.log('Server running on port 3000');
});

6. Improper Error Handling


Improper error handling can lead to various security vulnerabilities and other issues that can compromise an application's integrity, availability, and confidentiality.

Error messages may expose internal implementation details, such as stack traces, database queries, or server configurations, to end users or attackers. This information can be used to plan further attacks. Unhandled exceptions can cause an application to crash or behave unpredictably, leading to denial of service (DoS) or other security issues.

Also, failing to log errors properly or logging sensitive data insecurely can make it difficult to detect and respond to security incidents, and returning inconsistent or generic error messages without proper context can lead to confusion or provide attackers with hints about potential vulnerabilities.

Improper error handling can expose sensitive information, make an application vulnerable to attacks, and cause system instability. For instance, if an application reveals stack traces or database errors to an end user, an attacker could use that information to identify and exploit weaknesses. 

Here's an example of a simple Express.js application that interacts with a database:

const express = require('express');
const app = express();
const mongoose = require('mongoose');


app.get('/user/:id', async (req, res) => {
    try {
        const userId = req.params.id;
        const user = await User.findById(userId);
        if (!user) {
            throw new Error('User not found');
        }
        res.send(user);
    } catch (error) {
        // Insecure: Exposing sensitive error details
        res.status(500).send(`Internal Server Error: ${error.message}`);
    }
});

mongoose.connect('mongodb://localhost/test', { useNewUrlParser: true, useUnifiedTopology: true });
app.listen(3000, () => {
    console.log('Server running on port 3000');
});

In this example, the application exposes detailed error messages to the client. An attacker could use this information to gain insights into the application's internal structure and potentially exploit it.

To prevent such vulnerabilities, do not expose detailed error messages to end users. Log them instead. Return generic error messages to users and log the detailed error information on the server side. Ensure error messages are consistent and do not reveal internal details.

Here’s how you can improve the previous example to handle errors securely:

const express = require('express');
const app = express();
const mongoose = require('mongoose');

app.get('/user/:id', async (req, res) => {
    try {
        const userId = req.params.id;
        const user = await User.findById(userId);
        if (!user) {
            return res.status(404).send('User not found');
        }
        res.send(user);
    } catch (error) {
        // Secure: Logging the error and returning a generic message
        console.error(`Error fetching user: ${error.message}`);
        res.status(500).send('Internal Server Error');
    }
});
mongoose.connect('mongodb://localhost/test', { useNewUrlParser: true, useUnifiedTopology: true });

app.listen(3000, () => {
    console.log('Server running on port 3000');
});

Automate JavaScript Vulnerability Identification with Codacy

 

Utilizing static code analysis tools is a highly effective method for detecting and addressing JavaScript vulnerabilities early in the development process and vulnerability prioritisations. These tools automatically scan your codebase, identify security issues, and provide actionable insights to help resolve them.

Incorporating static code analysis into your JavaScript development workflow allows you to identify and address vulnerabilities before they reach production, significantly reducing the potential attack surface of your application.

Codacy offers a robust code analysis platform that supports over 40 programming languages, including JavaScript. With Codacy, you can continuously monitor your code for vulnerabilities, maintain code quality, and ensure compliance with coding standards and best practices.

To explore how Codacy can benefit your development process, schedule a demo with one of our experts today or take advantage of a free 14-day trial.

RELATED
BLOG POSTS

How to Perform Continuous, Non-Blocking Code Reviews
If you own an Internet-based enterprise, your main branch powers your business and should be protected at all costs. Code reviews are how engineering...
11 Common Java Vulnerabilities and How to Avoid Them
Java is a cornerstone of modern software development, powering everything from enterprise-level applications to Android apps and large-scale data...
Understanding Injection Attacks and How to Avoid Them
In 2017, attackers wreaked havoc for 76 days before Equifax finally discovered the breach. Several security lapses had allowed attackers to access the...

Automate code
reviews on your commits and pull request

Group 13