Static Analysis for TypeScript: Where Type Safety Stops and SAST Begins
In July 2024, security researchers traced a quiet supply-chain incident through the npm registry: a popular @types/* package commonly pulled into TypeScript projects had been swapped through an account takeover, and the new release shipped a postinstall script that exfiltrated environment variables from the developer machines that ran npm install. The incident did not require a clever runtime exploit; the moment a developer in a TypeScript codebase added the package to devDependencies, the script ran with the privileges of their shell. A year earlier, the same registry had absorbed CVE-2023-26136, a prototype-pollution flaw in the widely used tough-cookie dependency that affected every TypeScript application using the popular request-handling stacks built on top of it. The pattern is familiar to anyone who has shipped a Node service: the type system catches one class of bug, the supply chain delivers another, and the runtime that executes the compiled JavaScript treats both equally.
Static analysis for TypeScript operates inside that gap. The compiler is excellent at the problems it was designed to solve — null safety, structural typing, exhaustive switch checks — and it is not a security tool. It has no concept of tainted data, no model of trust boundaries, no rule that says req.body deserves more scrutiny than a string literal. This guide walks through why the type system does not substitute for SAST, what the TypeScript-flavored vulnerability picture actually looks like across NestJS, Next.js, and Express, and which scanners catch the patterns that JavaScript SAST covers when the source is typed.
The TypeScript Vulnerability Picture
TypeScript is JavaScript with a static type checker bolted on at compile time. The type information is erased before node sees the code, which means every JavaScript runtime vulnerability still applies in full force. Prototype pollution (CWE-1321) writes through __proto__ at runtime regardless of how strictly the merge function was typed — see our prototype pollution deep dive for the mechanics. SQL injection in raw queries shows up across NestJS repositories that bypass the query-builder API, in Prisma $queryRawUnsafe calls, and in Drizzle raw fragments. SSRF through user-controlled URLs reaches Node's fetch, axios, and got the same way it does in plain JavaScript. Command injection through child_process.exec is unaffected by the argument's TypeScript type. Insecure deserialization through JSON.parse followed by a class-constructor cast or plainToClass from class-transformer lets an attacker shape the object into something the rest of the codebase trusts.
The type system also fails open in two specific ways that matter for security. The first is the any escape hatch: any value cast to any drops every constraint, and any return from a third-party library typed as any propagates through the codebase without warning. The second is the unchecked type assertion — req.body as LoginInput tells the compiler to trust the shape of req.body without any runtime validation, and the compiler complies. Cross-site scripting in React's dangerouslySetInnerHTML, Next.js server-rendered HTML, and Vue's v-html remains the highest-volume client-side finding. Path traversal in fs.readFile, hardcoded secrets, weak randomness from Math.random used for tokens, and typosquatting in the @types namespace round out the list.
NestJS SQL Injection: The Raw Query Behind a Decorator
NestJS controllers look safe because the framework does so much for you — DI containers, decorators, pipes, automatic transformation. The vulnerability arrives the moment a developer reaches past the repository abstraction into the underlying connection to write a query the query-builder cannot express:
// VULNERABLE
import { Controller, Get, Param } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@Controller('users')
export class UsersController {
constructor(@InjectDataSource() private readonly ds: DataSource) {}
@Get(':id')
async findOne(@Param('id') id: string) {
return this.ds.query(
`SELECT id, email, role FROM users WHERE id = ${id}`
);
}
} The id: string parameter type tells the compiler nothing about whether the value is safe. A request to /users/1%20OR%201=1 returns every row. The fix is the parameterized form that every TypeORM, Prisma, and pg driver has supported since the day it shipped:
// FIXED
@Get(':id')
async findOne(@Param('id') id: string) {
return this.ds.query(
'SELECT id, email, role FROM users WHERE id = $1',
[id]
);
} SAST catches this by tracing the flow from the @Param-decorated argument into the first string argument of ds.query and flagging it as a tainted-string sink. The detection holds across template literals, string concatenation, and helper functions that wrap the call. See our coverage of A03 Injection for the broader category.
Next.js API Route SSRF: The Fetch the Attacker Wrote
Next.js API routes and server actions inherit the full Node runtime, which means they inherit the full SSRF surface. The vulnerable shape passes a user-supplied URL straight into the platform fetch with the request's auth context still attached:
// VULNERABLE
// app/api/preview/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const { url } = (await req.json()) as { url: string };
const response = await fetch(url);
const text = await response.text();
return NextResponse.json({ preview: text.slice(0, 500) });
} The as { url: string } assertion is the type-system trap: it satisfies the compiler without validating anything at runtime. An attacker submits http://169.254.169.254/latest/meta-data/iam/security-credentials/ and reads cloud-instance metadata. The fix validates the URL against an allowlist of permitted hosts and refuses private address ranges before the request goes out:
// FIXED
import { NextRequest, NextResponse } from 'next/server';
import { promises as dns } from 'node:dns';
import ipaddr from 'ipaddr.js';
import { z } from 'zod';
const PreviewInput = z.object({ url: z.string().url() });
async function isSafeUrl(rawUrl: string): Promise<boolean> {
let parsed: URL;
try { parsed = new URL(rawUrl); } catch { return false; }
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
const addresses = await dns.resolve(parsed.hostname);
return addresses.every((addr) => ipaddr.parse(addr).range() === 'unicast');
}
export async function POST(req: NextRequest) {
const parsed = PreviewInput.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: 'invalid input' }, { status: 400 });
}
if (!(await isSafeUrl(parsed.data.url))) {
return NextResponse.json({ error: 'invalid url' }, { status: 400 });
}
const response = await fetch(parsed.data.url, { redirect: 'manual' });
const text = await response.text();
return NextResponse.json({ preview: text.slice(0, 500) });
} The Zod schema turns the type assertion into a real runtime check; the DNS resolution and address check close the obvious bypasses; redirect: 'manual' prevents an attacker-controlled server from returning a 302 to a private address and walking past the validation. SAST flags the unvalidated req.json() result reaching fetch; only careful flow analysis recognizes the validated form as safe.
The any Cast That Bypasses Everything
The most TypeScript-specific vulnerability shape is the one where the type system is told to look the other way. A class meant to represent an authenticated user is constructed from raw JSON, the compiler is silenced with a cast, and the rest of the codebase treats the object as if it had been validated:
// VULNERABLE
interface User {
id: number;
email: string;
role: 'admin' | 'user';
}
app.post('/session', (req, res) => {
const user = JSON.parse(req.body.token) as User;
if (user.role === 'admin') {
return res.json({ access: 'full' });
}
return res.json({ access: 'limited' });
}); The attacker controls req.body.token entirely; the cast asserts a shape the runtime never verifies, so any payload claiming "role": "admin" wins. The fix is a runtime schema that produces the typed value only when the input matches:
// FIXED
import { z } from 'zod';
const UserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
role: z.enum(['admin', 'user'])
});
app.post('/session', (req, res) => {
const parsed = UserSchema.safeParse(JSON.parse(req.body.token));
if (!parsed.success) {
return res.status(400).json({ error: 'invalid token' });
}
// parsed.data is fully validated and properly typed
return res.json({ access: parsed.data.role === 'admin' ? 'full' : 'limited' });
}); The safeParse result narrows to the typed value only after every field has been checked at runtime. SAST tools that understand the TypeScript AST flag the original because the cast crosses a trust boundary without any validator in between; the fix moves the call through a known-safe schema that the analyzer recognizes as input validation.
Detection: The Compiler Is Not a SAST Tool
The TypeScript compiler is a type checker. It tells you that req.body.id is unknown until you narrow it; it does not tell you that the resulting string is tainted. Linting fills the first layer of the gap: typescript-eslint with the eslint-plugin-security and eslint-plugin-no-unsanitized plugins catches a meaningful fraction of eval, Function, innerHTML, and child_process.exec findings on a syntactic basis, runs on every save in editors that have it configured, and is the cheapest possible feedback loop. The next layer is Semgrep, which ships a TypeScript ruleset alongside its JavaScript ruleset with both pattern-based and limited taint-based rules and runs in CI fast enough to gate pull requests. Snyk Code covers the commercial end with broader inter-procedural taint analysis and IDE plugins for VS Code and JetBrains.
Most modern SAST engines analyze the emitted JavaScript rather than the TypeScript source directly, which is fine for the vast majority of bug classes — the runtime semantics are what matter for prototype pollution, SSRF, and command injection. The TypeScript-aware engines additionally inspect the source AST, which lets them reason about decorators, framework conventions, and unsafe type assertions that the compiled output flattens. The patterns that any modern TypeScript SAST should catch on the first pass are: tainted input flowing to eval, Function, setTimeout with a string argument, the vm family, child_process.exec and execSync, raw-string queries to NestJS data sources and Prisma $queryRawUnsafe, innerHTML and framework-specific HTML sinks, and unvalidated URLs reaching outbound HTTP clients. The patterns that need real data-flow analysis to catch reliably are prototype pollution in custom merge helpers, gadget chains across multiple files, and SSRF where the URL is composed from several pieces. See our SAST tools comparison for how the major scanners rank on JavaScript and TypeScript benchmarks.
Where GraphNode SAST Fits
GraphNode SAST covers TypeScript as part of its JavaScript-family analysis, with inter-procedural data flow that traces user input from NestJS controllers, Next.js route handlers, Express middleware, and tRPC procedures across services and ORM layers to the sinks where the bug class fires. The engine analyzes both the TypeScript source and the emitted JavaScript so unsafe type assertions and decorator-driven entry points are visible, and it surfaces findings on the pull request that introduced the unsafe flow. Scans run in CI in the same time budget as a typical test suite.
Closing
TypeScript pays its security dividend by removing one large class of bugs — the null dereferences, the wrong-shape arguments, the missing case in the switch — and it leaves the rest of the runtime untouched. SQL injection still exists; the parameter just has a type now. SSRF still exists; the URL is just declared string. Prototype pollution still exists; the merge helper is just generic. The teams that catch these patterns reliably treat the type system as a productivity tool rather than a security boundary, validate every untrusted input through a runtime schema before the cast, wire typescript-eslint and Semgrep into the editor and CI, and pick a SAST scanner that understands the framework they actually use. The cost of catching the flow on the diff is one review comment; the cost of catching it after a credential leaks from cloud metadata is everything else.
GraphNode SAST analyzes TypeScript and JavaScript with full inter-procedural data flow across 13+ languages — request a demo.
Request Demo