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/pathis a protocol-relative URL — the browser uses the current page’s protocol (http or https) and navigates toevil.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?