hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Web Security Fundamentals with @hectoday/http

The Attacker's Mindset

  • Thinking Like an Attacker
  • Project Setup

Injection Attacks

  • SQL Injection
  • SQL Injection: Beyond the Basics
  • Command Injection
  • Header Injection

Cross-Site Scripting (XSS)

  • What Is XSS?
  • Output Encoding
  • Content Security Policy in Practice

Broken Access and Redirects

  • Insecure Direct Object References (IDOR)
  • Open Redirects
  • Server-Side Request Forgery (SSRF)

File and Data Handling

  • Path Traversal
  • Mass Assignment
  • Denial of Service via Input

Putting It All Together

  • Security Testing
  • The OWASP Top 10
  • Capstone: Hardened Notes API

Server-Side Request Forgery (SSRF)

When your server makes requests for the user

Open redirects trick the user’s browser into visiting a malicious URL. SSRF tricks your server into making a request to a URL the attacker chooses. The difference: your server often has access to internal resources that the attacker’s browser cannot reach.

The vulnerable code

Our bookmarks feature fetches a URL to get the page title:

route.post("/bookmarks", {
  resolve: async (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    const body = await c.request.json();
    const url = body.url;

    // DELIBERATELY VULNERABLE — fetches any URL the user provides
    let title = url;
    try {
      const response = await fetch(url);
      const html = await response.text();
      const match = html.match(/<title>(.*?)<\/title>/i);
      if (match) title = match[1];
    } catch {
      // Fetch failed, use URL as title
    }

    const id = crypto.randomUUID();
    db.prepare("INSERT INTO bookmarks (id, user_id, url, title) VALUES (?, ?, ?, ?)").run(
      id,
      user.id,
      url,
      title,
    );

    return Response.json({ id, url, title }, { status: 201 });
  },
});

The server fetches whatever URL the user provides. This is the vulnerability.

Attack 1: Read cloud metadata

On cloud platforms (AWS, GCP, Azure), instances can access a metadata service at a well-known internal IP. On AWS:

curl -X POST http://localhost:3000/bookmarks \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/"}'

Your server fetches the AWS metadata URL and returns the response (possibly containing IAM credentials) in the bookmark title. The attacker just read your cloud credentials from the outside.

Attack 2: Scan internal services

The attacker can probe your internal network:

# Is there a service on port 6379 (Redis)?
{"url":"http://localhost:6379/"}

# What about the database admin panel?
{"url":"http://internal-db-admin.local:8080/"}

# Your app's own admin endpoint?
{"url":"http://localhost:3000/admin/users"}

Your server makes these requests from inside the network, bypassing firewalls. The response time and error messages tell the attacker what services exist.

Attack 3: Read local files

Some URL parsers accept file:// URLs:

{"url":"file:///etc/passwd"}

Your server reads a local file and returns its content in the bookmark title.

The fix: validate URLs

Create src/url-validator.ts:

// src/url-validator.ts
import dns from "dns/promises";

const BLOCKED_HOSTS = new Set([
  "localhost",
  "127.0.0.1",
  "0.0.0.0",
  "::1",
  "[::1]",
  "metadata.google.internal",
]);

function isPrivateIp(ip: string): boolean {
  // IPv4 private ranges
  if (ip.startsWith("10.")) return true;
  if (ip.startsWith("172.") && parseInt(ip.split(".")[1]) >= 16 && parseInt(ip.split(".")[1]) <= 31)
    return true;
  if (ip.startsWith("192.168.")) return true;
  if (ip.startsWith("169.254.")) return true; // Link-local (AWS metadata)
  if (ip === "127.0.0.1" || ip === "0.0.0.0") return true;
  return false;
}

export async function isUrlSafe(urlString: string): Promise<boolean> {
  let parsed: URL;
  try {
    parsed = new URL(urlString);
  } catch {
    return false;
  }

  // Only allow http and https
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
    return false;
  }

  // Block known dangerous hosts
  if (BLOCKED_HOSTS.has(parsed.hostname)) {
    return false;
  }

  // Resolve the hostname and check if it points to a private IP
  try {
    const addresses = await dns.resolve4(parsed.hostname);
    for (const addr of addresses) {
      if (isPrivateIp(addr)) return false;
    }
  } catch {
    // DNS resolution failed — block to be safe
    return false;
  }

  return true;
}

This function checks three things:

  1. Protocol: Only http: and https: are allowed. This blocks file://, ftp://, gopher://, and other schemes.
  2. Hostname: Known dangerous hosts (localhost, metadata endpoints) are blocked.
  3. DNS resolution: The hostname is resolved, and the resulting IP is checked against private ranges. This catches cases where an attacker registers a domain that resolves to 127.0.0.1 or 169.254.169.254.

[!WARNING] The DNS resolution check has a race condition called DNS rebinding: the attacker’s domain could resolve to a safe IP during validation, then resolve to a private IP when fetch makes the actual request. Full protection against DNS rebinding requires more advanced techniques (using a DNS proxy, or resolving the IP and connecting to it directly). The checks above stop the most common attacks.

Apply it to the bookmarks route:

import { isUrlSafe } from "../url-validator.js";

route.post("/bookmarks", {
  resolve: async (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    const body = await c.request.json();
    const url = body.url;

    if (!url || typeof url !== "string") {
      return Response.json({ error: "URL is required" }, { status: 400 });
    }

    // Validate the URL before fetching
    const safe = await isUrlSafe(url);
    if (!safe) {
      return Response.json({ error: "URL not allowed" }, { status: 400 });
    }

    let title = url;
    try {
      const response = await fetch(url);
      const html = await response.text();
      const match = html.match(/<title>(.*?)<\/title>/i);
      if (match) title = match[1];
    } catch {
      // Fetch failed
    }

    const id = crypto.randomUUID();
    db.prepare("INSERT INTO bookmarks (id, user_id, url, title) VALUES (?, ?, ?, ?)").run(
      id,
      user.id,
      url,
      title,
    );

    return Response.json({ id, url, title }, { status: 201 });
  },
});

Exercises

Exercise 1: Add the bookmarks route (vulnerable version). Try fetching http://localhost:3000/health via the bookmarks endpoint. You should see the response.

Exercise 2: Apply the URL validation. Try the same request. It should be blocked.

Exercise 3: Try file:///etc/passwd. The protocol check should block it.

Exercise 4: Think about DNS rebinding. Why might resolving the DNS before fetching not be sufficient? (Because the DNS could change between the check and the fetch.)

Why is SSRF particularly dangerous on cloud platforms?

← Open Redirects Path Traversal →

© 2026 hectoday. All rights reserved.