Skip to main content

So erstellen Sie ein sicheres API-Gateway mit Node.js

Artikel von

Florian Rappl

28. Dezember 2022

0 Min. Lesezeit

Microservices bieten gegenüber Monolithen erhebliche Vorteile. Sie können die Entwicklung einfacher skalieren und die Skalierung der Infrastruktur präzise steuern. Außerdem verkürzen häufige kleine Updates und schrittweise Rollouts die Time-to-Market deutlich.

Trotz dieser Vorteile bringt eine Microservices-Architektur ein Problem mit sich: Auf ihre Services kann nicht von außen zugegriffen werden. Ein API-Gateway kann dieses Problem glücklicherweise lösen.

API-Gateways können Ihren Frontend-Anwendungen (z. B. Websites und nativen Apps) einen klaren Zugriffsweg auf sämtliche Backend-Funktionen bieten. Sie können als Aggregatoren für die Microservices und als Middleware für allgemeine Aufgaben wie Authentifizierung und Autorisierung dienen. Außerdem erleichtern API-Gateways die Zusammenfassung und Vereinheitlichung von Services, indem sie Ausgabeformate wie XML und JSON in ein einzelnes Format überführen.

API-Gateways sind ein wichtiges Instrument für Sicherheit und Zuverlässigkeit. Sie bieten Sitzungsverwaltung, Eingabebereinigung, Schutz vor Distributed-Denial-of-Service-Angriffen (DDoS), Rate-Limiting und Transaktionsprotokollierung.

In diesem Artikel erstellen wir von Grund auf ein sicheres API-Gateway – nur mit Node.js und einigen Open-Source-Paketen. Sie benötigen lediglich grundlegende Kenntnisse im Umgang mit dem Terminal, Node.js Version 14 oder höher und JavaScript. Den fertigen Projektcode finden Sie hier.

Legen wir los!

Unser API-Gateway erstellen

Zwar könnten wir einen Webserver in Node.js von Grund auf schreiben, doch überlassen wir die Hauptarbeit hier einem bestehenden Framework. Die wohl beliebteste Wahl für Node.js ist Express: Es ist schlank, ausreichend schnell und lässt sich leicht erweitern. Außerdem können Sie fast jedes benötigte Paket in Express integrieren.

Erstellen wir zunächst ein neues Node.js-Projekt und installieren Express:

npm init -y
npm install express --save

Mit diesen beiden Befehlen haben wir ein neues Node.js-Projekt mit den Standardeinstellungen erstellt und das Paket express installiert. In unserem Projektverzeichnis sollten sich nun die Datei package.json und das Verzeichnis node_modules befinden.

Erstellen Sie nun eine Datei namens index.js und legen Sie sie neben package.json ab. Der Dateiinhalt sollte zunächst so aussehen:

const express = require("express");

const app = express();
const port = 3000;

app.get("/", (req, res) => {
  const { name = "user" } = req.query;
  res.send(`Hello ${name}!`);
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

Damit sollte ein einfacher Webserver mit einem Endpunkt laufen. Starten Sie ihn mit dem folgenden Befehl im Terminal:

node index.js

Rufen Sie anschließend in Ihrem Browser http://localhost:3000 auf. Auf dem Bildschirm sollte „Hello user!“ erscheinen. Wenn Sie der URL eine Abfrage anhängen, zum Beispiel ?name=King, sollte „Hello King!“ angezeigt werden.

Als Nächstes richten wir die Authentifizierung ein. Dazu verwenden wir das Paket express-session. Es nutzt ein Cookie, um unsere Endpunkte zu schützen. Nutzerinnen und Nutzer ohne gültiges Sitzungscookie erhalten eine entsprechende Antwort mit dem HTTP-Statuscode HTTP 401 (Unauthorized) oder 403 (Forbidden).

Außerdem schützen wir unser Passwort besser, indem wir es in einer Umgebungsvariable speichern. Mit dem Paket dotenv laden wir die Variable SESSION_SECRET aus einer .env-Datei in process.env.

Beenden Sie zunächst den vorherigen Prozess, indem Sie in der Konsole Ctrl + C eingeben. Installieren Sie anschließend express-session und dotenv mit folgendem Befehl:

npm install express-session dotenv --save

Weitere Anweisungen zum Einrichten einer sicheren Sitzung finden Sie in der Dokumentation zu express-session auf GitHub. Dazu gehört etwa, das Cookie-Flag secure auf true zu setzen, damit das Cookie nur über HTTPS-Verbindungen übertragen wird. Berücksichtigen Sie bei der Sitzungsverwaltung auch weitere Sicherheitsmaßnahmen, beispielsweise das Erneuern der Sitzungs-ID bei sensiblen Vorgängen wie der Anmeldung oder einer Passwortänderung.

Erstellen Sie als Nächstes im Stammverzeichnis Ihres Projekts eine .env-Datei und fügen Sie den folgenden Code ein:

SESSION_SECRET=`<your_secret>`

Umgebungsvariablen werden üblicherweise in Großbuchstaben geschrieben. Verwenden Sie unbedingt ein einzigartiges, zufällig generiertes Geheimnis – ein starkes Passwort eignet sich dafür.

Unsere Ergänzungen in index.js sollten so aussehen. Dieses Snippet verwenden wir später beim Zusammenfügen der Lösung:

require("dotenv").config();

const session = require("express-session");

const secret = process.env.SESSION_SECRET;
const store = new session.MemoryStore();
const protect = (req, res, next) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    res.sendStatus(401);
  } else {
    next();
  }
};

app.use(
  session({
    secret,
    resave: false,
    saveUninitialized: true,
    store,
  })
);

Mit dieser Einrichtung erhalten wir eine sitzungsbasierte Verwaltung im Arbeitsspeicher, mit der Sie Endpunkte schützen können. Verwenden wir sie für einen neuen Endpunkt und richten wir spezielle Endpunkte für die Anmeldung und Abmeldung ein. Fügen Sie den folgenden Code am Ende von index.js ein:

app.get("/login", (req, res) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    req.session.authenticated = true;
    res.send("Successfully authenticated");
  } else {
    res.send("Already authenticated");
  }
});

app.get("/logout", protect, (req, res) => {
  req.session.destroy(() => {
    res.send("Successfully logged out");
  });
});

app.get("/protected", protect, (req, res) => {
  const { name = "user" } = req.query;
  res.send(`Hello ${name}!`);
});

Führen Sie den Code nun erneut mit dem Befehl node index.js aus und probieren Sie ihn selbst aus.

Wie funktioniert das Ganze? Hier ist ein einfacher Ablauf:

  • Der Aufruf von / funktioniert.

  • Der Aufruf von /protected gibt den HTTP-Statuscode 401 (Unauthorized) zurück.

  • Der Aufruf von /login meldet Sie automatisch an und zeigt „Successfully authenticated.“ an.

  • Ein erneuter Aufruf von /login zeigt „Already authenticated.“ an.

  • Wenn Sie /protected aufrufen, funktioniert die Seite nun.

  • Der Aufruf von /logout setzt die Sitzung zurück und zeigt „Successfully logged out.“ an.

  • Anschließend ist /protected wieder nicht zugänglich und gibt den Statuscode 401 zurück.

  • Auch ein erneuter Aufruf von /logout gibt den Statuscode 401 zurück.

Mit der Middleware protect vor dem Request-Handler können wir einen Endpunkt schützen und sicherstellen, dass Nutzerinnen und Nutzer angemeldet sind. Zusätzlich dienen die Endpunkte /login und /logout dazu, diese Funktionen zu ergänzen.

Beenden Sie den Prozess erneut mit Ctrl + C. Bevor wir uns dem Kern unseres API-Gateways widmen, sehen wir uns Protokollierung und korrektes Rate-Limiting an.

Rate-Limiting

Rate-Limiting begrenzt, wie oft innerhalb eines festgelegten Zeitraums auf Ihre API zugegriffen werden kann. So schützen Sie sie vor einer Überlastung der Bandbreite durch normalen Datenverkehr und DoS-Angriffe. Sie können Ratenlimits für Datenverkehr aus bestimmten Quellen konfigurieren. Außerdem gibt es verschiedene Möglichkeiten, das Zeitfenster zu berechnen und durchzusetzen, in dem Anfragen verarbeitet werden.

Installieren Sie zunächst ein Paket für Rate-Limiting:

npm install express-rate-limit --save

Konfigurieren Sie anschließend das Paket. Fügen Sie diesen Code in die Datei index.js ein, und zwar vor den Routen, für die das Limit gelten soll:

const rateLimit = require("express-rate-limit");

app.use(
  rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 5, // 5 calls
  })
);

Testen Sie die Konfiguration, indem Sie den Server mit dem Befehl node index.js neu starten und unseren ersten Endpunkt mehrmals aufrufen. Sobald Sie innerhalb von 15 Minuten das Limit von fünf Anfragen erreicht haben, sollte die Meldung „Too many requests, please try again later.“ angezeigt werden. Standardmäßig gibt express-rate-limit auch den korrekten HTTP-Statuscode 429 (Too Many Requests) zurück.

Beenden Sie den Prozess, bevor Sie zum nächsten Schritt übergehen, um Ihr Limit zurückzusetzen.

Protokollierung

Für die Protokollierung können wir winston verwenden. Dazu gehört auch eine spezielle Middleware für Express: express-winston. Außerdem möchten wir möglicherweise die Antwortzeiten der Endpunkte protokollieren, um sie später genauer zu untersuchen. Das Paket response-time eignet sich dafür. Installieren wir diese Pakete.

npm install winston express-winston response-time --save

Die Integration dieser Pakete ist unkompliziert. Fügen Sie den folgenden Code vor dem Code ein, den Sie protokollieren möchten – am besten also vor dem Rate-Limiting-Code in index.js:

const winston = require("winston");
const expressWinston = require("express-winston");
const responseTime = require("response-time");

app.use(responseTime());

app.use(
  expressWinston.logger({
    transports: [new winston.transports.Console()],
    format: winston.format.json(),
    statusLevels: true,
    meta: false,
    msg: "HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms",
    expressFormat: true,
    ignoreRoute() {
      return false;
    },
  })
);

Mit dieser Konfiguration erhalten wir zusätzliche Ausgaben. Wenn Sie nun den Server starten und / aufrufen, sollte die Konsole so aussehen:

Server running at http://localhost:3000
{"level":"info","message":"GET / 200 8ms","meta":{}}
{"level":"warn","message":"GET /favicon.ico 404 3ms","meta":{}}

Wenn Sie fortfahren möchten, beenden Sie den Prozess mit Ctrl + C.

Cross-Origin Resource Sharing (CORS)

Da unser API-Gateway als Vermittlungsschicht zwischen Frontend- und Backend-Services dient, übernehmen wir hier die Konfiguration von Cross-Origin Resource Sharing (CORS). CORS ist ein browserbasierter Sicherheitsmechanismus, der sicherstellt, dass das Backend bestimmte Cross-Origin-Anfragen akzeptiert, zum Beispiel Anfragen von www.company.com an api.company.com.

Dazu senden wir vor der eigentlichen Anfrage eine spezielle Anfrage. Sie verwendet die HTTP-Methode OPTION HTTP und erwartet spezielle Header in der Antwort, die nachfolgende Anfragen erlauben oder ablehnen.

Um CORS in unserem Gateway zu aktivieren, installieren wir das Paket cors. Außerdem können wir mit dem praktischen Paket helmet weitere Sicherheits-Header hinzufügen. Installieren Sie beide Pakete mit dem folgenden Befehl:

npm install cors helmet --save

So können wir die Pakete integrieren:

const cors = require("cors");
const helmet = require("helmet");

app.use(cors());
app.use(helmet());

Mit dieser Konfiguration können alle Domains auf die API zugreifen. Wir könnten die Einstellungen auch detaillierter abstimmen, aber vorerst reicht die obige Konfiguration aus.

Proxying

Ein API-Gateway leitet hauptsächlich Anfragen an bestimmte Microservices weiter, um Geschäftslogik- und andere HTTP-Anfragen zu routen. Dafür benötigen wir ein Paket, das diese Weiterleitung übernimmt – einen Proxy. Wir verwenden http-proxy-middleware. Sie können es mit dem folgenden Befehl installieren:

npm install http-proxy-middleware --save

Fügen wir es nun zusammen mit einem neuen Endpunkt hinzu:

const { createProxyMiddleware } = require("http-proxy-middleware");

app.use(
  "/search",
  createProxyMiddleware({
    target: "http://api.duckduckgo.com/",
    changeOrigin: true,
    pathRewrite: {
      [`^/search`]: "",
    },
  })
);

Testen Sie die Konfiguration, indem Sie den Server starten und /search?q=x&format=json aufrufen. Dieser Endpunkt gibt die Ergebnisse zurück, die durch die Weiterleitung der Anfrage an http://api.duckduckgo.com/ abgerufen wurden. Wenn Sie den Code ausführen, beenden Sie den Prozess anschließend, bevor Sie mit den letzten Änderungen fortfahren.

Konfiguration

Da wir nun alle Komponenten haben, konfigurieren wir sie so, dass sie zusammenarbeiten. Erstellen Sie dazu im selben Verzeichnis wie die anderen Dateien eine neue Datei namens config.js:

require("dotenv").config();

exports.serverPort = 3000;
exports.sessionSecret = process.env.SESSION_SECRET;
exports.rate = {
  windowMs: 5 * 60 * 1000,
  max: 100,
};
exports.proxies = {
  "/search": {
    protected: true,
    target: "http://api.duckduckgo.com/",
    changeOrigin: true,
    pathRewrite: {
      [`^/search`]: "",
    },
  },
};

Hier haben wir die grundlegende Konfiguration unseres API-Gateways erstellt. Wir legen den Port, den Verschlüsselungsschlüssel für Sitzungscookies und die verschiedenen Proxy-Endpunkte fest. Die Optionen für jeden Proxy entsprechen denen der Funktion createProxyMiddleware, ergänzt um den Schlüssel protected.

Passen wir nun auch die Deklarationen für secret und port sowie den Aufruf von rateLimit in index.js an, damit sie auf die Werte in unserer Konfigurationsdatei verweisen:

const secret = config.sessionSecret;
...
const port = config.serverPort;
...
app.use(rateLimit(config.rate));

Entfernen Sie außerdem den Code, dessen Funktionalität wir nach config.js verschoben haben, sowie unseren anfänglichen Code zum Testen des Endpunkts. Löschen Sie den folgenden Code aus index.js:

require("dotenv").config();
...
app.use(
 "/search",
 createProxyMiddleware({
   target: "http://api.duckduckgo.com/",
   changeOrigin: true,
   pathRewrite: {
 [`^/search`]: "",
   },
 })
);
...
app.get("/", (req, res) => {
 const { name = "user" } = req.query;
 res.send(`Hello ${name}!`);
});
...
app.get("/protected", protect, (req, res) => {
 const { name = "user" } = req.query;
 res.send(`Hello ${name}!`);
});

Die fertige Anwendung

Sehen wir uns den Inhalt der fertigen Datei index.js an, die wir um einige kleine Ergänzungen erweitert haben:

// import all the required packages
const cors = require("cors");
const express = require("express");
const session = require("express-session");
const rateLimit = require("express-rate-limit");
const expressWinston = require("express-winston");
const helmet = require("helmet");
const { createProxyMiddleware } = require("http-proxy-middleware");
const responseTime = require("response-time");
const winston = require("winston");
const config = require("./config");

// configure the application
const app = express();
const port = config.serverPort;
const secret = config.sessionSecret;
const store = new session.MemoryStore();

Wir ergänzen eine Logik, um die Werte der geschützten Eigenschaften der Proxys zu prüfen, die wir in config.js auflisten. Wenn sie auf false gesetzt sind, übergibt die hier definierte Funktion alwaysAllow die Steuerung an den nächsten Handler:

const alwaysAllow = (_1, _2, next) => {
  next();
};
const protect = (req, res, next) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    res.sendStatus(401);
  } else {
    next();
  }
};

Einige ältere Servertechnologien fügen im HTTP-Header außerdem nicht funktionsrelevante Beschreibungsdaten zum Server hinzu. Um unser API sicher zu halten, entfernen wir diese Daten und geben potenziell böswilligen Akteuren weniger Informationen preis:

app.disable("x-powered-by");

app.use(helmet());

app.use(responseTime());

app.use(
  expressWinston.logger({
    transports: [new winston.transports.Console()],
    format: winston.format.json(),
    statusLevels: true,
    meta: false,
    level: "debug",
    msg: "HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms",
    expressFormat: true,
    ignoreRoute() {
      return false;
    },
  })
);

app.use(cors());

app.use(rateLimit(config.rate));

app.use(
  session({
    secret,
    resave: false,
    saveUninitialized: true,
    store,
  })
);

app.get("/login", (req, res) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    req.session.authenticated = true;
    res.send("Successfully authenticated");
  } else {
    res.send("Already authenticated");
  }
});

Als Nächstes durchlaufen wir die in config.js aufgeführten Proxys, prüfen den Wert ihres Parameters protected, rufen entweder die zuvor definierte Funktion protect oder alwaysAllow auf und fügen für jeden konfigurierten Eintrag einen neuen Proxy hinzu:

Object.keys(config.proxies).forEach((path) => {
  const { protected, ...options } = config.proxies[path];
  const check = protected ? protect : alwaysAllow;
  app.use(path, check, createProxyMiddleware(options));
});

app.get("/logout", protect, (req, res) => {
  req.session.destroy(() => {
    res.send("Successfully logged out");
  });
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

Dieser Code ist bereits recht flexibel, verwendet jedoch standardmäßig HTTP statt HTTPS. In vielen Fällen ist das ausreichend. In Kubernetes kann der Code beispielsweise hinter einem NGINX-Ingress laufen, der TLS verarbeitet und für das Zertifikat zuständig ist.

Manchmal möchten wir den laufenden Code jedoch direkt dem Internet zugänglich machen. In diesem Fall benötigen wir ein gültiges Zertifikat.

Ein Zertifikatsanbieter wie Let’s Encrypt bietet zahlreiche Möglichkeiten, HTTPS zu aktivieren. Mit einem Client wie Greenlock Express können Sie Ihre Zertifikate automatisch verwalten. Alternativ können Sie mit einem Client wie Publishlab acme-client einen detaillierteren Ansatz umsetzen.

const { readFileSync } = require('fs');
const { createServer } = require('https');

// assumes that the key and certificate are stored in a "cert" directory
const credentials = {
  key: readFileSync('cert/server.key', 'utf8'),
  cert: readFileSync('cert/server.crt', 'utf8'),
};

// here we use the express "app" to attach to the created server
const httpsServer = createServer(credentials, app);

Beachten Sie, dass hierfür sudo erforderlich ist. Das ist ohnehin zu erwarten, denn HTTPS ist hier nur erforderlich, wenn der Server direkt erreichbar ist. Deshalb benötigen wir Port 443 statt eines öffentlichen Ports wie 8443.

// use standard HTTPS port
httpsServer.listen(443);

Fazit

Wir haben jetzt ein sicheres API-Gateway von Grund auf mit Node.js entwickelt. Dabei haben wir Funktionen wie Sitzungsverwaltung, Drosselung, Proxys, Protokollierung und CORS implementiert. Außerdem haben wir gelernt, Node.js so zu konfigurieren, dass TLS verwendet und der Zugriff über HTTPS erzwungen wird.

Ein API-Gateway bietet erhebliche Vorteile für größere Back-End-Infrastrukturen. Wenn die Back-End-Services hinter einem API-Gateway verborgen sind, lassen sich gemeinsame Sicherheitsprotokolle einfach durchsetzen. Dadurch werden unsere Services deutlich einfacher und vor alltäglichen Schwachstellen geschützt.

Gepostet in:

Weiterlesen

Live Stream

Remediation Agents einfach erklärt: Warum Beheben wichtiger ist als Finden

Erfahren Sie, wie Snyks Remediation Agent mithilfe von Security-Intelligence, Breakability-Analysen und Validierung Schwachstellen in mergefähige Pull Requests verwandelt.

feature ai ide dark
Blog

Ein erster Blick auf Evo Agentic AppSec: agentische Behebung und Schutz vor bösartigem Code

Entdecken Sie die ersten Agentic-AppSec-Funktionen von Snyk: einen autonomen Remediation Agent, der Schwachstellen behebt, und Malicious Code Defense, das riskante Pakete vor der Veröffentlichung blockiert.

feature insights context
Blog

Der keyv-npm-Angriff: Preinstall-Malware, vertrauenswürdige Provenance und IDE-Hooks

keyv 6.0.0 und zehn weitere npm-Releases enthielten Malware, die bei der Installation ausgeführt wird. Erfahren Sie, welche Versionen betroffen sind, welche Hashes gelten, wie Sie die Malware erkennen und in welcher Reihenfolge Sie sicher reagieren.