Skip to content

handlers: reject protocol-relative URLs in redirect validation#5281

Open
adilburaksen wants to merge 2 commits into
google:masterfrom
adilburaksen:fix/open-redirect-protocol-relative
Open

handlers: reject protocol-relative URLs in redirect validation#5281
adilburaksen wants to merge 2 commits into
google:masterfrom
adilburaksen:fix/open-redirect-protocol-relative

Conversation

@adilburaksen

Copy link
Copy Markdown

Summary

check_redirect_url in src/appengine/handlers/base_handler.py uses
_SAFE_URL_PATTERN (based on the Closure Library SafeUrl pattern) to
validate redirect targets. The relative-URL branch of that pattern is:

[^:/?#]*(?:[/?#]|$)

[^:/?#]* can match zero characters, after which (?:[/?#]|$) matches
the leading / of //evil.com. This means the pattern accepts any
protocol-relative URL such as //attacker.example.com/steal as safe.

Browsers interpret //evil.com/path as scheme-relative (same scheme as
the current page), so a next=//evil.com/login parameter causes a
post-login redirect to the attacker's host.

Fix

Add a negative lookahead (?!//) to the relative-URL branch:

# Before
r'^(?:(?:https?|mailto|ftp):|[^:/?#]*(?:[/?#]|$))'

# After
r'^(?:(?:https?|mailto|ftp):|(?!//)[^:/?#]*(?:[/?#]|$))'

This rejects any URL that the relative branch would match but that begins
with //, while continuing to accept ordinary relative paths (/login,
relative/path) and absolute URLs with safe schemes.

Verification

>>> import re
>>> p = re.compile(r'^(?:(?:https?|mailto|ftp):|(?!//)[^:/?#]*(?:[/?#]|$))', re.I)
>>> bool(p.match('//evil.com/path'))
False
>>> bool(p.match('/login'))
True
>>> bool(p.match('https://clusterfuzz.com/'))
True

_SAFE_URL_PATTERN's relative-URL branch matched the empty string
prefix of '//evil.com', so check_redirect_url accepted
protocol-relative URLs as safe. Browsers interpret '//evil.com/path'
as 'same-scheme://evil.com/path', enabling open redirect from any
endpoint that passes the user-supplied 'next' / 'redirect' parameter
through check_redirect_url.

Add a negative lookahead (?!//) before the relative-URL branch so
that any URL beginning with '//' is rejected unless it already matched
the explicit-scheme branch (http://, https://, etc).
@adilburaksen adilburaksen requested a review from a team as a code owner May 16, 2026 18:24
@adilburaksen

Copy link
Copy Markdown
Author

Gentle ping after ~18 days — small change rejecting protocol-relative URLs in the redirect handler to close an open-redirect. CI is green; glad to add a test case or adjust if you'd like a tighter check.

@adilburaksen

Copy link
Copy Markdown
Author

Pushed a follow-up that closes a gap in the original (?!//) check.

The scheme-relative guard alone is bypassable, because browsers normalize backslashes to forward slashes and strip ASCII tab/newline and leading control/whitespace before navigating. Since _SAFE_URL_PATTERN is start-anchored and [^:/?#]* consumes a leading space or backslash, the (?!//) lookahead (evaluated at offset 0) never sees the //:

dest= value passes (?!//)-only regex browser navigates to
/\evil.com, \\evil.com, \/\/evil.com yes //evil.com
//evil.com, \r\n//evil.com yes //evil.com

Both /login?dest= and /logout?dest= are fully request-controlled and reach check_redirect_url, so these navigate to an external host through the trusted login/logout flow.

The added pre-check rejects any input with a backslash, a control character, or leading/trailing whitespace before the pattern match. Verified it blocks all of the above plus the original //evil.com, while still allowing /another-page, /login, https://blah.com/test, /dashboard?q=a b, and still rejecting javascript:.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant