HYPERDRIFT

Security Lessons: Why Your Node.js Apps Are Probably Exposed

Security Lessons: Why Your Node.js Apps Are Probably Exposed

A post-incident writeup from a real production security audit

The Discovery

During a routine security audit of our VPS running multiple Next.js and Nuxt applications, we discovered something alarming: all our Node.js apps were directly accessible from the internet, completely bypassing Nginx, SSL termination, and any rate limiting we had in place.

$ ss -tlnp | grep -E '300[0-9]'
LISTEN 0.0.0.0:3000  # hyperdrift - EXPOSED
LISTEN 0.0.0.0:3001  # revela - EXPOSED  
LISTEN 0.0.0.0:3005  # intel - EXPOSED
LISTEN 0.0.0.0:3101  # revela-dev - DEV EXPOSED TO INTERNET!

That 0.0.0.0 means "listen on all network interfaces" — including the public one.

The Architecture We Thought We Had

Internet → Nginx (SSL, rate limits) → localhost:3000 → App

The Architecture We Actually Had

Internet → Nginx (SSL, rate limits) → localhost:3000 → App
Internet ──────────────────────────→ 0.0.0.0:3000 → App  ← OOPS

Anyone could hit http://your-server-ip:3000 directly and bypass all our security layers.

Why This Happens

The Default Behavior Problem

Most Node.js frameworks default to binding on 0.0.0.0:

Next.js standalone server.js:

const hostname = process.env.HOSTNAME || '0.0.0.0'  // DEFAULT IS DANGEROUS

Nuxt/Nitro:

const host = process.env.NITRO_HOST || process.env.HOST;  // undefined = 0.0.0.0

Express:

app.listen(3000)  // Defaults to 0.0.0.0

The PM2 Trap

When using PM2 to manage processes, environment variables don't always propagate the way you expect:

# This DOES NOT work reliably
HOST=127.0.0.1 pm2 restart app --update-env

# The app may not see the HOST variable depending on how it was originally started

The Fix

1. For Next.js Standalone (output: 'standalone')

The standalone server respects HOSTNAME:

// In your PM2 ecosystem or environment
env: {
  HOSTNAME: "127.0.0.1",
  PORT: 3000
}

2. For Next.js with next start

You need the -H flag — environment variables alone won't work:

# In package.json or PM2 config
next start -H 127.0.0.1 -p 3000

3. For Nuxt/Nitro

Set NITRO_HOST or HOST:

env: {
  NITRO_HOST: "127.0.0.1",
  HOST: "127.0.0.1",
  PORT: 3000
}

4. For Express/Generic Node

Explicitly bind to localhost:

// BAD
app.listen(3000)

// GOOD
app.listen(3000, '127.0.0.1')

Our PM2 Ecosystem Fix

// ecosystem.config.js
module.exports = {
  apps: [{
    name: "myapp",
    script: "npm",
    args: "start",
    env: {
      HOST: "127.0.0.1",
      HOSTNAME: "127.0.0.1",
      NITRO_HOST: "127.0.0.1",  // For Nuxt apps
      PORT: 3000,
      NODE_ENV: "production"
    }
  }]
}

For next start apps that ignore environment variables:

# In Ansible/deployment config
script: "npm"
args: "start -- -H 127.0.0.1"

Verification

After fixing, all apps should bind to 127.0.0.1:

$ ss -tlnp | grep -E '300[0-9]'
LISTEN 127.0.0.1:3000  # ✓ Only accessible via localhost
LISTEN 127.0.0.1:3001  # ✓ 
LISTEN 127.0.0.1:3005  # ✓
LISTEN 127.0.0.1:3101  # ✓

Other Findings From Our Audit

SSH: PermitRootLogin was enabled

Even with password auth disabled, PermitRootLogin yes is risky:

  • If a private key is ever compromised, attacker gets root immediately
  • No audit trail of which user performed actions
# /etc/ssh/sshd_config
PermitRootLogin no  # Always disable this

Active Brute Force Attempts

Our fail2ban logs showed constant attacks:

root     ssh:notty    152.42.139.163   Feb 5 22:42
root     ssh:notty    188.166.9.61     Feb 5 22:42
nagios   ssh:notty    167.172.34.51    Feb 5 22:17
hadoop   ssh:notty    159.223.218.92   Feb 5 22:12

196 failed attempts, 33 IPs banned. The internet is hostile.

Pending Security Updates

openssh-client/server  9.6p1-3ubuntu13.13 → 13.14
linux-*                6.8.0-90 → 6.8.0-100
python3.12             (security tagged)
libglib2.0             (security tagged)

Unattended-upgrades was enabled but security updates were still pending — check your configs actually work.

Security Checklist for Node.js on VPS

  • All apps bind to 127.0.0.1, not 0.0.0.0
  • Nginx is the only thing on ports 80/443
  • PermitRootLogin no in SSH config
  • Password authentication disabled
  • fail2ban active and banning
  • UFW enabled with default deny
  • Unattended-upgrades configured and working
  • Dev environments not exposed to internet
  • Database ports restricted to specific IPs

Quick Audit Commands

# Check what's exposed
ss -tlnp | grep '0.0.0.0'

# Check SSH config
grep -E '^(PermitRootLogin|PasswordAuthentication)' /etc/ssh/sshd_config

# Check failed login attempts
sudo lastb | head -20

# Check pending security updates
apt list --upgradable 2>/dev/null | grep security

# Check firewall
sudo ufw status verbose

The Takeaway

Defense in depth failed because we assumed the inner layer (app binding) was secure when it wasn't.

Nginx, SSL, and firewalls are great, but if your app binds to 0.0.0.0, attackers can bypass all of it. The fix is simple — bind to 127.0.0.1 — but it requires explicit configuration in most frameworks.

Check your servers. You might be surprised.


Have questions about securing your Node.js infrastructure? Reach out on GitHub or Twitter.