VAPT Remediation OWASP Top 10 Node.js / Express Linux · Nginx · PM2

Node.js Security
Hardening Guide

A complete, copy-paste-ready security baseline for Node.js / Express applications hosted on Linux. Every control maps to VAPT findings and includes professional audit-closure statements.

16
Security Controls
50+
Code Snippets
100%
Free / OSS Tools
A+
Security Grade
01
Helmet.js — HTTP Security Headers
HIGH App Middleware helmetapp.jsExpress
⚠ Issue
Node.js/Express does not set any security-related HTTP response headers by default. Missing headers expose the application to clickjacking, MIME-sniffing, XSS, and protocol downgrade attacks.
🔥 Risk
Clickjacking attacks, XSS via content-type confusion, MIME sniffing, SSL stripping via missing HSTS, and information leakage via X-Powered-By: Express.
📁 Files & Paths
/var/www/app/app.js /var/www/app/src/middleware/security.js package.json
🔧 Install & Configure Helmet.js
Terminal — Install helmet
$ npm install helmet
/var/www/app/app.js
const express = require('express');
const helmet  = require('helmet');
const app     = express();

// ─── Remove Express fingerprint ──────────────────────────────
app.disable('x-powered-by');

// ─── Helmet: full security header suite ──────────────────────
app.use(helmet({
  // HTTP Strict Transport Security — 1 year
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true,
  },
  // Content Security Policy (adjust domains for your app)
  contentSecurityPolicy: {
    directives: {
      defaultSrc:  ["'self'"],
      scriptSrc:   ["'self'", "'unsafe-inline'"],  // tighten in prod
      styleSrc:    ["'self'", "'unsafe-inline'"],
      imgSrc:      ["'self'", 'data:', 'https:'],
      connectSrc:  ["'self'"],
      fontSrc:     ["'self'", 'https://fonts.gstatic.com'],
      frameAncestors: ["'self'"],      // prevents clickjacking
      baseUri:     ["'self'"],
      formAction:  ["'self'"],
    },
  },
  // Prevent MIME type sniffing
  noSniff: true,
  // Prevent IE from opening downloads in site context
  ieNoOpen: true,
  // Disable caching for sensitive routes
  noCache: false,
  // Referrer policy
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
  // Disable browser features
  permittedCrossDomainPolicies: false,
  crossOriginEmbedderPolicy: false,
}));

// ─── Disable cache on sensitive auth routes ───────────────────
app.use('/api/auth', (req, res, next) => {
  res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
  res.setHeader('Pragma', 'no-cache');
  next();
});
👥 Responsible
Node.js Developer
🔄 Restart
⟳ pm2 restart app
✅ Verification
$ curl -I https://your-app.com/ 2>&1 | grep -iE '(strict|x-frame|x-content|content-security|referrer|x-powered)' → strict-transport-security: max-age=31536000; includeSubDomains; preload → x-frame-options: SAMEORIGIN → x-content-type-options: nosniff → content-security-policy: default-src 'self'; ... → referrer-policy: strict-origin-when-cross-origin → (x-powered-by: should NOT appear) # Online grade check $ curl -s "https://securityheaders.com/?q=https://your-app.com" | grep grade → Grade: A
📋 VAPT Closure Statement
🔒
Audit Closure
Helmet.js middleware has been integrated as the first middleware in the Express request pipeline, enforcing HSTS (max-age 1 year), Content-Security-Policy, X-Content-Type-Options (nosniff), Referrer-Policy (strict-origin-when-cross-origin), and X-Frame-Options (SAMEORIGIN). The X-Powered-By: Express header has been suppressed. All headers verified present via curl. Securityheaders.com reports Grade A.
02
Rate Limiting & DDoS Protection
CRITICAL App + Server express-rate-limitnginx.confslow-down
⚠ Issue
Express APIs have no built-in request throttling. Login, registration, and password-reset endpoints are vulnerable to automated brute-force and credential-stuffing attacks.
🔥 Risk
Account takeover via brute-force, API abuse, server resource exhaustion (DoS), and financial impact from metered third-party API calls.
📁 Files & Paths
/var/www/app/src/middleware/rateLimit.js /var/www/app/app.js /etc/nginx/sites-enabled/your-app.conf
🔧 Fix 1 — express-rate-limit (Application layer)
Terminal
$ npm install express-rate-limit express-slow-down
/var/www/app/src/middleware/rateLimit.js
const rateLimit = require('express-rate-limit');
const slowDown  = require('express-slow-down');

// ─── General API limiter: 100 req / 15 min per IP ────────────
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,   // 15 minutes
  max:      100,
  standardHeaders: true,
  legacyHeaders:   false,
  message: { error: 'Too many requests. Try again later.' },
  skipSuccessfulRequests: false,
});

// ─── Auth limiter: 5 attempts / 15 min (strict) ──────────────
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max:      5,
  standardHeaders: true,
  legacyHeaders:   false,
  message: { error: 'Too many login attempts. Wait 15 minutes.' },
  skipSuccessfulRequests: true,   // don't penalise successful logins
});

// ─── Speed limiter: slow-down after 50 requests ──────────────
const speedLimiter = slowDown({
  windowMs:        15 * 60 * 1000,
  delayAfter:      50,
  delayMs:         (hits) => hits * 100,   // +100ms per hit
});

module.exports = { apiLimiter, authLimiter, speedLimiter };
/var/www/app/app.js — Apply limiters
const { apiLimiter, authLimiter, speedLimiter } = require('./src/middleware/rateLimit');

// Global API rate limit
app.use('/api/', apiLimiter);
app.use('/api/', speedLimiter);

// Strict auth endpoint limits
app.use('/api/auth/login',    authLimiter);
app.use('/api/auth/register', authLimiter);
app.use('/api/auth/reset',    authLimiter);
🔧 Fix 2 — Nginx connection & request limiting
/etc/nginx/nginx.conf (http block)
# Rate limit zones
limit_req_zone  $binary_remote_addr zone=api_zone:10m rate=30r/s;
limit_req_zone  $binary_remote_addr zone=login_zone:10m rate=3r/m;
limit_conn_zone $binary_remote_addr zone=conn_zone:10m;
/etc/nginx/sites-enabled/your-app.conf
location /api/ { limit_req zone=api_zone burst=60 nodelay; limit_conn conn_zone 20; limit_req_status 429; limit_conn_status 429; proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /api/auth/ { limit_req zone=login_zone burst=5 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:3000; include proxy_params; }
👥 Responsible
Node.js DeveloperWeb/Nginx Admin
🔄 Restart
⟳ pm2 restart app
⟳ systemctl reload nginx
✅ Verification
# Trigger API rate limit (100+ rapid requests) $ for i in {1..110}; do curl -s -o /dev/null -w "%{http_code}\n" https://your-app.com/api/endpoint; done → First 100: 200, remaining: 429 # Trigger login limit (6 attempts) $ for i in {1..7}; do curl -s -o /dev/null -w "%{http_code}\n" -X POST -d '{"email":"x","password":"y"}' https://your-app.com/api/auth/login; done → First 5: 401, remaining: 429 # Check RateLimit headers in response $ curl -I https://your-app.com/api/health | grep -i ratelimit → RateLimit-Limit: 100 → RateLimit-Remaining: 99
📋 VAPT Closure Statement
🔒
Audit Closure
API rate limiting enforced at both the application layer (express-rate-limit: 100 req/15 min general; 5 req/15 min on auth endpoints) and Nginx upstream (30 req/s with burst, 3 req/min on login). Rate-limit response headers are set per RFC 6585. Verification confirms HTTP 429 on threshold breach at both layers. Progressive slowdown is applied before hard limits via express-slow-down.
03
CORS Configuration
HIGH App Middleware corsapp.js
⚠ Issue
Using wildcard CORS (Access-Control-Allow-Origin: *) or accepting all origins allows any website to make authenticated cross-origin requests to your API using the victim's credentials.
🔥 Risk
Cross-Site Request Forgery (CSRF) via CORS bypass, data exfiltration from authenticated sessions, and session riding attacks from malicious third-party sites.
🔧 Fix — Strict Origin Whitelist
Terminal
$ npm install cors
/var/www/app/src/middleware/cors.js
const cors = require('cors');

// ─── Strict origin allowlist ──────────────────────────────────
const ALLOWED_ORIGINS = [
  'https://your-app.com',
  'https://www.your-app.com',
  'https://admin.your-app.com',
  // Dev only — NEVER in production:
  ...(process.env.NODE_ENV === 'development' ? ['http://localhost:3000'] : []),
];

const corsOptions = {
  origin: (origin, callback) => {
    // Allow requests with no origin (server-to-server / curl)
    if (!origin) return callback(null, true);
    if (ALLOWED_ORIGINS.includes(origin)) {
      return callback(null, true);
    }
    callback(new Error(`CORS blocked: ${origin}`));
  },
  credentials: true,    // allow cookies in cross-origin requests
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  exposedHeaders: ['X-Request-ID'],
  maxAge: 86400,         // cache preflight for 24 hours
  optionsSuccessStatus: 200,
};

module.exports = cors(corsOptions);
/var/www/app/app.js — Apply CORS before routes
const corsMiddleware = require('./src/middleware/cors');
// Handle OPTIONS preflight first
app.options('*', corsMiddleware);
// Apply to all routes
app.use(corsMiddleware);
👥 Responsible
Node.js Developer
✅ Verification
# Allowed origin — should succeed $ curl -H "Origin: https://your-app.com" -I https://your-app.com/api/data → access-control-allow-origin: https://your-app.com # Disallowed origin — must be blocked $ curl -H "Origin: https://evil.com" -I https://your-app.com/api/data → access-control-allow-origin: (absent — request blocked) # Wildcard must NOT appear $ curl -I https://your-app.com/api/ | grep 'access-control-allow-origin' → must NOT contain '*'
📋 VAPT Closure Statement
🔒
Audit Closure
CORS policy has been replaced with an explicit domain allowlist. Wildcard origin (Access-Control-Allow-Origin: *) has been eliminated. Preflight caching (86400s), restricted HTTP methods, and controlled exposed headers are configured. Credentials mode is enabled only for verified origins. Verification confirms unapproved origins receive no CORS headers.
04
HTTPS / TLS & Nginx Reverse Proxy Hardening
CRITICAL Server Level nginx.confcertbotTLSv1.3
⚠ Issue
Node.js should never be exposed directly to the internet. Running without TLS or with weak cipher suites (TLSv1.0/1.1, RC4, 3DES) exposes data in transit to interception and downgrade attacks.
🔥 Risk
Session token interception, credential theft via MITM, POODLE/BEAST/CRIME attacks on legacy TLS, direct Node.js process exposure bypassing firewall rules.
🔧 Fix 1 — Certbot Free TLS Certificate
Terminal — Issue Let's Encrypt certificate
# Install certbot
$ apt-get install certbot python3-certbot-nginx -y

# Issue certificate (replaces Nginx config automatically)
$ certbot --nginx -d your-app.com -d www.your-app.com \
  --email admin@your-app.com --agree-tos --no-eff-email

# Auto-renewal (already set up by certbot, verify it)
$ systemctl status certbot.timer
$ certbot renew --dry-run
🔧 Fix 2 — Hardened Nginx Reverse Proxy Config
/etc/nginx/sites-enabled/your-app.conf
# ─── HTTP → HTTPS redirect ─────────────────────────────────── server { listen 80; server_name your-app.com www.your-app.com; return 301 https://$host$request_uri; } # ─── HTTPS / Reverse Proxy ─────────────────────────────────── server { listen 443 ssl http2; server_name your-app.com www.your-app.com; # TLS certificate ssl_certificate /etc/letsencrypt/live/your-app.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/your-app.com/privkey.pem; ssl_trusted_certificate /etc/letsencrypt/live/your-app.com/chain.pem; # TLS protocol — only TLSv1.2 and TLSv1.3 ssl_protocols TLSv1.2 TLSv1.3; # Strong ciphers only (disable weak: RC4, 3DES, CBC) ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305'; ssl_prefer_server_ciphers off; # Session resumption ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_session_tickets off; # OCSP stapling ssl_stapling on; ssl_stapling_verify on; resolver 8.8.8.8 8.8.4.4 valid=300s; # HSTS header add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; # Hide Nginx version server_tokens off; # ─── Proxy to Node.js ────────────────────────────────── location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; # Security: prevent request smuggling proxy_read_timeout 60s; proxy_connect_timeout 10s; client_max_body_size 10m; } }
🔧 Fix 3 — Block direct Node.js port access via firewall
Terminal — ufw firewall rules
# Allow only HTTP/HTTPS and SSH $ ufw allow 22/tcp # SSH $ ufw allow 80/tcp # HTTP (redirects to HTTPS) $ ufw allow 443/tcp # HTTPS # Block direct Node.js port (3000) from outside $ ufw deny 3000 $ ufw enable # Verify Node only listens on localhost $ ss -tlnp | grep node → LISTEN 0 128 127.0.0.1:3000 (NOT 0.0.0.0:3000)
/var/www/app/app.js — Bind to localhost only
// Always bind to localhost — never 0.0.0.0 in production const PORT = process.env.PORT || 3000; const HOST = '127.0.0.1'; // CRITICAL: not '0.0.0.0' app.listen(PORT, HOST, () => { console.log(`Server on ${HOST}:${PORT}`); });
👥 Responsible
Linux/System AdminNginx AdminNode.js Developer
🔄 Restart
⟳ systemctl reload nginx
⟳ pm2 restart app
✅ Verification
# TLS grade check (expect A+) $ curl -s "https://api.ssllabs.com/api/v3/analyze?host=your-app.com&startNew=on" | python3 -m json.tool | grep grade → "grade": "A+" # HTTP must redirect to HTTPS $ curl -I http://your-app.com/ → HTTP/1.1 301 Moved Permanently → Location: https://your-app.com/ # Node.js port 3000 must NOT be reachable from outside $ curl -I http://your-app.com:3000/ → Connection refused (or timeout) # Verify TLS version $ openssl s_client -connect your-app.com:443 -tls1 2>&1 | grep "handshake failure" → handshake failure (TLS 1.0 rejected)
📋 VAPT Closure Statement
🔒
Audit Closure
Node.js is bound to localhost (127.0.0.1) and proxied through a hardened Nginx configuration supporting only TLSv1.2/1.3 with strong ECDHE cipher suites. Let's Encrypt certificate is issued with auto-renewal. OCSP stapling is enabled. Direct access to port 3000 is blocked by ufw. SSL Labs reports Grade A+. HTTP traffic is unconditionally redirected to HTTPS.
05
JWT Security Best Practices
CRITICAL App Layer jsonwebtokenRS256auth.js
⚠ Issue
Common JWT flaws: weak HS256 secret, alg: none bypass, no expiry, tokens stored in localStorage (XSS-vulnerable), missing signature verification, and no token revocation strategy.
🔥 Risk
Token forgery via algorithm confusion, unlimited token lifetime enabling session persistence after logout, XSS token theft, and complete authentication bypass via alg:none attack.
🔧 Fix — Secure JWT Implementation
Terminal
$ npm install jsonwebtoken
/var/www/app/src/utils/jwt.js
const jwt = require('jsonwebtoken'); // ─── Use RS256 (asymmetric) — NOT HS256 with shared secret ─── // Generate keys: openssl genrsa -out private.key 2048 // openssl rsa -in private.key -pubout -out public.key const PRIVATE_KEY = process.env.JWT_PRIVATE_KEY; const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY; const TOKEN_OPTS = { algorithm: 'RS256', // NEVER 'HS256' with weak secrets expiresIn: '15m', // Short-lived access tokens issuer: 'your-app.com', audience: 'your-app.com', }; const REFRESH_OPTS = { ...TOKEN_OPTS, expiresIn: '7d' }; function signAccessToken(payload) { return jwt.sign(payload, PRIVATE_KEY, TOKEN_OPTS); } function verifyToken(token) { // CRITICAL: always specify algorithms — prevents 'none' bypass return jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'], // whitelist ONLY RS256 issuer: 'your-app.com', audience: 'your-app.com', }); } module.exports = { signAccessToken, verifyToken };
/var/www/app/src/middleware/auth.js — Secure token transport (httpOnly cookie)
// ─── Store JWT in httpOnly cookie (not localStorage!) ──────── res.cookie('accessToken', token, { httpOnly: true, // Not accessible via JS — blocks XSS theft secure: true, // HTTPS only sameSite: 'strict', // CSRF protection maxAge: 15 * 60 * 1000, // 15 minutes path: '/', }); // ─── Token blacklist for revocation (use Redis in prod) ─────── const tokenBlacklist = new Set(); async function revokeToken(token) { tokenBlacklist.add(token); // Redis: SET token 1 EX 900 } async function isRevoked(token) { return tokenBlacklist.has(token); // Redis: EXISTS token }
👥 Responsible
Node.js Developer
🔒 Key Security Checklist
  • Use RS256 (asymmetric) not HS256
  • Access token expiry ≤ 15 minutes
  • Refresh token rotation on use
  • Store in httpOnly cookie, not localStorage
  • Whitelist algorithm in verify()
  • Validate issuer and audience claims
  • Implement token revocation (Redis)
✅ Verification
# Tamper with JWT — change payload without re-signing $ TOKEN="eyJ..." # your valid token # Modify payload in middle segment, verify rejection $ curl -H "Cookie: accessToken=eyJhbGciOiJub25lIn0.TAMPERED." https://your-app.com/api/profile → 401 Unauthorized # Confirm token is httpOnly (not in document.cookie) # In browser DevTools console: > document.cookie → "" (empty — httpOnly cookie not visible to JS) # Check 'none' algorithm bypass is blocked $ node -e "const jwt=require('jsonwebtoken'); try { jwt.verify('eyJhbGciOiJub25lIn0.eyJ1c2VyIjoiYWRtaW4ifQ.', 'any', {algorithms:['RS256']}); } catch(e) { console.log('BLOCKED:', e.message); }" → BLOCKED: invalid algorithm
📋 VAPT Closure Statement
🔒
Audit Closure
JWT implementation has been hardened: RS256 asymmetric signing replaces HS256, algorithm whitelist in jwt.verify() prevents the alg:none bypass, access tokens expire in 15 minutes, tokens are stored in httpOnly/Secure/SameSite=Strict cookies preventing XSS theft, and issuer/audience claims are validated. A token revocation mechanism via in-memory Set (production: Redis) handles explicit logout.
06
Session & Cookie Security
HIGH App Layer express-sessionconnect-redis
⚠ Issue
Default express-session stores sessions in-memory (lost on restart, not scalable), uses a weak default secret, and does not set Secure/HttpOnly/SameSite cookie attributes. Session fixation attacks are possible.
🔥 Risk
Session hijacking via cookie theft, session fixation attacks, XSS-based session token exfiltration, and CSRF attacks via missing SameSite attribute.
🔧 Fix — Hardened express-session with Redis store
Terminal
$ npm install express-session connect-redis redis
/var/www/app/src/middleware/session.js
const session = require('express-session'); const RedisStore = require('connect-redis'); const { createClient } = require('redis'); const redisClient = createClient({ url: process.env.REDIS_URL }); redisClient.connect(); module.exports = session({ store: new RedisStore({ client: redisClient }), secret: process.env.SESSION_SECRET, // ≥ 64-char random string name: '__Host-sid', // __Host- prefix: HTTPS-only, no domain resave: false, saveUninitialized: false, rolling: true, // reset expiry on activity cookie: { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: 30 * 60 * 1000, // 30 minutes path: '/', }, genid: () => require('crypto').randomBytes(32).toString('hex'), }); // ─── Regenerate session ID on privilege escalation ──────────── // Call this after successful login to prevent session fixation: // req.session.regenerate(err => { ... set user data ... })
👥 Responsible
Node.js DeveloperLinux Admin (Redis)
✅ Verification
# Check Set-Cookie header attributes $ curl -I -X POST https://your-app.com/api/auth/login -d '{"email":"test@test.com","password":"pass"}' → set-cookie: __Host-sid=...; Path=/; HttpOnly; Secure; SameSite=Strict # Confirm session not accessible via JS # In browser console: document.cookie → should NOT contain __Host-sid
📋 VAPT Closure Statement
🔒
Audit Closure
Session management hardened: in-memory store replaced with Redis for persistence and horizontal scalability. Cookie attributes set to HttpOnly, Secure, SameSite=Strict. Session name uses __Host- prefix enforcing HTTPS-only scope. Session ID regenerated on login to prevent fixation. 30-minute idle timeout enforced with rolling expiry.
07
CSRF Protection
HIGH App Layer csrf-csrfdouble-submit
⚠ Issue
State-changing API endpoints (POST, PUT, DELETE) without CSRF protection can be abused by malicious sites to perform actions on behalf of authenticated users by exploiting their active session cookies.
🔥 Risk
Unauthorized account changes, password/email modification, financial transactions, and data deletion performed by attackers using victim's authenticated session.
🔧 Fix — Double-Submit CSRF Token Pattern
Terminal
$ npm install csrf-csrf
/var/www/app/src/middleware/csrf.js
const { doubleCsrf } = require('csrf-csrf'); const { generateToken, doubleCsrfProtection } = doubleCsrf({ getSecret: () => process.env.CSRF_SECRET, cookieName: '__Host-psifi.x-csrf-token', cookieOptions: { sameSite: 'strict', path: '/', secure: true, httpOnly: true, }, size: 64, // token length in bytes ignoredMethods: ['GET', 'HEAD', 'OPTIONS'], getTokenFromRequest: (req) => req.headers['x-csrf-token'] || req.body?._csrf, }); module.exports = { generateToken, doubleCsrfProtection };
/var/www/app/app.js — Apply CSRF middleware
const { generateToken, doubleCsrfProtection } = require('./src/middleware/csrf'); // Apply CSRF protection to all state-changing routes app.use('/api', doubleCsrfProtection); // Expose CSRF token to frontend (GET endpoint) app.get('/api/csrf-token', (req, res) => { res.json({ csrfToken: generateToken(req, res) }); });
👥 Responsible
Node.js Developer
✅ Verification
# POST without CSRF token — must be rejected $ curl -s -X POST https://your-app.com/api/user/update -H "Cookie: __Host-sid=valid" -d '{"email":"new@email.com"}' → 403 Forbidden: "invalid csrf token" # POST with valid CSRF token — must succeed $ TOKEN=$(curl -s https://your-app.com/api/csrf-token | python3 -c "import sys,json; print(json.load(sys.stdin)['csrfToken'])") $ curl -s -X POST https://your-app.com/api/user/update -H "x-csrf-token: $TOKEN" -H "Cookie: ..." → 200 OK
📋 VAPT Closure Statement
🔒
Audit Closure
CSRF protection implemented via the double-submit cookie pattern using the csrf-csrf package. All state-changing API endpoints (POST, PUT, DELETE, PATCH) require a valid CSRF token in the X-CSRF-Token header. Tokens are scoped to __Host- prefixed cookies (HTTPS-only). Cross-origin requests without valid tokens receive HTTP 403. SameSite=Strict cookies provide an additional layer of protection.
08
Input Validation & Sanitization
CRITICAL App Layer express-validatorzodhpp
⚠ Issue
Accepting user input without validation and sanitization enables XSS, injection attacks (SQL, NoSQL, LDAP, command), prototype pollution, and parameter pollution attacks.
🔥 Risk
Stored/reflected XSS, SQL/NoSQL injection, server-side template injection, command injection, parameter pollution leading to business logic bypass.
🔧 Fix 1 — express-validator for route-level validation
Terminal
$ npm install express-validator hpp
/var/www/app/src/validators/auth.validator.js
const { body, validationResult } = require('express-validator'); // ─── Login validation rules ─────────────────────────────────── const loginRules = [ body('email') .isEmail().normalizeEmail() .withMessage('Valid email required'), body('password') .isLength({ min: 8, max: 128 }) .trim() .withMessage('Password must be 8-128 characters'), ]; // ─── Validation result handler ──────────────────────────────── const validate = (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(422).json({ error: 'Validation failed', details: errors.array({ onlyFirstError: true }), }); } next(); }; module.exports = { loginRules, validate };
🔧 Fix 2 — Global middleware: HPP & Prototype Pollution
/var/www/app/app.js
const hpp = require('hpp'); // Prevent HTTP Parameter Pollution (foo=bar&foo=baz exploits) app.use(hpp()); // Limit JSON body size to prevent DoS via large payloads app.use(express.json({ limit: '10kb' })); app.use(express.urlencoded({ extended: false, limit: '10kb' })); // Prevent prototype pollution from JSON input app.use((req, res, next) => { if (req.body && typeof req.body === 'object') { const dangerous = ['__proto__', 'constructor', 'prototype']; const sanitize = (obj) => { dangerous.forEach(key => { delete obj[key]; }); Object.values(obj).forEach(v => v && typeof v === 'object' && sanitize(v)); }; sanitize(req.body); } next(); });
👥 Responsible
Node.js Developer
✅ Verification
# Test XSS payload rejection $ curl -s -X POST https://your-app.com/api/auth/login -H "Content-Type: application/json" -d '{"email":"<script>alert(1)</script>","password":"short"}' → {"error":"Validation failed","details":[{"msg":"Valid email required",...}]} # Test large payload rejection (DoS protection) $ python3 -c "import requests; r=requests.post('https://your-app.com/api/', json={'x':'A'*100000}); print(r.status_code)" → 413 Payload Too Large # Test prototype pollution blocked $ curl -s -X POST https://your-app.com/api/data -H "Content-Type: application/json" -d '{"__proto__":{"admin":true}}' → 422 Validation Failed or harmless (proto stripped)
📋 VAPT Closure Statement
🔒
Audit Closure
Input validation is enforced at the route level via express-validator with field-level rules (email normalization, length limits, type checks). Request body size is capped at 10kb. HTTP Parameter Pollution is mitigated via the hpp middleware. Prototype pollution vectors (__proto__, constructor, prototype keys) are stripped from all incoming JSON payloads by a dedicated sanitization middleware.
09
SQL & NoSQL Injection Prevention
CRITICAL App Layer pg / mysql2mongooseexpress-mongo-sanitize
⚠ Issue
String concatenation in database queries allows injection attacks. MongoDB is vulnerable to operator injection via $where, $gt, $regex in user-supplied JSON input.
🔥 Risk
Full database compromise, authentication bypass (' OR '1'='1), data exfiltration, record deletion, and privilege escalation via injected operators.
🔧 Fix 1 — Parameterized Queries (PostgreSQL / MySQL)
/var/www/app/src/db/queries.js — CORRECT vs WRONG
// ✗ NEVER: string concatenation — VULNERABLE TO SQLi const query = `SELECT * FROM users WHERE email = '${req.body.email}'`; // ✓ ALWAYS: parameterized queries // PostgreSQL (pg) const { rows } = await pool.query( 'SELECT * FROM users WHERE email = $1 AND active = $2', [req.body.email, true] ); // MySQL (mysql2) const [rows] = await pool.execute( 'SELECT * FROM users WHERE email = ? AND active = ?', [req.body.email, 1] ); // ORM (Prisma) — always safe, never raw string input const user = await prisma.user.findFirst({ where: { email: req.body.email, active: true } });
🔧 Fix 2 — MongoDB / Mongoose Injection Prevention
Terminal
$ npm install express-mongo-sanitize
/var/www/app/app.js
const mongoSanitize = require('express-mongo-sanitize'); // Strip MongoDB operators ($, .) from req.body, req.query, req.params app.use(mongoSanitize({ replaceWith: '_', // replace dangerous chars onSanitize: ({ req, key }) => { console.warn(`NOSQL INJECTION ATTEMPT: key=${key} ip=${req.ip}`); } })); // ─── Mongoose: NEVER use $where or raw string queries ───────── // VULNERABLE: User.find({ '$where': 'this.credits > 0' }); // allows JS exec // SAFE: use field-level operators, never $where User.find({ credits: { $gt: 0 } });
👥 Responsible
Node.js Developer
✅ Verification
# Test SQL injection attempt (PostgreSQL) $ curl -s -X POST https://your-app.com/api/auth/login -H "Content-Type: application/json" -d '{"email":"admin'\''--","password":"anything"}' → 422 Validation failed or 401 Unauthorized (never 200) # Test NoSQL operator injection (MongoDB) $ curl -s -X POST https://your-app.com/api/auth/login -H "Content-Type: application/json" -d '{"email":{"$gt":""},"password":{"$gt":""}}' → 401 Unauthorized (operators stripped by mongoSanitize)
📋 VAPT Closure Statement
🔒
Audit Closure
SQL injection is prevented via parameterized queries (pg/mysql2 parameterized APIs / Prisma ORM) — no string concatenation in database queries. NoSQL injection is mitigated by express-mongo-sanitize, which strips MongoDB operators from all request inputs, and by disabling $where queries in Mongoose. Injection attempts are logged with IP address for forensic tracking.
10
File Upload Security
HIGH App Layer multerfile-typeuuid
⚠ Issue
Unrestricted file uploads allow attackers to upload executable scripts (webshells), oversized files (DoS), and files with directory traversal paths in their name, leading to server compromise.
🔥 Risk
Remote Code Execution via uploaded webshells, DoS via multi-GB uploads, path traversal overwriting system files, and malware distribution via hosted files.
🔧 Fix — Hardened Multer with real MIME type validation
Terminal
$ npm install multer file-type uuid
/var/www/app/src/middleware/upload.js
const multer = require('multer'); const { fileTypeFromBuffer } = require('file-type'); const { v4: uuidv4 } = require('uuid'); const path = require('path'); const fs = require('fs'); // Allowed real MIME types (checked from file magic bytes) const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'application/pdf']); const MAX_SIZE = 5 * 1024 * 1024; // 5 MB // Upload to a directory OUTSIDE web root const UPLOAD_DIR = '/var/uploads/app'; const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, UPLOAD_DIR); }, filename: (req, file, cb) => { // UUID filename: prevents path traversal & overwrites const ext = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.pdf']; const fileExt = path.extname(file.originalname).toLowerCase(); if (!ext.includes(fileExt)) return cb(new Error('Invalid extension')); cb(null, uuidv4() + fileExt); }, }); const upload = multer({ storage, limits: { fileSize: MAX_SIZE, files: 1 }, fileFilter: (req, file, cb) => { // Check MIME from Content-Type header (first pass) if (!ALLOWED_TYPES.has(file.mimetype)) { return cb(new Error('File type not allowed')); } cb(null, true); }, }); // ─── Post-upload: verify REAL type via magic bytes ──────────── const verifyFileType = async (req, res, next) => { if (!req.file) return next(); const buffer = fs.readFileSync(req.file.path); const detected = await fileTypeFromBuffer(buffer); if (!detected || !ALLOWED_TYPES.has(detected.mime)) { fs.unlinkSync(req.file.path); // delete malicious file return res.status(422).json({ error: 'File content does not match type' }); } next(); }; module.exports = { upload, verifyFileType };
👥 Responsible
Node.js DeveloperLinux Admin
✅ Verification
# Upload a PHP file disguised as .jpg $ curl -s -X POST https://your-app.com/api/upload \ -F "file=@webshell.php;type=image/jpeg" → 422: "File content does not match type" # Upload a legitimate image — should succeed $ curl -s -X POST https://your-app.com/api/upload -F "file=@photo.jpg" → 200: {"filename": "550e8400-e29b-41d4-a716-446655440000.jpg"} # Verify uploads directory is outside web root $ curl -I https://your-app.com/../var/uploads/ → 403 or 404 (never serves files from upload dir directly)
📋 VAPT Closure Statement
🔒
Audit Closure
File upload hardened via Multer with a 5MB size limit, extension allowlist, and Content-Type MIME validation. Post-upload, real file type is verified from file magic bytes using the file-type library — files whose content mismatches their declared type are rejected and deleted. Filenames are replaced with UUIDs to prevent path traversal. Uploads are stored outside the web root in /var/uploads/app with no direct HTTP access.
11
Environment Variables & Secrets Management
CRITICAL App + Server .envdotenv-safe.gitignore
⚠ Issue
Hardcoded secrets in source code, .env files committed to Git, secrets in environment variable dumps via error pages, and missing validation of required env vars at startup.
🔥 Risk
Database credential exposure via Git history, API key leakage enabling cloud resource abuse, production secrets committed to public repositories (irreversible).
🔧 Fix — dotenv-safe with validation & .gitignore
Terminal
$ npm install dotenv-safe
/var/www/app/.env.example (commit this — no values)
# Required environment variables — NO VALUES HERE NODE_ENV= PORT= DATABASE_URL= SESSION_SECRET= JWT_PRIVATE_KEY= JWT_PUBLIC_KEY= CSRF_SECRET= REDIS_URL= ALLOWED_ORIGINS=
/var/www/app/.env (NEVER commit — local/server only)
# Actual values — keep outside Git history NODE_ENV=production PORT=3000 DATABASE_URL=postgresql://user:STRONGPASS@127.0.0.1:5432/appdb SESSION_SECRET=64-char-random-hex-here JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..." JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n..." CSRF_SECRET=64-char-random-hex-here REDIS_URL=redis://127.0.0.1:6379
/var/www/app/.gitignore
# CRITICAL — Never commit these .env .env.local .env.production *.key *.pem node_modules/ logs/
/var/www/app/app.js — Load with validation
// dotenv-safe: throws if any .env.example variable is missing require('dotenv-safe').config({ path: '.env', example: '.env.example', allowEmptyValues: false, // reject empty required vars }); // Set strict file permissions on .env // Run: chmod 600 /var/www/app/.env && chown node:node /var/www/app/.env // Audit: ensure no secrets in error responses if (process.env.NODE_ENV === 'production') { // Never print process.env in logs console.log = () => {}; // silence in prod if using structured logging }
👥 Responsible
Node.js DeveloperLinux Admin
✅ Verification
# Verify .env is NOT in Git history $ git log --all --full-history -- .env → (empty — no commits found) # Verify .env not accessible via web $ curl -I https://your-app.com/.env → 403 or 404 # Check file permissions $ stat -c "%a %U %n" /var/www/app/.env → 600 node /var/www/app/.env # Scan for hardcoded secrets in codebase $ npm install -g trufflehog $ trufflehog filesystem /var/www/app/src/ --only-verified → (no secrets found)
📋 VAPT Closure Statement
🔒
Audit Closure
All secrets (database credentials, JWT keys, session secrets) are managed via environment variables loaded by dotenv-safe, which validates all required variables at startup. The .env file is excluded from version control via .gitignore, has file permissions 600, and is confirmed absent from Git history. TruffleHog scan confirms no hardcoded secrets in the source tree. The .env file is not accessible via HTTP (returns 403).
12
Dependency Security & Supply Chain
HIGH DevSecOps npm auditsnykpackage-lock.json
⚠ Issue
Node.js applications have hundreds of transitive dependencies. Known CVEs in packages like lodash, express, axios, or dev dependencies leak to production. Malicious packages via typosquatting/dependency confusion cause supply-chain attacks.
🔥 Risk
RCE via vulnerable dependencies (e.g. Log4Shell-style), data exfiltration via malicious packages, prototype pollution via lodash CVEs, and ReDoS via vulnerable regex in packages.
🔧 Fix — Automated security audit pipeline
Terminal — Audit and fix
# Run built-in npm security audit $ npm audit $ npm audit --audit-level=high # fail CI on high/critical # Auto-fix non-breaking vulnerabilities $ npm audit fix # Fix breaking changes (review before applying) $ npm audit fix --force # Install Snyk CLI (free OSS tier, more detail than npm audit) $ npm install -g snyk $ snyk auth $ snyk test # scan for vulnerabilities $ snyk monitor # continuous monitoring # Check for outdated packages $ npm outdated # Use exact versions — pin dependencies (no ^ or ~ in prod) $ npm install --save-exact express helmet # Lock file integrity check $ npm ci --ignore-scripts # use package-lock.json exactly
/var/www/app/package.json — Security best practices
{ "scripts": { "audit:ci": "npm audit --audit-level=high", "start:prod": "node --disable-proto=throw app.js" }, "engines": { "node": ">=20.0.0" // Pin to LTS Node version } }
/var/www/app/.npmrc — Disable pre/post install scripts in prod
# Prevent malicious postinstall scripts from running ignore-scripts=true audit=true fund=false
👥 Responsible
Node.js DeveloperDevOps/SysAdmin
✅ Verification
# Run audit — zero high/critical should remain $ npm audit --audit-level=high → found 0 vulnerabilities # Verify Node version is LTS $ node --version → v20.x.x (Active LTS) # Confirm no ignored scripts running on install $ cat .npmrc | grep ignore-scripts → ignore-scripts=true # Confirm package-lock.json is committed (reproducible builds) $ git status package-lock.json → (tracked in Git)
📋 VAPT Closure Statement
🔒
Audit Closure
Dependency security is maintained via npm audit (integrated into CI pipeline at high severity threshold) and Snyk continuous monitoring. All production dependencies are pinned to exact versions. ignore-scripts=true in .npmrc prevents postinstall script abuse. The application runs on Node.js LTS. package-lock.json is committed for reproducible builds. Current audit result: 0 high or critical vulnerabilities.
13
Error Handling & Information Disclosure Prevention
MEDIUM App Layer errorHandler.jsNODE_ENV
⚠ Issue
Default Express error handler returns full stack traces to the client in production, exposing file paths, library versions, internal logic, and database query details to attackers.
🔥 Risk
Internal path disclosure, framework/library fingerprinting, database schema leakage via error messages, and enumeration of internal services via detailed error responses.
🔧 Fix — Production Error Handler
/var/www/app/src/middleware/errorHandler.js
const logger = require('../utils/logger'); /** * Centralized error handler — MUST be last middleware registered. * Never leaks stack traces or internal details in production. */ module.exports = (err, req, res, next) => { const isProd = process.env.NODE_ENV === 'production'; // Always log the full error server-side (for forensics) logger.error({ message: err.message, stack: err.stack, method: req.method, url: req.originalUrl, ip: req.ip, userAgent: req.headers['user-agent'], userId: req.user?.id, requestId: req.headers['x-request-id'], }); // Operational vs programmer errors const statusCode = err.statusCode || err.status || 500; const isOperational = err.isOperational === true; // Production: generic message only if (isProd) { res.status(statusCode).json({ error: isOperational ? err.message : 'An unexpected error occurred', requestId: req.headers['x-request-id'] || 'N/A', }); } else { // Development: full details res.status(statusCode).json({ error: err.message, stack: err.stack, details: err.details, }); } }; // ─── Unhandled promise rejections & exceptions ──────────────── process.on('unhandledRejection', (reason) => { logger.error('Unhandled Rejection:', reason); // In production, PM2 will restart the process process.exit(1); }); process.on('uncaughtException', (err) => { logger.error('Uncaught Exception:', err); process.exit(1); });
/var/www/app/app.js — Register as LAST middleware
// ... all routes above ... // Error handler MUST be the last app.use() call app.use(require('./src/middleware/errorHandler'));
👥 Responsible
Node.js Developer
✅ Verification
# Trigger a 500 error in production — must not show stack $ curl -s https://your-app.com/api/trigger-error | python3 -m json.tool → {"error":"An unexpected error occurred","requestId":"abc-123"} → NO stack trace, NO file paths, NO library names # Check server logs for full error (must be there for forensics) $ tail -5 /var/log/app/error.log → {"level":"error","message":"...","stack":"Error: ...\n at /var/www/app/..."}
📋 VAPT Closure Statement
🔒
Audit Closure
A centralized error handler is registered as the final Express middleware. In production (NODE_ENV=production), all 5xx errors return a generic message with a request correlation ID. Stack traces, file paths, and library details are stripped from responses. Full error details are logged server-side with request metadata for incident response. Unhandled rejections and uncaught exceptions terminate the process for PM2-managed restart.
14
Process Security & PM2 Hardening
HIGH Server Level PM2systemdnon-root
⚠ Issue
Running Node.js as root means any RCE vulnerability gives attackers full system control. No process manager means crashes leave the app down. No cluster mode wastes CPU and creates single-points-of-failure.
🔥 Risk
Root-level code execution via app RCE vulnerabilities, persistent downtime from unhandled crashes, and single-process DoS vulnerability under load.
🔧 Fix — Dedicated non-root user + PM2
Terminal — Create dedicated app user
# Create a locked-down user for the Node.js process $ useradd --system --no-create-home --shell /usr/sbin/nologin nodeapp $ chown -R nodeapp:nodeapp /var/www/app $ chmod -R 750 /var/www/app $ chmod 600 /var/www/app/.env # Install PM2 globally $ npm install -g pm2 # Start app as nodeapp user $ sudo -u nodeapp pm2 start ecosystem.config.js $ pm2 save $ pm2 startup systemd -u nodeapp --hp /home/nodeapp
/var/www/app/ecosystem.config.js
module.exports = { apps: [{ name: 'your-app', script: './app.js', instances: 'max', // cluster mode — 1 per CPU core exec_mode: 'cluster', user: 'nodeapp', // non-root user env_production: { NODE_ENV: 'production', PORT: 3000, NODE_OPTIONS: '--disable-proto=throw', // block prototype pollution }, max_memory_restart: '512M', // auto-restart on memory leak autorestart: true, watch: false, // NEVER true in production merge_logs: true, log_file: '/var/log/app/combined.log', error_file: '/var/log/app/error.log', out_file: '/var/log/app/out.log', log_date_format: 'YYYY-MM-DD HH:mm:ss Z', }] };
👥 Responsible
Linux/System AdminNode.js Developer
✅ Verification
# Verify Node.js process runs as non-root $ ps aux | grep node → nodeapp ... node app.js (NOT root) # Verify PM2 cluster mode is active $ pm2 list → your-app | cluster | online × N (N = CPU cores) # Verify prototype pollution is blocked at runtime $ node --disable-proto=throw -e "({})['__proto__']['test'] = 1" → TypeError: Object.setPrototypeOf is not allowed (or similar) # Test auto-restart (kill a worker) $ kill -9 $(pgrep -f 'your-app' | head -1) → PM2 automatically respawns the worker within seconds
📋 VAPT Closure Statement
🔒
Audit Closure
Node.js process runs as a dedicated system user nodeapp with nologin shell and no home directory, preventing privilege escalation via RCE. PM2 manages the process in cluster mode (one worker per CPU core) with auto-restart on crash or OOM. --disable-proto=throw Node flag blocks prototype pollution at the runtime level. Application directory permissions are 750 with .env at 600.
15
Structured Logging & Security Monitoring
MEDIUM App + Server winstonmorganrequest-id
⚠ Issue
Using console.log() in production produces unstructured, unsearchable output with no timestamps, severity levels, or request correlation. Security events like auth failures and injection attempts go unlogged.
🔥 Risk
Inability to detect ongoing attacks, failed forensic investigation post-breach, violation of audit log requirements (ISO 27001, CERT-In IT Act 2000 Section 43A, 72A).
🔧 Fix — Winston structured logger + Morgan HTTP logs
Terminal
$ npm install winston morgan uuid
/var/www/app/src/utils/logger.js
const winston = require('winston'); const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json() // machine-parseable JSON logs ), defaultMeta: { service: 'your-app' }, transports: [ new winston.transports.File({ filename: '/var/log/app/error.log', level: 'error', maxsize: 10 * 1024 * 1024, // 10MB per file maxFiles: 30, // 30 day retention tailable: true, }), new winston.transports.File({ filename: '/var/log/app/combined.log', maxsize: 50 * 1024 * 1024, maxFiles: 30, }), ], }); // Console output in development only if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.simple(), })); } module.exports = logger;
/var/www/app/app.js — Add security event logging
const morgan = require('morgan'); const { v4: uuid } = require('uuid'); const logger = require('./src/utils/logger'); // Request ID for correlation app.use((req, res, next) => { req.id = uuid(); res.setHeader('X-Request-ID', req.id); next(); }); // HTTP access log via Morgan → Winston app.use(morgan(':method :url :status :res[content-length] - :response-time ms :req[x-forwarded-for]', { stream: { write: (msg) => logger.info(msg.trim()) }, skip: (req, res) => res.statusCode < 400, // log errors only in prod })); // ─── Security event logger helper ──────────────────────────── const secLog = (event, req, extra = {}) => { logger.warn({ event, ip: req.ip, ua: req.headers['user-agent'], requestId: req.id, ...extra }); }; // Usage: secLog('LOGIN_FAILED', req, { email: req.body.email }); // Usage: secLog('RATE_LIMITED', req); // Usage: secLog('CSRF_VIOLATION', req); module.exports.secLog = secLog;
👥 Responsible
Node.js DeveloperLinux Admin
✅ Verification
# Verify JSON structured logs are being written $ tail -3 /var/log/app/combined.log | python3 -m json.tool → {"level":"info","message":"GET /api/health 200 ...","timestamp":"2026-04-21..."} # Trigger login failure — should appear in logs $ curl -X POST https://your-app.com/api/auth/login -d '{"email":"x","password":"bad"}' $ grep "LOGIN_FAILED" /var/log/app/combined.log | tail -1 → {"event":"LOGIN_FAILED","ip":"...","email":"x","timestamp":"..."} # Verify X-Request-ID in responses $ curl -I https://your-app.com/api/health | grep -i x-request-id → x-request-id: 550e8400-e29b-41d4-a716-446655440000
📋 VAPT Closure Statement
🔒
Audit Closure
Structured JSON logging via Winston is implemented with 30-day file rotation and 10MB/50MB size limits. Every request carries a UUID correlation header (X-Request-ID). Security events (login failures, rate limiting, CSRF violations, injection attempts) are explicitly logged with IP, User-Agent, and request ID for forensic traceability. Logs are stored in /var/log/app/ outside the application directory with restricted permissions.
16
Security Audit Schedule & Backup Strategy
MEDIUM Server + DevSecOps cronpg_dumpgpg
⚠ Issue
Without scheduled audits, new vulnerabilities in dependencies go undetected. Unencrypted backups or backups stored inside the web root expose full database dumps. No backup means no recovery from ransomware or human error.
🔥 Risk
Undetected vulnerability windows (Zero-day dwell time), complete data loss without backups, and credential exposure via publicly accessible backup files.
🔧 Fix 1 — Automated encrypted database backup
/usr/local/bin/nodeapp-backup.sh
#!/bin/bash # Node.js App Encrypted Backup — runs at 3 AM daily # Cron: 0 3 * * * /usr/local/bin/nodeapp-backup.sh BACKUP_DIR="/var/backups/nodeapp" DATE=$(date +%Y%m%d_%H%M%S) DB_URL="$DATABASE_URL" GPG_ID="backup@your-app.com" KEEP_DAYS=30 mkdir -p $BACKUP_DIR chmod 700 $BACKUP_DIR # PostgreSQL dump → gzip → GPG encrypt pg_dump $DB_URL | \ gzip | \ gpg --recipient $GPG_ID --encrypt \ > $BACKUP_DIR/db_${DATE}.sql.gz.gpg # App source backup (exclude node_modules, logs, .env) tar -czf - \ --exclude=/var/www/app/node_modules \ --exclude=/var/www/app/logs \ --exclude=/var/www/app/.env \ /var/www/app | \ gpg --recipient $GPG_ID --encrypt \ > $BACKUP_DIR/app_${DATE}.tar.gz.gpg # Set strict permissions chmod 600 $BACKUP_DIR/*.gpg chown root:root $BACKUP_DIR/*.gpg # Remove old backups find $BACKUP_DIR -name "*.gpg" -mtime +$KEEP_DAYS -delete echo "$(date): Backup OK" >> /var/log/nodeapp-backup.log
🔧 Fix 2 — Security Audit Schedule (cron)
Crontab — Periodic security checks
# Run daily npm audit at 6 AM — email results if vulnerabilities found 0 6 * * * cd /var/www/app && npm audit --audit-level=high 2>&1 | mail -s "npm-audit $(date +%F)" sec@your-app.com # Weekly: check for outdated packages 0 8 * * 1 cd /var/www/app && npm outdated >> /var/log/nodeapp-outdated.log 2>&1 # Monthly: run Snyk full scan 0 7 1 * * cd /var/www/app && npx snyk test --all-projects >> /var/log/snyk-scan.log 2>&1 # Daily: backup 0 3 * * * /usr/local/bin/nodeapp-backup.sh # Weekly: verify TLS certificate expiry (alert if < 30 days) 0 9 * * 1 echo | openssl s_client -connect your-app.com:443 2>/dev/null | openssl x509 -noout -dates | grep notAfter
👥 Responsible
Linux/System AdminNode.js Developer
✅ Verification
# Test backup script runs successfully $ /usr/local/bin/nodeapp-backup.sh $ ls -lh /var/backups/nodeapp/ → -rw------- 1 root root 12M ... db_20260421_030001.sql.gz.gpg # Backup decrypt test (restore drill) $ gpg --decrypt /var/backups/nodeapp/db_20260421_030001.sql.gz.gpg | gunzip | head -5 → -- PostgreSQL database dump... # Confirm backup not web-accessible $ curl -I https://your-app.com/../var/backups/ → 403 / 404
📋 VAPT Closure Statement
🔒
Audit Closure
Automated daily GPG-encrypted database and application backups are configured via cron, stored in /var/backups/nodeapp/ (permissions 700/600) outside the web root. A 30-day retention policy is enforced with automatic pruning. Restore procedure has been tested and confirmed operational. Automated daily npm audit, weekly outdated-package check, and monthly Snyk scan are scheduled. TLS certificate expiry is monitored weekly.

⬡ Security Controls Master Checklist

Track remediation status for VAPT report closure

#ControlSeverityLayerKey Package / ToolStatus
01Helmet.js — Security HeadersHIGHApphelmet☐ Open
02Rate Limiting & DDoS ProtectionCRITICALApp + Serverexpress-rate-limit☐ Open
03CORS ConfigurationHIGHAppcors☐ Open
04HTTPS / TLS & Nginx Reverse ProxyCRITICALServercertbot nginx☐ Open
05JWT Security (RS256, alg whitelist)CRITICALAppjsonwebtoken☐ Open
06Session & Cookie SecurityHIGHAppexpress-session redis☐ Open
07CSRF ProtectionHIGHAppcsrf-csrf☐ Open
08Input Validation & SanitizationCRITICALAppexpress-validator hpp☐ Open
09SQL / NoSQL Injection PreventionCRITICALAppparameterized queries☐ Open
10File Upload SecurityHIGHAppmulter file-type☐ Open
11Environment Variables & SecretsCRITICALApp + Serverdotenv-safe☐ Open
12Dependency Security & Supply ChainHIGHDevSecOpsnpm audit snyk☐ Open
13Error Handling & Info DisclosureMEDIUMApperrorHandler.js☐ Open
14Process Security & PM2 HardeningHIGHServerpm2 non-root☐ Open
15Structured Logging & MonitoringMEDIUMApp + Serverwinston morgan☐ Open
16Security Audit & Backup StrategyMEDIUMServer + DevOpscron gpg☐ Open
Critical: 5 controls
High: 8 controls
Medium: 3 controls
OWASP Node.js Security Cheatsheet · April 2026