Sicherheit
Das Framework liefert die Bausteine; diese Seite zeigt, wie man sie
zusammensetzt. Das lauffähige Gegenstück ist
examples/http/secure-service.ts.
Der empfohlene Stack
Abschnitt betitelt „Der empfohlene Stack“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), )))))));| Schicht | Warum hier |
|---|---|
requestId | ganz außen, damit jede Log-Zeile (auch Fehler) eine ID hat |
securityHeaders | über allem, was ablehnt — damit auch diese Responses die Header tragen |
cors | außerhalb der Auth — Preflights sind laut Spec anonym |
requestTimeout | Latenz begrenzen, bevor echte Arbeit läuft |
handleErrors | außerhalb von csrf, damit es dessen eigene 403 mappt — und innerhalb der Header-Schichten, damit das Ergebnis noch durch sie läuft |
csrf | nur Browser-Apps; nach CORS, vor den Handlern |
fallback | an der Wurzel — beantwortet alles Unpassende |
Was jede Response ohnehin schon trägt
Abschnitt betitelt „Was jede Response ohnehin schon trägt“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.
Betriebsumfeld
Abschnitt betitelt „Betriebsumfeld“- 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.remoteAddressist der Socket-Peer, kein fälschbaresX-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 (trustedProxiesvonIpAllowlist), 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östtrust proxy: truegenau 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.
Cookies
Abschnitt betitelt „Cookies“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=Laxconst 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.
Fehlerhygiene
Abschnitt betitelt „Fehlerhygiene“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.
Redirects
Abschnitt betitelt „Redirects“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.
Untrusted Input begrenzen
Abschnitt betitelt „Untrusted Input begrenzen“- Request-Bodies sind auf jedem Backend bei 1 MiB gekappt, und ein
größerer Body wird mit demselben
413 Payload Too Largeabgewiesen, egal welches Backend bedient — bevor der Handler läuft und ohnewithErrorHandlerzu 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,bodyLimitin der Options-Bag vonFastifyBackend. Ein Body mit einerContent-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
maxFrameBytesgekappt (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 überactor-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, dessenupgradeWebSocketüberhaupt kein Payload-Limit anbietet; Bun mit dem Express- oder Fastify-Backend, wowsauf Buns eingebauten Shim auflöst, dermaxPayloadzwar annimmt und zurückliefert, aber nicht durchsetzt; und der ausgehendeWebsocketClientActorauf jeder Laufzeit — kein natives Client-WebSocketbeachtet ein Payload-Limit, das man seinem Konstruktor übergibt, gemessen auf Bun, Node und Deno, wo die Option angenommen wird, alsundefinedzurü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üsseltenws://-Verbindung. Siehe WebSocket. - Untrusted HTML — mit dem
html-Template escapen; für Rich-Markup vorrawHtmlmit 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
HttpClientmaterialisiert 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 — eineContent-Lengthist 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 wirftHttpResponseTooLargeError. Heb die Werte pro Aufruf an (maxResponseBytes,timeoutMs), pro Integration (newClient(...)) oder flottenweit unteractor-ts.http.client. Redirects sind mitbegrenzt — 5 Hops, undauthorization/cookiefallen bei jedem Origin-Wechsel weg, damit eine feindlicheLocationkein Bearer-Token zu einem Host ihrer Wahl tragen kann. Siehe den HTTP-Client.
Statische Dateien
Abschnitt betitelt „Statische Dateien“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.
WebSocket
Abschnitt betitelt „WebSocket“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.
Checkliste
Abschnitt betitelt „Checkliste“| Maßnahme | Womit |
|---|---|
| MIME-Sniffing auf jeder Response abgeschaltet | nichts — per Default an |
| Security-Header auf jeder Response | serverweit withSecurityHeaders(...) — securityHeaders() deckt einen Teilbaum ab, Ablehnungen eingeschlossen |
| HTTPS vom Browser erzwungen | strictTransportSecurity() / withHsts |
| Cross-Origin kontrolliert | cors(...) |
| CSRF (Browser-Formulare) | csrfProtection(...) |
| Keine internen Fehler-Leaks | withErrorHandler + generische Bodies |
| Keine Open Redirects | redirect(...) — same-origin per Default |
| Request-Korrelation | requestId() |
| Begrenzte Latenz | requestTimeout(...) |
| Sichere Dateiauslieferung | getFromDirectory-Defaults (dotfiles deny, Symlink-Confinement) |
| Ausgehende Antworten begrenzt | nichts — 30 s und 8 MiB bei jedem HttpClient-Aufruf; pro Aufruf, pro newClient(...) oder actor-ts.http.client |
| Ausgehende Redirects begrenzt | nichts — 5 Hops, Credentials fallen bei Origin-Wechsel weg; redirect: 'error' weist sie ab |
