By the end of this tutorial you'll have URL allow-listing and SSRF protection, security headers, secrets in a manager, dependency & static analysis scanning in CI, and a one-page threat model in your repo.
← Back to Module 09 overviewjavascript:, file:, and private-IP targets.curl -I shows HSTS, CSP, no X-Powered-By..env in production; loaded from cloud secret manager.docs/threat-model.md committed and linked from the README.npm install ipaddr.js
Create src/url-policy.ts:
import { lookup } from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
const DENY_HOSTS = new Set(['localhost', '0.0.0.0']);
export async function isSafeTarget(raw: string): Promise<boolean> {
let u: URL;
try { u = new URL(raw); } catch { return false; }
if (!ALLOWED_PROTOCOLS.has(u.protocol)) return false;
if (DENY_HOSTS.has(u.hostname.toLowerCase())) return false;
const records = await lookup(u.hostname, { all: true });
for (const r of records) {
const a = ipaddr.parse(r.address);
const range = a.range();
if (['private', 'loopback', 'linkLocal', 'uniqueLocal', 'reserved'].includes(range))
return false;
}
return true;
}
Use it in POST /shorten before insert; reject with 400.
it.each([
'javascript:alert(1)',
'file:///etc/passwd',
'http://localhost/admin',
'http://127.0.0.1/',
'http://169.254.169.254/latest/meta-data/'
])('rejects %s', async (url) => {
const r = await request(app()).post('/shorten').send({ url });
expect(r.status).toBe(400);
});
npm install helmet
// in src/app.ts, before routes
import helmet from 'helmet';
app.disable('x-powered-by');
app.use(helmet({
contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"] } },
hsts: { maxAge: 63072000, includeSubDomains: true, preload: true }
}));
curl -I http://localhost:3000/health shows strict-transport-security, content-security-policy, no x-powered-by.In src/code.ts:
import { randomInt } from 'node:crypto';
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
export function randomCode(length = 7): string {
let out = '';
for (let i = 0; i < length; i++) out += ALPHABET[randomInt(ALPHABET.length)];
return out;
}
Stop using .env in production:
fly secrets set JWT_SECRET=… DATABASE_URL=… — already done in Module 08..env with dev-only values; ensure .env is in .gitignore.Verify nothing real has leaked:
npx gitleaks detect --no-git --source . npx gitleaks detect # scans full git history
Create .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule: { interval: weekly }
- package-ecosystem: github-actions
directory: "/"
schedule: { interval: weekly }
- package-ecosystem: docker
directory: "/"
schedule: { interval: weekly }
Create .github/workflows/security.yml:
name: security
on: [pull_request, push]
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: gitleaks/gitleaks-action@v2
env: { GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} }
codeql:
runs-on: ubuntu-latest
permissions: { security-events: write, contents: read }
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with: { languages: javascript-typescript }
- uses: github/codeql-action/analyze@v3
Create docs/threat-model.md using the Module 09 deep dive's STRIDE walk for the URL shortener as a template. Keep it to one page. Link it from README.md.
git checkout -b module-09 git add . git commit -m "module 09: ssrf guard, helmet, crypto rng, dependabot, codeql, threat model" git push -u origin module-09
Content-Security-Policy-Report-Only while you tune; switch to enforcing once clean.