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

Open Redirects

From broken access to broken redirects

The previous lesson showed how an attacker accesses data they should not see by changing an ID. This lesson shows how an attacker weaponizes your redirect logic to send users to a phishing site — using your trusted domain as the launchpad.

The vulnerable code

Many apps redirect users after login. The pre-login page stores a returnUrl so the user lands where they intended:

route.get("/login-page", {
  resolve: (c) => {
    const returnUrl = new URL(c.request.url).searchParams.get("returnUrl") ?? "/";

    const html = `
      <html><body>
        <form method="POST" action="/login">
          <input name="email" placeholder="Email" />
          <input name="password" type="password" placeholder="Password" />
          <input type="hidden" name="returnUrl" value="${returnUrl.replace(/"/g, """)}" />
          <button type="submit">Login</button>
        </form>
      </body></html>
    `;

> [!NOTE]
> We encode `"` as `"` in the hidden input's value to prevent XSS. Without this, an attacker could set `returnUrl` to `"><script>alert(1)</script>` and inject a script into the page. This is the output encoding lesson applied here — always encode untrusted data for its context.

    return new Response(html, { headers: { "content-type": "text/html" } });
  },
});

route.post("/login", {
  resolve: async (c) => {
    // ... authenticate user ...

    const body = await c.request.formData();
    const returnUrl = body.get("returnUrl") as string ?? "/";

    // DELIBERATELY VULNERABLE — redirects to any URL
    return Response.redirect(returnUrl, 302);
  },
});

The returnUrl is taken from user input and used directly in a redirect. This is the vulnerability.

The attack

The attacker crafts a link:

https://yourapp.com/login-page?returnUrl=https://evil.com/fake-login

They send this link to the victim via email: “Your account needs attention. Click here to log in.”

The victim sees yourapp.com in the URL and trusts it. They log in normally. After login, the server redirects them to https://evil.com/fake-login, which looks exactly like your app’s login page but says “Session expired, please log in again.”

The victim enters their credentials again — this time on the attacker’s site. The attacker captures their password.

This works because the victim trusted yourapp.com in the original URL. They did not notice the redirect to evil.com because it happened instantly after login.

Why it works

The server blindly redirects to whatever URL the client provides. It does not check whether the URL is on the same domain.

The fix: validate redirect URLs

Only redirect to URLs on your own domain. There are two approaches:

Approach 1: Allow only relative paths

function isSafeRedirect(url: string): boolean {
  // Must start with / and must not start with // (protocol-relative URL)
  return url.startsWith("/") && !url.startsWith("//");
}

const returnUrl = (body.get("returnUrl") as string) ?? "/";
const safeUrl = isSafeRedirect(returnUrl) ? returnUrl : "/";
return Response.redirect(safeUrl, 302);

Relative paths (like /dashboard, /notes/note-1) stay on the same domain. Absolute URLs (https://evil.com) and protocol-relative URLs (//evil.com) are rejected.

[!WARNING] Check for // at the start. A URL like //evil.com/path is a protocol-relative URL — the browser uses the current page’s protocol (http or https) and navigates to evil.com. Without the // check, this would bypass the “starts with /” test.

Approach 2: Parse and check the host

function isSafeRedirect(url: string, baseUrl: string): boolean {
  try {
    const parsed = new URL(url, baseUrl);
    const base = new URL(baseUrl);
    return parsed.origin === base.origin;
  } catch {
    return false;
  }
}

const returnUrl = (body.get("returnUrl") as string) ?? "/";
const safeUrl = isSafeRedirect(returnUrl, "http://localhost:3000") ? returnUrl : "/";

This parses the URL and checks that the origin matches your app’s origin. It handles edge cases like URL encoding and unusual URL formats.

Where open redirects hide

Any route that redirects based on user input is a potential open redirect. Common locations: login return URLs, logout redirect, OAuth callback URLs, email verification links, and “back to” links in multi-step forms.

Exercises

Exercise 1: Add the vulnerable login page. Try the attack: set returnUrl=https://example.com and observe the redirect after login.

Exercise 2: Apply the relative-path fix. Try returnUrl=https://evil.com — it should redirect to / instead. Try returnUrl=/dashboard — it should work.

Exercise 3: Try returnUrl=//evil.com. Does the relative-path check catch it? (It should, because of the // check.)

Why is an open redirect on a trusted domain dangerous?

← Insecure Direct Object References (IDOR) Server-Side Request Forgery (SSRF) →

© 2026 hectoday. All rights reserved.