URL Shortener Tutorial · Module 09 of 11

Security Hardening

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.

~4–5 hrsSSRFHelmetDependabotCodeQL
← Back to Module 09 overview
Definition of Done

What You'll Have

  • Automated test rejecting javascript:, file:, and private-IP targets.
  • Security headers via Helmet — curl -I shows HSTS, CSP, no X-Powered-By.
  • Secrets removed from .env in production; loaded from cloud secret manager.
  • Dependabot, gitleaks, and CodeQL workflows running on PRs.
  • docs/threat-model.md committed and linked from the README.
The Steps

Build It

STEP 1

Block open-redirect & SSRF on shorten

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.

STEP 2

Add a test for SSRF and bad schemes

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);
});
STEP 3

Add security headers with Helmet

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 }
}));
✓ Verify: curl -I http://localhost:3000/health shows strict-transport-security, content-security-policy, no x-powered-by.
STEP 4

Use crypto-quality randomness for codes

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;
}
STEP 5

Move secrets out of source

Stop using .env in production:

  • Fly.io: fly secrets set JWT_SECRET=… DATABASE_URL=… — already done in Module 08.
  • AWS / GCP / Azure: store in their secret manager; the workload reads at boot via OIDC-issued credentials.
  • Locally: keep .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
STEP 6

Enable Dependabot, gitleaks, CodeQL

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
STEP 7

Write the threat model

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.

STEP 8

Commit

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
Common Gotchas

If Something Goes Wrong

  • SSRF check is slow — DNS lookup adds ~10–50ms. Cache resolutions briefly; reject early on protocol/host.
  • CSP breaks the page — start with Content-Security-Policy-Report-Only while you tune; switch to enforcing once clean.
  • CodeQL job times out — give it 20 min; or use the matrix variant for parallelism.
  • Race condition between DNS resolution and connect — a sophisticated attacker can flip the IP. Add network-level egress controls if you fetch the target URL.
What's Next

Move On