During a recent audit of a high-traffic Indian fintech platform, we identified a critical vulnerability where the application’s reverse proxy was caching responses based on the URL path but ignoring the X-Forwarded-Host header. By injecting a malicious host into this unkeyed header, we successfully poisoned the cache, causing legitimate users to load JavaScript assets from an attacker-controlled domain. This is not an isolated incident; it is a recurring pattern in Node.js environments where default configurations prioritize performance over strict header validation, often bypassing standard OWASP Top 10 defenses.
Understanding Web Cache Poisoning in Node.js
Web cache poisoning occurs when an attacker exploits the behavior of a web cache to serve malicious content to other users. In a Node.js ecosystem, this usually involves a reverse proxy like Nginx, CloudFront, or a local CDN provider common in India. The cache identifies unique requests using a "cache key," which typically includes the request method and the URI. However, unkeyed inputs—headers not included in the cache key—can still influence the response generated by the Node.js backend.
The Mechanics of Unkeyed Header Injection
We frequently observe developers using headers like X-Forwarded-Host to dynamically generate absolute URLs for redirects or asset loading. If the caching layer does not include this header in its cache key, an attacker can send a crafted request that the backend uses to generate a response. The cache then stores this "poisoned" response and serves it to subsequent users who send a legitimate request.
Testing for X-Forwarded-Host vulnerability
curl -I -H "X-Forwarded-Host: attacker-controlled-site.in" https://api.target-app.in/v1/assets/config.js
If the response returns a X-Cache: Hit and the body contains references to attacker-controlled-site.in, the cache is poisoned. In India, many Tier-2 ISP caching proxies and local CDNs ignore the Vary header, making them particularly susceptible to this attack vector.
Exploiting Prototype Pollution for Cache Poisoning
Vulnerabilities like CVE-2020-8203 in the lodash library, documented in the NIST NVD, allowed for prototype pollution, which can be leveraged for cache poisoning. By manipulating the Object.prototype, an attacker can inject properties that change how the application processes headers or generates HTML. If the Node.js application uses a vulnerable utility to merge configuration objects, the global prototype is compromised.
Conceptual exploit payload for prototype pollution leading to header manipulation
import requests
url = "https://api.target-app.in/update-profile" payload = { "__proto__": { "allowedHeaders": ["X-Malicious-Header"], "exposedHeaders": ["X-Malicious-Header"] } } requests.post(url, json=payload)
Node.js Security Best Practices: The Foundation
Securing a Node.js application requires a multi-layered approach that starts with the runtime environment and extends to the application logic. We treat the package.json file as the primary attack surface. With the DPDP Act 2023 now in effect, Indian organizations are legally obligated to implement "reasonable security safeguards" to protect personal data. This begins with rigorous dependency management and following established SSH security best practices for infrastructure management.
Managing Dependencies and Using npm audit
The Node.js ecosystem is notorious for deep dependency trees. A single top-level package can pull in hundreds of sub-dependencies, any of which could be compromised. We use npm audit not just as a one-time check, but as a mandatory step in the CI/CD pipeline.
Run a high-level audit and fail the build if vulnerabilities are found
npm audit --audit-level=high
When we encounter unfixable vulnerabilities in a dependency, we utilize the overrides field in package.json to force a specific, secure version of a sub-dependency. This is a critical technique when a maintainer is slow to patch a known CVE.
Securing Environment Variables
Hardcoding credentials is a common failure point. We've seen numerous cases where .env files are accidentally committed to public repositories. We recommend using tools like dotenv for local development but transitioning to secret management services like AWS Secrets Manager or HashiCorp Vault for production.
Scanning for accidentally committed secrets in the local git history
git log -p | grep -Ei "password|api_key|secret|token"
Avoiding Dangerous Functions
Functions like eval(), setTimeout() with string arguments, and new Function() allow for the execution of arbitrary code. I have seen these used for "dynamic" logic, but they almost always introduce Remote Code Execution (RCE) vulnerabilities. We enforce ESLint rules like no-eval and no-implied-eval to prevent these from reaching production.
Implementing Robust CORS Policies
Cross-Origin Resource Sharing (CORS) is a browser-side security mechanism, but it is configured on the server. Misconfigured CORS policies are one of the most common ways sensitive PII is leaked from Indian fintech APIs. Using Access-Control-Allow-Origin: * on an API that handles credentials (cookies or Authorization headers) is a catastrophic failure.
The Danger of the Wildcard and Regex Bypasses
Many developers use wildcards in staging and forget to remove them in production. Even when attempting to be specific, poorly written regular expressions can lead to bypasses. For example, a regex intended to match trusted-app.in might inadvertently match trusted-app.in.attacker.com.
Using nmap to check for weak CORS configurations
nmap -p 443 --script http-cors
Secure CORS Configuration Snippet
We recommend using a whitelist approach with explicit validation. The following configuration for the cors middleware ensures that only trusted domains can access the API, and it handles the pre-flight OPTIONS request correctly.
const express = require('express'); const cors = require('cors');
const app = express();
const whitelist = [ 'https://www.company-app.in', 'https://admin.company-app.in' ];
const corsOptions = { origin: function (origin, callback) { // Allow requests with no origin (like mobile apps or curl) if (!origin || whitelist.indexOf(origin) !== -1) { callback(null, true); } else { callback(new Error('CORS Policy Violation: Origin not allowed')); } }, methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], credentials: true, maxAge: 600 // Cache pre-flight response for 10 minutes };
app.use(cors(corsOptions));
Express.js Security Hardening
Express.js is the de facto standard for Node.js web servers, but its default settings are not secure. It leaks information about the underlying technology stack and lacks essential security headers.
Implementing Helmet.js for Secure HTTP Headers
Helmet.js is a collection of middleware functions that set various HTTP headers to secure the application. It helps mitigate attacks like Clickjacking, Cross-Site Scripting (XSS), and MIME-sniffing.
const helmet = require('helmet');
// Basic implementation app.use(helmet());
// Advanced CSP configuration to prevent XSS app.use( helmet.contentSecurityPolicy({ directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "https://trusted-cdn.in"], objectSrc: ["'none'"], upgradeInsecureRequests: [], }, }) );
Preventing Brute Force with Express-Rate-Limit
Rate limiting is essential for protecting login endpoints and resource-intensive API routes. In the Indian context, where botnets are frequently used for credential stuffing against e-commerce platforms, rate limiting is a primary defense.
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({ windowMs: 15 60 1000, // 15 minutes max: 5, // Limit each IP to 5 login attempts per window message: 'Too many login attempts, please try again after 15 minutes', standardHeaders: true, legacyHeaders: false, });
app.use('/api/v1/auth/login', loginLimiter);
Disabling the X-Powered-By Header
By default, Express sends the X-Powered-By: Express header. This tells an attacker exactly what technology you are using, allowing them to narrow down their exploit attempts.
// Disable the header manually or let Helmet handle it app.disable('x-powered-by');
Node.js API Security Best Practices
APIs are the backbone of modern applications, especially with the rise of UPI and Open Banking in India. Securing these interfaces requires strict validation and robust authentication.
Robust JWT Management
JSON Web Tokens (JWT) are commonly used for stateless authentication. However, I often see tokens signed with weak secrets or using the none algorithm. We always use asymmetric signing (RS256) where the private key stays on the auth server and the public key is used by microservices for verification.
const jwt = require('jsonwebtoken'); const fs = require('fs');
const privateKey = fs.readFileSync('./private.key');
// Signing a token with a 24h expiration const token = jwt.sign({ uid: user.id }, privateKey, { algorithm: 'RS256', expiresIn: '24h', issuer: 'auth.company.in' });
Input Validation and Sanitization
Injection attacks (SQLi, NoSQLi) are prevented by validating all incoming data against a strict schema. We use libraries like joi or zod to ensure that the data conforms to expected formats before it ever touches the database layer.
const zod = require('zod');
const userSchema = zod.object({ username: zod.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/), email: zod.string().email(), age: zod.number().int().positive() });
app.post('/api/user', (req, res) => { const result = userSchema.safeParse(req.body); if (!result.success) { return res.status(400).json(result.error); } // Proceed with validated data });
Advanced Production Hardening
Once the code is secure, the environment must be hardened. Running a Node.js process as the root user is a common mistake that turns a limited application compromise into a full system takeover. For DevOps teams, implementing secure SSH access for teams is a vital step in ensuring that only authorized personnel can modify production environments.
Running as a Non-Root User
In our Dockerfiles, we explicitly create a service user and switch to it before starting the application. This limits the blast radius if an RCE vulnerability is exploited.
Example Dockerfile snippet for non-root execution
FROM node:18-slim
RUN groupadd -r nodejs && useradd -r -g nodejs nodejs USER nodejs
WORKDIR /app COPY --chown=nodejs:nodejs . .
CMD ["node", "server.js"]
Proper Error Handling and Logging
Leaking stack traces in production responses provides attackers with valuable information about your file structure and internal logic. We use a global error handler to catch exceptions and log them to a secure, centralized logging system like ELK or Graylog, while returning a generic error message to the user.
// Global error handler in Express app.use((err, req, res, next) => { console.error(err.stack); // Log to internal system res.status(500).send({ error: 'Internal Server Error', requestId: req.id // Reference for debugging }); });
Automating Security Scans in CI/CD
Security is not a checkbox; it is a process. We integrate Static Application Security Testing (SAST) and Software Composition Analysis (SCA) into the GitLab or GitHub Actions pipeline.
GitHub Action for Snyk security scanning
name: Snyk Security Scan on: [push] jobs: security: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Run Snyk to check for vulnerabilities uses: snyk/actions/node@master env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
Monitoring for Real-time Threat Detection
Post-deployment, we rely on monitoring to detect anomalies. In the Indian market, where financial transactions are often targeted, monitoring for high rates of 4xx errors or unusual spikes in traffic from specific IP ranges is critical. Integrating a robust SIEM and log monitoring solution allows teams to visualize these metrics in real-time.
Detecting Cache Poisoning Attempts via Logs
To identify if someone is attempting to poison your cache, look for requests with suspicious headers that do not match the expected traffic patterns.
Searching Nginx logs for X-Forwarded-Host injection attempts
grep "X-Forwarded-Host" /var/log/nginx/access.log | grep -v "your-trusted-domain.in"
If you see domains like burpcollaborator.net or interactsh.com in your logs, an automated scanner or a manual tester is probing your infrastructure for unkeyed header vulnerabilities.
Trusting Proxies Correctly
If your Node.js app is behind a proxy (Nginx, HAProxy, CloudFront), you must explicitly tell Express which proxy to trust. This prevents header spoofing where an attacker sends their own X-Forwarded-For header to bypass rate limits.
// Trust only the immediate reverse proxy app.set('trust proxy', 'loopback');
// Or trust a specific IP range (e.g., VPC CIDR) app.set('trust proxy', '10.0.0.0/16');
This setting ensures that req.ip and req.protocol are accurately populated from the headers set by your trusted proxy, not by the client. Without this, your rate limiting and logging are fundamentally flawed.
To verify your CORS configuration against the latest regex bypass techniques, run the following command to test how your server responds to a null origin or a subdomain-prefixed origin:
curl -H "Origin: https://trusted-app.in.attacker.com" -I https://api.target.in
