Warming up the neural circuits...
By the end of this chapter you will:
.env in productionnpm audit in CI and use Dependabot for dependency securityMost "we got hacked" stories are NOT about a actor breaking AES. They are about:
GET /invoices/:id that didn't check ownership.npm install that had a backdoor..env got stolen.Defence is layered. No single control protects you. Read this whole chapter.
String-concatenating user into a raw query is an immediate critical vulnerability. Always use parameterised queries or the ’s where: {} syntax.
// ❌ DEAD ON ARRIVAL — string concatenation
const rows = await this.sequelize.query(
`SELECT * FROM users WHERE email = '${email}'`
);
// User sends email = "x' OR '1'='1" → returns every user
// User sends email = "x'; DROP TABLE users; --" → enjoy
// ✅ Parameterised — driver escapes for you
const rows =
Sequelize's where: { ... } is always safe. Raw SQL with ? or :name placeholders is safe. Anything with template-literal interpolation of user input into a query string is a vulnerability.
If you find yourself thinking "I'll just escape the string myself" — stop. The list of edge cases (encodings, quote types, comments) is longer than you think. Use parameterisation.
Object.assign betrays you// ❌ Trusts the entire request body
@Patch('users/:id')
async update(@Param('id') id, @Body() body) {
const user = await this.userModel.findByPk(id
Sequelize's .update(values, { fields: [...] }) is a second line of defence:
await user.update(body, { fields: ['name', 'phone'] }); // ignores everything elseInsecure Direct Object Reference: a user can access another user's data by guessing the ID.
// ❌ JWT proves "you are logged in" — but who said this user can read order 5172?
@Get('orders/:id')
async get(@Param('id') id) {
return this.orderModel.findByPk(id);
}
// ✅ Always scope by ownership in the WHERE clause
Notice we return 404 (not 403) when the order is not theirs — otherwise an attacker can enumerate which order IDs exist.
This is rule 11 in Chapter 17. Apply it to every endpoint that takes an ID.
// ❌ User gives a URL, your backend fetches it
@Post('webhook/test')
async test(@Body('url') url) {
await axios.get(url); // attacker sends url = http://169.254.169.254/...
}169.254.169.254 is the AWS metadata endpoint. From inside an EC2/ECS task it returns IAM credentials. Your "test " endpoint just leaked your AWS keys.
Mitigations:
Library ssrf-req-filter or request-filtering-agent does this for you.
If your service parses XML (older webhook integrations, SOAP, SAML), make sure external entities are disabled:
import { XMLParser } from 'fast-xml-parser';
const parser = new XMLParser({ allowBooleanAttributes: false });
// fast-xml-parser does NOT support external entities by design — goodDefault Node parsers like libxmljs have XXE off by default since v0.19. Don't downgrade.
A nasty quirk of : if you merge user input into an object recursively, you can write to __proto__ and pollute every object in the runtime.
// ❌ Naïve deep merge
function merge(target, src) {
for (const k of Object.keys(src)) {
if (typeof src[k] === 'object') merge(target
Defences:
lodash.merge v4.17.21+ (older versions had this bug).class-validator — unknown fields are stripped.Object.freeze(Object.prototype) if you don't need to extend it.JSON.parse with a reviver that drops __proto__ and constructor keys.../ smuggled into a filename// ❌ User-controlled path joins
const file = path.join('/uploads', req.params.name); // name = "../etc/passwd"
fs.readFileSync(file);
// ✅ Resolve and verify containment
Better: don't accept paths from users at all. Use opaque IDs and look up the path in the DB (Chapter 19).
// ❌ exec with template literals
import { exec } from 'child_process';
exec(`convert ${filename} out.png`); // filename = "a.jpg; rm -rf /"
// ✅ Use execFile (no shell), pass args as an array
import { execFile } from 'child_process';
execFileThe exec* functions that accept argument arrays do not invoke /bin/sh, so shell metacharacters in filename are inert.
💡 Even better: avoid shelling out entirely. Use a library (
sharpinstead of ImageMagick CLI,pdf-libinstead ofpdftk).
Authentication answers "who are you". Authorisation answers "are you allowed to do this". Most apps get auth right and authorisation half-right.
Patterns:
@Roles('admin'). Necessary but coarse.Write the check explicitly each time. Don't make it implicit ("the is valid so it must be fine"). The cost of one extra if is far less than the cost of a privilege escalation.
Without rate limits:
Use @nestjs/throttler or express-rate-limit:
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 60_000 } }) // 5 attempts per minute
@Post('login')
login(...) { }Tighter limits on sensitive endpoints (login, password reset, OTP, SMS sends). Looser on read endpoints.
For real abuse protection, push rate limiting to the edge (Cloudflare, AWS WAF, API Gateway). Limits inside your app still consume CPU on every blocked request.
A "database compromise" usually means one of:
.env file leaked, IAM key in a public repo.The defences are concentric circles. You want all of them.
The DB should sit in a private subnet with no route to the internet. The app servers connect to it. The internet does not.
Internet → ALB (public subnet)
→ API instances (private subnet)
→ RDS PostgreSQL (isolated subnet, only the API SG allowed)If the only way to talk to the DB is through your API, an attacker who steals the DB credentials still cannot use them from their laptop.
Don't run the application as the database superuser. Create three users:
CREATE ROLE app_writer LOGIN PASSWORD '...'; -- INSERT/UPDATE/DELETE/SELECT, no DDL
CREATE ROLE app_reader LOGIN PASSWORD '...'; -- SELECT only — for read replicas
CREATE ROLE migrations LOGIN PASSWORD '...'; -- DDL — used only by CI to run migrations
GRANT SELECT ON ALL TABLES
The app's main connection uses app_writer. A SQL injection bug becomes "attacker can read/write data" but not "attacker can DROP TABLE users" or "attacker can read pg_shadow to dump password hashes."
sslmode=require from the client. Reject non-TLS connections at the DB.RDS gives you PITR for free. Turn it on with a 35-day window. Test the restore — a backup you've never restored is not a backup, it's a hope.
1. Create a new RDS instance from a snapshot 24 hours old
2. Confirm rows are correct
3. Tear it downDo this drill once a quarter.
pgAudit extension logs every DDL and DML event. Pipe these to CloudWatch / S3 with a retention policy. After an incident, you want to be able to answer "which rows did the attacker read".
.env files are fine for local dev. They are not fine for production.
In production:
Other rules:
.env — .env belongs in .gitignore. Use git-secrets as a pre-commit to block accidental commits.password, token, secret keys from any log line (Chapter 12).Your code is maybe 5% of what runs in production. The other 95% is npm packages.
npm audit in CI — fail on --audit-level=high.npm ci not npm install in production builds — uses the lockfile exactly.overrides in package.json when a sub-dep is known-bad.node-fetch is real, node-feth is malware.The Solar Winds, ua-parser-js, event-stream, colors.js incidents all happened because nobody read the latest commit before pulling in a transitive update.
Logs are useful and dangerous:
// ❌ Logs the entire request including the password
logger.log(`Login attempt: ${JSON.stringify(req.body)}`);
// ❌ Logs the JWT in the auth header
logger.log(`Headers: ${JSON.stringify(req
PII leaking into logs is a real GDPR / DPDPA violation. Set up redaction (Chapter 12) and review your log lines.
// main.ts
app.use(helmet()); // sets ~10 security headers
app.enableCors({
origin: ['https://app.example.com'], // never '*' for authed APIs
credentials: true,
});helmet() sets:
Strict-Transport-Security (HSTS)X-Content-Type-Options: nosniffX-Frame-Options: DENYContent-Security-PolicyReferrer-PolicyFive lines, enormous return.
| # | Risk | Where this chapter covers it |
|---|---|---|
| 1 | Broken Access Control | IDOR, authorisation bugs |
| 2 | Cryptographic Failures | Encryption at rest / in transit |
| 3 | Injection | SQL injection, command injection, XXE |
| 4 | Insecure Design | Threat model, defence in depth |
| 5 | Security Misconfiguration | Helmet, CORS, public S3 buckets |
| 6 | Vulnerable Components | Dependency scanning |
| 7 | Auth Failures | Chapter 10 — JWT rules |
| 8 | Data Integrity Failures | Mass assignment, prototype pollution |
| 9 | Logging Failures | Logging without leaking |
| 10 | SSRF | SSRF section |
If you can talk through every item with a real example from this codebase, you’re hireable.
❌ String-concat into SQL.
❌ Object.assign(model, req.body).
❌ findByPk(id) without ownership scope.
❌ User-controlled URL in axios.get(url).
❌ child_process.exec() with template literals.
❌ Public bucket / public RDS.
❌ Same secret across environments.
❌ Logging the request body.
❌ cors({ origin: '*' }) on an authed API.
❌ Running the app as the DB superuser.
❌ Skipping npm audit in CI.
.envnpm audit in CI; Dependabot enabledDefence is layered. No single control protects you. SQL injection is fixed by parameterised queries. IDOR is fixed by scoping every query by owner_id. Secrets are managed outside the repo. Dependencies are audited in CI. Run through the hardening checklist quarterly.