Startseite / Artikel / Sicherheitshärtung von Node.js: DoS, Injection und Schutz vor Containern

Sicherheitshärtung von Node.js: DoS, Injection und Schutz vor Containern

Erlernen Sie praktische Techniken, um Node.js-Dienste vor DoS-Angriffen durch den Event-Loop, ReDoS-Angriffen, Injektionsangriffen sowie unsicheren Containern mithilfe von Rate Limiting und Helmet zu schützen.

1457 Wörter

Auch ein Backend mit hervorragender Testabdeckung und sauberer Architektur kann in kürzester Zeit außer Betrieb genommen werden, wenn es anfällig für ein „Event Loop Starvation“, Injektionsangriffe oder einen schlecht gesicherten Container-Runtime ist.

Weil Node.js auf einem eindimensionalen Event Loop ausgeführt wird, treten Sicherheitslücken in Node.js-Diensten oft nicht nur als Datenlecks auf, sondern auch als vollständige Unverfügbarkeit – also ein kompletter Dienstverweigerungsangriff.

1. Schutz des Event Loops: Verhinderung von Dienstverweigerungsangriffen (DoS)

Das gravierendste Betriebsrisiko bei Node.js ist das Blockieren des Event Loops. Wenn ein Angreifer Eingaben sendet, die den regulären Ausdrucksmotor zum Rückwärtsverfolgen in O(2^n) zwingen, oder den Server mit überdimensionierten HTTP-Anfragekörpern überlastet, kommt der Event Loop zum Stillstand. Sobald er blockiert ist, reagiert der Prozess nicht mehr auf jede Anfrage von jedem Client – nicht nur auf die bösartigen.

Incoming Request Floods ──┐
                          ▼
             ┌──────────────────────────┐
             │ Node.js Event Loop       │
             │ (Single Thread Execution)│
             └────────────┬─────────────┘
                          │ ❌ Synchronous CPU Block
                          ▼
             ┌──────────────────────────┐
             │ Event Loop Freeze        │ ──► All other concurrent requests
             │ (High Latency / DoS)     │     time out or drop!
             └──────────────────────────┘

Abwehrmaßnahme A: strenge Grenzen für Anfragekörper und Payload

Wenn viele Middleware-Tools zum Parsen von Anfragekörpern auf ihre Standardeinstellungen belassen werden, akzeptieren sie gerne Payloads, die so groß sind, dass der verfügbare Speicher erschöpft wird oder unnötig viele CPU-Zyklen beim Parsen der Eingabe verbraucht werden.

Die falsche Vorgehensweise: unbegrenztes Parsen des Anfragekörpers

// ❌ BAD Accepts arbitrarily large JSON payloads
app.use(express.json());

Die richtige Vorgehensweise: Einhaltung von Grenzen für den Payload

Stellen Sie sowohl in der Anwendungslogik als auch an der Peripherie – beispielsweise im Reverse-Proxy oder in der Eingangslogik wie NGINX, Cloudflare oder einem AWS Application Load Balancer – explizite Größenbegrenzungen ein.

// shared/middleware/security.js
const express = require('express');

// Strict payload limits per content type
const configurePayloadLimits = (app) => {
  // Limit standard JSON bodies to 100kb
  app.use(express.json({ limit: '100kb' }));

  // Limit URL-encoded forms to 50kb
  app.use(express.urlencoded({ extended: true, limit: '50kb' }));
};

module.exports = { configurePayloadLimits };

Abwehrmaßnahme B: Verhinderung von ReDoS (Denial of Service durch reguläre Ausdrücke)

Eine ReDoS-Schwachstelle entsteht, wenn Ihr Code gegen Daten, die ein Angreifer kontrolliert, einen katastrophal rückwärtsgehenden regulären Ausdruck ausführt.

Anfälliges Muster: verschachtelte Quantifizierer

// ❌ VULNERABLE: Exponential backtracking O(2^n)
const emailRegex = /^([a-zA-Z0-9_\.\-])+\@(([a-zA-Z0-9\-])+\.)+([a-zA-Z0-9]{2,4})+$/;

// An input like "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" will hang the process for minutes!
emailRegex.test(userInput);

Sicheres Muster: Verwenden Sie validierte Bibliotheken oder lineare Engines

Anstatt eigene reguläre Ausdrücke für komplexe Validierungslogik zu entwickeln, nutzen Sie gut getestete Schema-Bibliotheken wie Zod oder Joi oder ein spezielles Validierungs-Paket wie validator.

const { z } = require('zod');

// Schema validation using safe built-in string parsers
const UserInputSchema = z.object({
  email: z.string().email().max(255), // Enforces linear-time length constraints
  username: z.string().alphanumeric().min(3).max(30)
});

function validatePayload(payload) {
  return UserInputSchema.parse(payload);
}

2. Verhinderung von Injection-Angriffen (SQL, NoSQL & Command)

Injection-Schwachstellen entstehen immer dann, wenn unzuverlässige Werte direkt in Datenbankabfragen oder in Befehle eingefügt werden, die an das Betriebssystem übergeben werden.

Die falsche Methode: Zeichenkettenkombination in Abfragen

// ❌ VULNERABLE: Direct string interpolation (SQL Injection)
async function findUserByEmail(email) {
  const query = `SELECT * FROM users WHERE email = '${email}'`;
  return await db.query(query); // Attacker passes: "' OR '1'='1"
}

Die richtige Methode: Parameterisierte Abfragen & starke Typisierung

Vermeiden Sie die Erstellung von Abfragen durch das Einfügen unverarbeiteter Parameter in Ihre Datenbanktreiber oder ORM-Aufrufe. Verwenden Sie Parameterplatzhalter – $1, $2 oder ? je nach Treiber – damit der Datenbankmotor die übergebenen Werte stets als reine Daten behandelt und niemals als ausführbaren SQL-Code.

// ✅ GOOD: Parameterized SQL Execution
async function findUserByEmail(email) {
  const text = 'SELECT id, email, role, created_at FROM users WHERE email = $1';
  const values = [email];

  const result = await db.query(text, values);
  return result.rows[0] || null;
}

3. Verteilte Rate Limiting über Redis

Rate Limiting ist für eine seriöse API nicht optional. Wenn Sie darauf verzichten, öffnen Sie die Tür für missbräuchliche Clients oder fehlerhaft funktionierende Integrationen, die Ihre Endpunkte überlasten, Datenbankverbindungen erschöpfen und die Leistung für alle anderen beeinträchtigen.

Das Problem ist, dass in Umgebungen mit mehreren laufenden Instanzen – man denke an Kubernetes-Pods hinter einem Load Balancer – ein Rate-Limiter, der seine Zähler im lokalen Speicher speichert, einfach nicht funktioniert, da jedes Prozess sein eigenes isoliertes Zustandsmodell hat. Stattdessen benötigt man einen gemeinsamen, verteilten Speicher wie Redis, damit jede Instanz denselben atomaren Zähler überprüft und aktualisiert.

Client Requests ──► [ Pod 1 ] ──┐
                                ├──► [ Distributed Redis ]
Client Requests ──► [ Pod 2 ] ──┘    (Atomic Request Counter)

Implementierung eines Unternehmens-Rate-Limiters

// shared/middleware/rateLimiter.js
const { RateLimiterRedis } = require('rate-limiter-flexible');
const redisClient = require('../database/redis');

// Create rate limiter instance: max 100 requests per 15 minutes per IP
const rateLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'rl_api',
  points: 100, // Maximum requests allowed
  duration: 15 * 60, // Per 15 minutes (in seconds)
  blockDuration: 60 * 5 // Block IP for 5 minutes if exceeded
});

const rateLimiterMiddleware = async (req, res, next) => {
  try {
    const clientIp = req.ip || req.headers['x-forwarded-for'];
    await rateLimiter.consume(clientIp);
    next();
  } catch (rejRes) {
    // Return standard HTTP 429 Too Many Requests
    res.setHeader('Retry-After', Math.round(rejRes.msBeforeNext / 1000) || 60);
    return res.status(429).json({
      status: 'error',
      code: 'TOO_MANY_REQUESTS',
      message: 'Rate limit exceeded. Please slow down your requests.'
    });
  }
};

module.exports = rateLimiterMiddleware;

4. Sicherheit von HTTP-Headern mit Helmet

Durch Default geben Express-Antworten interne Details über Header wie X-Powered-By: Express preis. Dieser einzige Header bietet Angreifern einen Shortcut: Sie wissen genau, welches Framework Sie verwenden, und können direkt auf bekannte, framework-spezifische Exploits zugreifen.

Die Lösung besteht darin, mithilfe des helmet-Middleware-Tools eine verstärkte Reihe von Standard-Antwort-Headern anzuwenden.

// shared/middleware/securityHeaders.js
const helmet = require('helmet');

function applySecurityHeaders(app) {
  app.use(
    helmet({
      // Hide frame options to mitigate Clickjacking
      frameguard: { action: 'deny' },
      // Strict Content Security Policy
      contentSecurityPolicy: {
        directives: {
          defaultSrc: ["'self'"],
          scriptSrc: ["'self'"],
          objectSrc: ["'none'"],
          upgradeInsecureRequests: []
        }
      },
      // Remove X-Powered-By header
      hidePoweredBy: true,
      // Enforce HTTPS
      hsts: {
        maxAge: 31536000, // 1 year
        includeSubDomains: true,
        preload: true
      }
    })
  );
}

module.exports = { applySecurityHeaders };

5. Sicherheit in Containern und Laufzeitumgebungen: Verstärkung von Dockerfiles

Es reicht nicht aus, sicheren Anwendungscode zu schreiben, wenn die Bereitstellung diese Sicherheitsmaßnahmen wieder aufhebt. Wenn der Node.js-Prozess innerhalb seines Docker-Containers als root läuft, gewährt jede auftretende Schwachstelle für die Ausführung von Remote-Code dem Angreifer Root-Zugriff – möglicherweise mit Zugang zum Dateisystem des Containers oder sogar zum Host.

Gute Praktiken für produktionstaugliche Dockerfiles mit mehreren Stufen

# -------------------------------------------------------------------
# STAGE 1: Build & Dependencies
# -------------------------------------------------------------------
FROM node:20-alpine AS builder

WORKDIR /usr/src/app

# Copy dependency manifests
COPY package*.json ./

# Install all dependencies (including devDependencies for building/testing)
RUN npm ci

# Copy source code
COPY . .

# Prune devDependencies for production runtime
RUN npm prune --production

# -------------------------------------------------------------------
# STAGE 2: Minimal Production Runtime
# -------------------------------------------------------------------
FROM node:20-alpine AS runner

# Set NODE_ENV to production
ENV NODE_ENV=production

WORKDIR /usr/src/app

# Copy built artifacts and production node_modules from builder
COPY --chown=node:node package*.json ./
COPY --chown=node:node --from=builder /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=builder /usr/src/app/src ./src

# 🔒 SECURITY REQUIREMENT: Never run Node.js as root in container environments
USER node

EXPOSE 3000

CMD ["node", "src/server.js"]

Warum das wichtig ist:

  • USER node: Entzieht dem laufenden Prozess die Root-Rechte und führt ihn stattdessen als unprivilegierter Benutzer aus
  • Mehrstufiger Build: Hält Entwicklungstools, Compiler und Testsets außerhalb des endgültigen Images, wodurch das reduziert wird, was ein Angreifer ausnutzen könnte
  • npm ci: Stellt durch strikte Einhaltung der in package-lock.json festgelegten Versionen reproduzierbare Installationen sicher
  • Architektur-Überprülliste für Teil 4

    Nutzen Sie diese Überprülliste, um eine Produktivveröffentlichung vor dem Versand zu prüfen:

    • Beschränkungen der Payloads: Ist jeder API-Endpunkt durch eine Obergrenze für die Größe des HTTP-Payloads geschützt, beispielsweise mit express.json({ limit: '100kb' })?
    • Schutz vor ReDoS-Angriffen: Wird alle unzuverlässige Zeichenketten-Eingabe durch überprüfte Bibliotheken (Zod, Joi) oder eine nicht-rückwärtsverfolgende Validierungslogik und nicht durch benutzerdefinierte reguläre Ausdrücke validiert?
  • Parameterisierte Abfragen: Übergeben alle Datenbankaufrufe Benutzereingaben über gebundene Parameter ($1, $2 oder Äquivalente), sodass eine SQL- und NoSQL-Injektion strukturell unmöglich ist?
  • Distribuierte Rate Limiting: Schützt ein atomarer, von Redis unterstützter Rate Limiter aktiv Ihre öffentlichen APIs vor Denial-of-Service-Angriffen?
  • Sicherheitsheader: Ist helmet so konfiguriert, dass frameworkidentifizierende Header unterdrückt, HSTS durchgesetzt und das Einbetten von Frames blockiert wird?
  • Nicht-eingeloggter Container-Benutzer: Gibt Ihr Dockerfile ausdrücklich USER node an, damit der Prozess niemals als Root ausgeführt wird?
  • Abhängigkeitsscanning: Sind automatische Schwachstellenprüfungen – npm audit, Snyk, Dependabot oder Ähnliches – in Ihren CI/CD-Pipeline integriert?
  • Zusätzliche Literatur