In this article
Reicht TypeScript für die Anwendungssicherheit aus?
TypeScript, eine Obermenge von JavaScript, führt statische Typisierung ein – ein leistungsstarkes Feature, das die Wartbarkeit von Code verbessert. Aber steigert es auch unsere Sicherheitserwartungen?
In diesem Beitrag möchte ich meine persönlichen Beobachtungen zu Trugschlüssen rund um die TypeScript-Sicherheit teilen und erläutern, wie überraschende Fallstricke die Erwartungen von Entwicklern enttäuschen können.
Welche Sicherheitsversprechen gibt TypeScript? Sehen wir uns das Problem direkt zu Beginn genauer an.
Nehmen wir an, Sie verlassen sich bei der Typsicherheit auf TypeScript. Eine kurze Frage: Zeigt dieses Beispiel, wie TypeScript alles im Griff behält und nur ausdrücklich Erlaubtes zulässt?
class UserController {
public getUserHelloComponent: RequestHandler = async (
_req: Request<{},{},{}, {name?: string} >,
res: Response) => {
const userName = _req.query.name || "World";
if (!sanitizeXSS(userName)) {
return res.status(400).send("Bad input detected!");
}
const helloComponent = `<h1>Hello, ${userName}!</h1>`;
return res.send(helloComponent);
}Noch wichtiger ist die Frage: Betrachten Sie den Einsatz von TypeScript als hilfreiche Typprüfung zur Entwicklungszeit, oder sind Sie der Ansicht, dass es auch für echte Sicherheit zur Laufzeit sorgt?
Falls Sie mit Laufzeitsicherheit geantwortet haben, begeben wir uns auf eine Entdeckungsreise zu Sicherheitskontrollen und Trugschlüssen rund um die TypeScript-Sicherheit.
Einführung in TypeScript
Mit TypeScript können Entwickler potenzielle Fehler früh im Entwicklungszyklus erkennen, indem sie Datentypen für Variablen, Funktionen und Objekte explizit definieren. Durch den Einsatz von TypeScript reduziert dieser proaktive, ergänzende Ansatz zum Erstellen von Anwendungen auf JavaScript-Basis das Risiko von Laufzeitfehlern, Sicherheitslücken und unerwartetem Verhalten erheblich.
Bei diesem Einstieg in TypeScript geht es darum, Laufzeitfehler aufgrund von Typen zu minimieren. TypeScript ist per Definition und tatsächlicher Verwendung ein Tool für die Entwicklungszeit. Entwickler schreiben ihren Code in TypeScript und kompilieren ihn zu JavaScript. Das TypeScript-Tooling-Ökosystem ermöglicht es ihnen, die Typsicherheit mithilfe der IDE-Unterstützung sicherzustellen und den Code über einen Build- und Continuous-Integration-Prozess auf korrekte Typen zu prüfen.
Das folgende Beispiel zeigt die Definition eines API-Routen-Handlers in einer mit TypeScript erstellten Express-Anwendung:
class UserController {
public getUsers: RequestHandler = async (_req: Request, res: Response) => {
const filterQuery: string = _req.query.filter as string || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });
return handleServiceResponse(serviceResponse, res);
};
}TypeScript und Sicherheit
Ein typbezogenes Sicherheitsproblem beim Programmieren ist als Type Juggling bekannt. Dabei versucht JavaScript implizit, Werte eines Typs in einen anderen umzuwandeln. Wahrscheinlich kennen Sie dieses Verhalten bereits aus den verschiedenen „Eigenheiten“ von JavaScript, die in Rätseln oft thematisiert werden.
Zum Beispiel:
// prints true
let x = 1;
console.log(x == "1")Der obige Code gibt „true“ aus, weil eine Typumwandlung stattfindet. JavaScript wandelt zunächst beide Operanden in denselben Typ um und vergleicht sie anschließend. Dabei wird „1“ als numerische 1 interpretiert und dann verglichen, was zu einem als wahr ausgewerteten Ergebnis führt.
Indem TypeScript die erwarteten Typen für Variablen, Funktionsparameter und Rückgabewerte definiert, kann es viele typbezogene Fehler bereits während der Entwicklung erkennen, bevor sie in der Produktion ausgenutzt werden können. Aber können Sie sich darauf verlassen? Type Juggling kann zu Sicherheitslücken führen, weil die Typen zur Laufzeit nicht strikt durchgesetzt werden. Dadurch lässt sich die Typprüfung umgehen, die für diese Typen vorgesehen war.
Eine Express- und TypeScript-Anwendung
Für die folgenden Beispiele verwenden wir eine Node.js-Backend-API, die auf dem Express-Webframework und einer TypeScript-Integration basiert.
Die Node.js-Server-API-Anwendung ist insbesondere wie folgt strukturiert:
src/
api/
user/
userController.ts
userRouter.ts
userModel.ts
userService.ts
userRepository.ts
server.tsDer Express-Servercode lädt die Routendefinitionen, Middleware und weitere Konfigurationen wie folgt:
import { userRouter } from "@/api/user/userRouter";
// Middlewares
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors({ origin: env.CORS_ORIGIN, credentials: true }));
app.use(helmet());
app.use(rateLimiter);
// Request logging
app.use(requestLogger);
// Routes
app.use("/health-check", healthCheckRouter);
app.use("/users", userRouter);In den Beispielen zur TypeScript-Sicherheit in diesem Artikel verwenden wir durchgehend das Beispiel userRouter.
TypeScript-Sicherheitsumgehung Nr. 1
Wir haben einen /users-Endpunkt, der als REST-API-Endpunkt dient. Er ermöglicht es Benutzern, nach sich selbst zu suchen und dabei insbesondere eine Filterzeichenfolge zu übergeben, um nach dem Namen eines Benutzers zu suchen.
Das Repository-Muster, das zeigt, wie diese Filterung erfolgt, sieht wie folgt aus: Es stellt eine Methode findAllAsync bereit, die ein Objekt mit einer Eigenschaft filter entgegennimmt.
export class UserRepository {
async findAllAsync({ filter }: { filter?: string } = {}): Promise<User[]> {
if (filter) {
return users.filter((user) => user.name.startsWith(filter));
}
return users;
}
}Zum Abfragen der Benutzer wird eine HTTP-GET-Anfrage gesendet:
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter=Al"| jq
Wie gelangt die Abfragezeichenfolge filter von der Routendefinition zur Repository-Schicht? Sehen wir uns dieses Muster an und wie wir TypeScript dabei einsetzen können.
Die HTTP-Routendefinition sieht wie folgt aus: Der Controller-Code für die Route / am Präfix /users wird definiert und verarbeitet alle HTTP-Anfragen mit der GET-Methode:
import { userController } from "./userController";
export const userRouter: Router = express.Router();
userRouter.get("/", userController.getUsers);Hier ist der Express-Controller-Code, der die HTTP-GET-Anfragen für diese Route verarbeitet:
import type { Request, RequestHandler, Response } from "express";
import { userService } from "@/api/user/userService";
import { handleServiceResponse } from "@/common/utils/httpHandlers";
class UserController {
public getUsers: RequestHandler = async (_req: Request, res: Response) => {
const filterQuery: any = _req.query.filter || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });
return handleServiceResponse(serviceResponse, res);
};
}Wie bei userRepository wurde auch die Definition des Dienstes userService, den der Controller hier aufruft, so angepasst, dass das erste Funktionsargument ein Objekt mit einer Eigenschaft filter entgegennimmt und dem Typ filterQuery entspricht.
Die HTTP-Antwort auf unsere curl-Anfrage listet alle Benutzer auf, deren Namen mit dem Buchstaben „Al“ beginnen. Wir erhalten also:
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter=Al"| jq
{
"success": true,
"message": "Users found",
"responseObject": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"age": 42,
"createdAt": "2025-01-13T10:51:37.118Z",
"updatedAt": "2025-01-18T10:51:37.118Z"
}
],
"statusCode": 200
}Bis hierhin gibt es keine Überraschungen. Aber was passiert, wenn die GET-Anfrage die Abfragezeichenfolge filter so übermittelt, dass sie als Array und nicht als Zeichenfolge interpretiert wird?
Hier ist die Exploit-Payload, die in unserer Node.js-Webanwendung ein Type-Juggling-Sicherheitsproblem verursachen würde. Dazu muss lediglich das Abfragezeichenfolgenfeld von filter= in filter[]= geändert werden. Die Antwort bleibt dieselbe:
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter[]=Al"| jq
{
"success": true,
"message": "Users found",
"responseObject": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"age": 42,
"createdAt": "2025-01-13T10:51:37.118Z",
"updatedAt": "2025-01-18T10:51:37.118Z"
}
],
"statusCode": 200
}Das funktioniert wie zuvor, weil JavaScript das Array gemäß seiner toString-Definition in eine Zeichenfolge umwandelt. Das Ergebnis ist das erste Element des Arrays, also der Text „Al“. Das Verhalten bleibt unverändert, obwohl die Variable filter intern nun als Zeichenfolge behandelt wird. Ist das problematisch? Das werden wir sehen.
Vielleicht ist Ihnen inzwischen die haarsträubende TypeScript-Definition im Controller-Code aufgefallen:
const filterQuery: any = _req.query.filter || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });Die Verwendung des dunklen Zaubers von any in TypeScript gilt als unkonventionell und ist verpönt – vielleicht nur noch übertroffen von der Verwendung von eval in JavaScript. Kein Wunder also, dass es Type Juggling gibt. Wir haben zugelassen, dass die Abfragezeichenfolge filter jeden beliebigen Typ annehmen kann, und sie dann im Benutzerdienst und im Repository verwendet.
Machen wir es besser.
TypeScript-Sicherheitsumgehung Nr. 2
Wir verzichten auf den speziellen TypeScript-Platzhaltertyp any und definieren die Variable filter stattdessen strikt und explizit als Zeichenfolgentyp.
Ändern wir die Routenimplementierung so, dass sie einen TypeScript-Zeichenfolgentyp verwendet:
class UserController {
public getUsers: RequestHandler = async (_req: Request, res: Response) => {
// we now define query as string
const filterQuery: string = _req.query.filter as string || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });
return handleServiceResponse(serviceResponse, res);
};Um sicherzustellen, dass alle Typen korrekt festgelegt sind und keine Typdefinitionsfehler vorliegen, führen wir den Compiler tsc aus und bestätigen, dass tatsächlich keine Fehler auftreten:
$ npx tscWas würde passieren, wenn wir dieselbe Type-Juggling-HTTP-Payload senden, in der filter als Array angegeben ist? Sehen wir nach:
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter[]=Al"| jq
{
"success": true,
"message": "Users found",
"responseObject": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"age": 42,
"createdAt": "2025-01-13T10:51:37.118Z",
"updatedAt": "2025-01-18T10:51:37.118Z"
}
],
"statusCode": 200
}Obwohl wir den Typ der Abfragezeichenfolge filter explizit als Zeichenfolge definiert haben und der TypeScript-Compiler fehlerfrei durchlief, konnten wir dennoch einen Type-Juggling-Angriff durchführen. Dieser Angriff dient selbstverständlich nur dazu, die Möglichkeiten zu demonstrieren, und ist nicht schädlich.
TypeScript-Sicherheitsumgehung Nr. 3
Probieren wir einen anderen TypeScript-Mechanismus zur Typdeklaration aus.
Wir verwenden eine gängigere Strategie zur Typisierung, anstatt TypeScript mit einer Typdeklaration wie any oder as string zu übergehen. Wir definieren die Schnittstelle für das erwartete Abfragezeichenfolgenschema und wenden sie auf den Objekttyp Request an.
Dieser GET-Endpunkt dient als vereinfachte Alternative zu React Server Components. Er nimmt eine Abfragezeichenfolge für den Namen des Benutzers entgegen und liefert eine HTTP-Antwort mit dem HTML-Inhalt der Komponente, die im DOM gerendert wird.
Die folgende Implementierung zeigt die serverseitige Komponente:
interface UserComponentQueryString {
name?: string;
}
class UserController {
public getUserHelloComponent: RequestHandler = async (
_req: Request<{}, {}, {}, UserComponentQueryString>,
res: Response) => {
const userName = _req.query.name || "World";
if (!sanitizeXSS(userName)) {
return res.status(400).send("Bad input detected!");
}
const helloComponent = `<h1>Hello, ${userName}!</h1>`;
return res.send(helloComponent);
}
}Wie Sie sehen, typisieren wir den Teil der Abfragezeichenfolge im Request-Objekt wie erwartet und über ReqQuery angegeben:
(alias) interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = qs.ParsedQs, Locals extends Record<string, any> = Record<string, any>>
import RequestAufgrund des offensichtlichen Sicherheitsproblems Cross-Site-Scripting (XSS) bereinigen wir auch die Benutzereingaben. Deshalb rufen wir eine Funktion sanitizeXSS() auf. Hier ist die Implementierung:
function sanitizeXSS(name: string): boolean {
const disallowList = ["<", ">", "&", '"', "'", "/", "="];
return !disallowList.some((badInput) => name.includes(badInput));
}Damit haben wir mithilfe der folgenden Mechanismen Sicherheitsleitplanken eingerichtet:
Unsere Schnittstelle ordnet das generische
Request<T>explizit zu. Der vierte Parameter,ReqQuery, wird aufUserComponentQueryStringgesetzt. Dadurch wird sichergestellt, dass die Abfragezeichenfolge der in unserer Schnittstelle definierten Struktur entspricht. Das bedeutet, dass_req.queryein Objekt mit einer optionalen Eigenschaft name vom Typ string ist.Wir gehen davon aus, dass Benutzer diese Logik zur Komponentenerstellung missbrauchen und ein Abfragezeichenfolgenfeld
namemit einem Wert wie<img src=x onError=alert(1) />übergeben könnten. Das ist ein vereinfachtes Beispiel dafür, wie sie schädliche JavaScript-Operationen clientseitig ausführen könnten. Um das zu verhindern, haben wir die FunktionsanitizeXSSerstellt. Sie erkennt gefährliche Zeichenfolgen in der Eigenschaftnameund verhindert, dass diese in der generierten HTML-Komponente verwendet werden.
Versuchen wir, eine solche schädliche Payload-Anfrage zu senden:
$ curl -G -X 'GET' -H 'accept: application/json' "http://localhost:8080/users/component" --data-urlencode "name=<img liran"
Bad input detected!Wie Sie sehen, haben wir die erwartete Antwort erhalten: Unsere Logik bereinigt schädliche Zeichen aus Benutzereingaben.
Was passiert jedoch, wenn wir die erlernte Sicherheitslücke durch Type Juggling ausnutzen und das Feld name in name[] umbenennen? Wird es als Array übergeben?
$ curl -G -X 'GET' -H 'accept: application/json' "http://localhost:8080/users/component" --data-urlencode "name[]=<img src=x onError=alert(1) />"
<h1>Hello, <img src=x onError=alert(1) />!</h1>Wir haben unsere Sicherheitsvorkehrungen umgangen! Wie konnte das passieren?
Das Array name[] wird an die Funktion sanitizeXSS() übergeben. Diese versucht, die Funktion includes() auf den übergebenen Namen anzuwenden, und verwendet dazu das Prädikat für den Abgleich mit dem Array disallowList. name ist jedoch keine Zeichenfolge, sondern ein Array. Deshalb gibt das Prädikat nie den booleschen Wert true zurück und die Cross-Site-Scripting-Bereinigungslogik schlägt fehl.
Beobachtungen und Erkenntnisse zu TypeScript-Praktiken
Verwenden Sie nicht den TypeScript-Platzhaltertyp
any.Typen wie
_req.query.filter as stringzu erzwingen, gilt als schlechte Praxis. Manche bezeichnen das als „TypeScript anzulügen“.Sie können die Praxis des TypeScript Narrowing anwenden, die oft als „Type Guard“ bezeichnet wird und zur Laufzeit prüft, ob die erwarteten Typen vorliegen. Zum Beispiel:
if (typeof padding === "number") { … }. Dies könnte jedoch zur regelmäßigen Praxis in der gesamten Codebasis werden und die Lesbarkeit sowie Wartbarkeit beeinträchtigen.
Ist TypeScript gut für die Sicherheit? Ja, aber dafür wurde es nicht entwickelt.
Meiner Meinung nach prüft TypeScript den Code zur Entwicklungszeit. Sie können zwar Typwächter implementieren, das geht aber auch mit reinem JavaScript. Um erwartete Typen, Strukturen und die Validierung von Daten während ihres gesamten Wegs durch den Code wirksam durchzusetzen, benötigt TypeScript eine Laufzeit-Ergänzung in Form einer Schema-Validierung, etwa mit Zod und anderen Bibliotheken.
Von Entwicklern geschätzt. Von der Security vertraut.
Die Developer-first-Tools von Snyk bieten integrierte und automatisierte Security, die Ihren Governance- und Compliance-Anforderungen gerecht wird.