Zum Inhalt springen
Deutsch

Sicherheit

Das Framework liefert die Bausteine; diese Seite zeigt, wie man sie zusammensetzt. Das lauffähige Gegenstück ist examples/http/secure-service.ts.

Die Reihenfolge zählt — von außen nach innen:

import {
concat, cors, csrfProtection, fallback, handleErrors, path,
requestId, requestTimeout, securityHeaders, withMiddleware,
CorsOptions, CsrfOptions,
} from 'actor-ts/http';
const corsOptions = CorsOptions.create().withOrigins('https://app.example').withCredentials();
const csrf = csrfProtection(CsrfOptions.create().withSecret(process.env.CSRF_SECRET!));
const routes =
withMiddleware(requestId(),
withMiddleware(securityHeaders(),
cors(corsOptions,
withMiddleware(requestTimeout(15_000),
handleErrors(errorMapper,
withMiddleware(csrf,
concat(
appRoutes,
fallback(notFound),
)))))));
SchichtWarum hier
requestIdganz außen, damit jede Log-Zeile (auch Fehler) eine ID hat
securityHeadersüber allem, was ablehnt — damit auch diese Responses die Header tragen
corsaußerhalb der Auth — Preflights sind laut Spec anonym
requestTimeoutLatenz begrenzen, bevor echte Arbeit läuft
handleErrorsaußerhalb von csrf, damit es dessen eigene 403 mappt — und innerhalb der Header-Schichten, damit das Ergebnis noch durch sie läuft
csrfnur Browser-Apps; nach CORS, vor den Handlern
fallbackan der Wurzel — beantwortet alles Unpassende

X-Content-Type-Options: nosniff braucht keine Einrichtung. Geschrieben wird der Header vom Backend, nicht von einer Middleware — er erreicht damit auch die Responses, die keine Middleware sieht: das backend-eigene Fehler-Mapping, den fallback-404 und den 413 bei zu großem Body. Ein Header aus dem Handler gewinnt weiterhin.

Er ist bewusst der einzige, der per Default an ist — es ist der einzige Header des Bündels, der nichts daran ändern kann, wie eine bestehende Anwendung eingebettet, geframed oder referenziert wird. X-Frame-Options und Cross-Origin-Resource-Policy würden iframes, Cross-Origin-Einbettung und OAuth-Popups brechen und bleiben deshalb Opt-in.

Um das ganze Bündel auf jede Response zu legen — inklusive derselben middleware-unsichtbaren Pfade — konfiguriere es am Server statt am Routen-Baum:

import { SecurityHeadersOptions } from 'actor-ts/http';
const securityHeadersOptions = SecurityHeadersOptions.create()
.withFrameOptions('SAMEORIGIN')
.withReferrerPolicy('strict-origin-when-cross-origin');
await http.newServerAt('0.0.0.0', 8080)
.withSecurityHeaders(securityHeadersOptions)
.bind(routes);

Optionen zu übergeben ist ein Opt-in in das vollständige Bündel, samt dessen eigener Defaults — die beiden Header oben kommen also zusammen mit Cross-Origin-Resource-Policy: same-origin und dem Rest. Ein einfaches Objekt geht genauso (withSecurityHeaders({ frameOptions: 'SAMEORIGIN' })), und withSecurityHeaders(false) schaltet den Mechanismus komplett ab.

Serverweit oder als securityHeaders()-Middleware — beide setzen dieselbe Menge. Die Middleware deckt ihren eigenen Teilbaum ab, und dazu gehört auch ein Short-Circuit, der wirft: Ein HttpError aus einer Schicht darunter — eine CSRF-403, eine Auth-401 — nimmt die Header mit hinaus. Was sie nie sieht, ist eine Response, die außerhalb ihres Teilbaums entsteht: der backend-eigene 404, der 413 bei zu großem Body und die generische 500, auf die ein Wurf ohne HttpError gemappt wird. Zur Middleware greifst du, wenn nur ein Routen-Teilbaum sie braucht; zum Builder, wenn der ganze Server sie braucht.

  • TLS terminiert am Proxy. Setze HSTS trotzdem — Browser ignorieren es über reines HTTP. Gate Security-Header nicht an einem Scheme, das die App nicht sieht.
  • Client-IPs vertrauen. req.remoteAddress ist der Socket-Peer, kein fälschbares X-Forwarded-For — und „überschreibt mein Proxy den Header” ist die falsche Frage, denn NGINX, AWS ALB und Cloudflare hängen alle daran an. Der eigene Text des Clients bleibt für immer links in der Kette. Benenne stattdessen die Proxys (trustedProxies von IpAllowlist), dann wird die Client-Adresse vom Socket-Ende nach innen aufgelöst — dem einzigen Ende, das die Infrastruktur kontrolliert. Traue niemals jedem Hop: bei Express und Fastify löst trust proxy: true genau zu diesem linkesten, client-gewählten Eintrag auf.
  • Secrets aus der Umgebung, nicht aus HOCON-Dateien — das CSRF-Secret, Basic-Zugangsdaten und Bearer-Tokens nehmen alle Werte, die du beim Start lädst.

Bevorzuge das __Host--Präfix (erfordert Secure, Path=/, kein Domain) und SameSite=Lax oder Strict. Das CSRF-Cookie nutzt dieses Präfix per Default — es ist das, was eine Geschwister-Subdomain am Platzieren eines Tokens hindert — und ist bewusst nicht HttpOnly (Double-Submit braucht JS zum Lesen); dein Session-/Auth-Cookie sollte HttpOnly sein.

serializeCookie bringt dich per Weglassen dorthin. Ein Attribut, das du nicht nennst, löst sich zum strikten Ende hin auf — Secure, HttpOnly, SameSite=Lax, Path=/ —, der Aufruf mit zwei Argumenten ist also schon auslieferbar, und was dabei herauskommt, erfüllt die __Host--Regeln, ohne dass ein Wort darüber fällt. Aufweichen ist der Teil, den du hinschreibst:

import { serializeCookie } from 'actor-ts/http';
// __Host-session=<id>; Path=/; HttpOnly; Secure; SameSite=Lax
const sessionCookie = serializeCookie('__Host-session', sessionId);
// Plain-HTTP local dev, and a cookie same-origin JS has to read.
const readableCookie = serializeCookie('token', value, { secure: false, httpOnly: false });

Path wird immer geschrieben, weil die Alternative schlimmer ist: ohne das Attribut leitet der Browser den Geltungsbereich aus der Request-URI ab — dasselbe Cookie deckt dann /account ab, wenn es von /account/login kommt, und /, wenn es von der Wurzel kommt.

Ebenso verweigert die Funktion alles, was aus dem Cookie ausbrechen könnte, das sie gerade baut — einen unzulässigen Namen oder Wert und einen Path oder eine Domain, die keine sind. Diese beiden landen unverändert im Header: ein ungeprüfter Path von '/;Domain=evil.example' würde ein Attribut nach fremder Wahl anhängen, und die letzte Domain ist die, die zählt — sie würde deine überschreiben, statt gegen sie zu verlieren.

Interna nicht leaken. Nutze withErrorHandler (oder ein Top-Level handleErrors), um einen generischen Body zurückzugeben, und logge das Detail serverseitig, verschlüsselt über die Request-ID. Halte 403/404 unspezifisch — die Static-File-Schicht liefert für jeden abgelehnten Pfad einen einheitlichen 404, verrät also nie, was existiert.

Auch die serverseitige Kopie braucht Hygiene, und das übliche Leck dort ist eine Verbindungs-URL: ein abgelehnter Optionswert und die Oversize-Frame-Warnung des WebSocket-Clients benennen beide eine, und beide werden mit maskierter Userinfo geschrieben. Wenn du selbst eine URL loggst, schick sie durch redactUrlCredentials oder redactedUrlLabel — ein Passwort, das einen Log-Aggregator erreicht hat, muss rotiert werden; löschen genügt nicht.

redirect(target) nimmt ausschließlich Same-Origin-Ziele — eine relative Referenz. Eine absolute URL, ein protokollrelatives //host oder ein Steuerzeichen wirft HttpError(400); einen ?next=-Parameter direkt in einen Redirect zu reichen, scheitert damit geschlossen, statt zum Open-Redirect zu werden. Wenn das Verlassen des Origins Absicht ist, sag es: redirectExternal(...) ist derselbe Helper ohne Origin-Regel, und der eigene Name macht jeden bewussten Off-Origin-Sprung mit einem Befehl auffindbar. Übergib eine Konstante oder ein Ziel aus deiner Allowlist — nie einen rohen Request-Parameter. Siehe Route DSL.

  • Request-Bodies sind auf jedem Backend bei 1 MiB gekappt, und ein größerer Body wird mit demselben 413 Payload Too Large abgewiesen, egal welches Backend bedient — bevor der Handler läuft und ohne withErrorHandler zu fragen, denn ein Size-Cap ist eine Transport-Entscheidung und kein Anwendungsfehler. Heb ihn dort an, wo ein Endpunkt wirklich mehr annimmt: withMaxBodyBytes(bytes) in den Express- und Hono-Backend-Options, bodyLimit in der Options-Bag von FastifyBackend. Ein Body mit einer Content-Length über dem Limit wird abgewiesen, bevor ein Byte davon gelesen wird; ein gechunkter Body deklariert keine Länge, also zählen ihn alle drei Backends beim Eintreffen mit und brechen den Read am Limit ab — die Bytes dahinter werden nie entgegengenommen.
  • WebSocket-Frames werden durch maxFrameBytes gekappt (1 MiB Default). Das Framework weist einen zu großen Frame immer ab, bevor er deinen Actor erreicht; auf den meisten Laufzeit/Backend-Paaren geht das aufgelöste Limit zusätzlich an den Socket der Laufzeit selbst, sodass der Frame schon abgewiesen wird, während er eintrifft, statt erst nachdem der Prozess ihn gepuffert hat — sonst könnte ein Peer pro Frame 16 MiB (Bun) oder 100 MiB (ws) Pufferung erzwingen, die das Framework anschließend verwirft. Das Limit zu senken — pro Route oder über actor-ts.http.websocket.maxFrameBytes — verkleinert dieses Puffer-Fenster mit. Drei Stellen haben kein Transport-Limit und puffern den Frame, bevor sie ihn abweisen: Hono auf Deno, dessen upgradeWebSocket überhaupt kein Payload-Limit anbietet; Bun mit dem Express- oder Fastify-Backend, wo ws auf Buns eingebauten Shim auflöst, der maxPayload zwar annimmt und zurückliefert, aber nicht durchsetzt; und der ausgehende WebsocketClientActor auf jeder Laufzeit — kein natives Client-WebSocket beachtet ein Payload-Limit, das man seinem Konstruktor übergibt, gemessen auf Bun, Node und Deno, wo die Option angenommen wird, als undefined zurückkommt und ein 4-MiB-Frame trotzdem vollständig zugestellt wird. Auf dem Client greift das Limit also nur im Nachhinein, und was es bringt, ist, dass der Verstoß endgültig ist: Ein zu großer Frame schließt die Verbindung mit 1009, sodass das Gegenüber die Allokation auf diesem Socket nicht wiederholen kann. Beachte, dass das Bedrohungsmodell dort ein anderes ist — das Gegenüber ist ein Endpunkt, den du selbst gewählt hast, oder ein MITM auf einer unverschlüsselten ws://-Verbindung. Siehe WebSocket.
  • Untrusted HTML — mit dem html-Template escapen; für Rich-Markup vor rawHtml mit einer dedizierten Bibliothek sanitisieren.
  • Antworten, die du selbst abrufst, sind ebenfalls Untrusted Input — und die Richtung, die in so einer Liste am leichtesten vergessen wird. Der HttpClient materialisiert einen ganzen Response-Body im Speicher, bevor ein Aufrufer ihn sieht; ohne Schranke entscheidet also die Gegenstelle, wie viel Heap dieses Prozesses sie sich nimmt — eine Content-Length ist eine Behauptung und keine Grenze, und eine gechunkte Antwort deklariert überhaupt keine Länge. Jeder Aufruf bringt eine 30-s-Deadline und eine 8-MiB-Obergrenze für den Body mit; der Body wird beim Lesen mitgezählt und während er eintrifft abgebrochen, die Verbindung wird abgerissen, damit der Rest nie ankommt, und der Aufruf wirft HttpResponseTooLargeError. Heb die Werte pro Aufruf an (maxResponseBytes, timeoutMs), pro Integration (newClient(...)) oder flottenweit unter actor-ts.http.client. Redirects sind mitbegrenzt — 5 Hops, und authorization / cookie fallen bei jedem Origin-Wechsel weg, damit eine feindliche Location kein Bearer-Token zu einem Host ihrer Wahl tragen kann. Siehe den HTTP-Client.

Behalte die Defaults dotfiles: 'deny' und symlinks: 'within-root'. Aktiviere browse nur für interne Tools — ein öffentliches Listing legt deine Verzeichnisstruktur offen. Siehe Statische Dateien.

Wickle eine websocket()-Route in cors(...), um Cross-Origin-Upgrades abzuweisen, und lege Auth-Middleware darum — withMiddleware läuft zur Upgrade-Zeit und gated damit den Handshake.

Im Härtungs-Stack oben braucht eine websocket()-Route keine Sonderbehandlung: securityHeaders(), requestId() und der Rest dürfen darüber liegen wie über jeder anderen Route, und das Upgrade kommt trotzdem zustande. Ihre Header landen auf einem abgelehnten Handshake, nicht auf einem angenommenen — die 101 schreibt das Backend selbst. Siehe WebSocket.

MaßnahmeWomit
MIME-Sniffing auf jeder Response abgeschaltetnichts — per Default an
Security-Header auf jeder Responseserverweit withSecurityHeaders(...) — securityHeaders() deckt einen Teilbaum ab, Ablehnungen eingeschlossen
HTTPS vom Browser erzwungenstrictTransportSecurity() / withHsts
Cross-Origin kontrolliertcors(...)
CSRF (Browser-Formulare)csrfProtection(...)
Keine internen Fehler-LeakswithErrorHandler + generische Bodies
Keine Open Redirectsredirect(...) — same-origin per Default
Request-KorrelationrequestId()
Begrenzte LatenzrequestTimeout(...)
Sichere DateiauslieferunggetFromDirectory-Defaults (dotfiles deny, Symlink-Confinement)
Ausgehende Antworten begrenztnichts — 30 s und 8 MiB bei jedem HttpClient-Aufruf; pro Aufruf, pro newClient(...) oder actor-ts.http.client
Ausgehende Redirects begrenztnichts — 5 Hops, Credentials fallen bei Origin-Wechsel weg; redirect: 'error' weist sie ab