0%

Input That Becomes Code · practice

Redirect Only to Your Own Site

Lesson 4 made sure that next cannot add a line to your reply. It did not ask where next points. Run the scenario again:

python pl_sec.py injection

Its second part is about that question:

--- a redirect: ana logs in, and next names another site ---
POST /login?next=%2F%2Fother.example%2Fwelcome  -> 303
  location: //other.example/welcome
a browser that follows this leaves your site for other.example

The target held %2F%2Fother.example%2Fwelcome. parse_qs decoded each %2F to a slash, so the is //other.example/welcome. That is one clean line, and header_value lets it through.

with Python, Chapter 6, showed what a client does with a location. It resolves the value against the it just asked for, and a value that starts with // replaces the host. Only the scheme is kept, so the client goes on to https://other.example/welcome.

Now look at it from the reader’s side. Someone sends them a link to your login, with your host name in it. They check the name and type their password. The login is real and it succeeds, and the page that comes next belongs to someone else. That page can look like yours and ask for the password once more. A server that redirects to wherever a request says is an open redirect.

What a local path looks like

next exists to send the reader to another page of the reading . Such a value is a path on this site. It starts with one /, like /books or /books?limit=5.

Four kinds of value are not local:

  • //other.example names a host, as you just saw.

  • https://other.example/ is a whole URL, with a scheme and a host.

  • /\other.example starts with one slash, but a browser treats a backslash in a URL as a slash. It reads //other.example.

  • A value with a tab or a line break in it, such as /, a tab, and then /other.example. A browser removes tabs and line breaks from a URL before it reads it, and what is left is //other.example again.

The last two matter because of who follows the redirect. The value has to be safe for the program that reads it next, and that program is a browser.

Why is /\other.example refused although it starts with one slash?

Ask the parser, and do not trust it alone

You know urlsplit from the router. Its result has a scheme and a netloc, the host part, and both are empty for a plain path. Ask it about four values. In Python source, "/\\other.example" is the text /\other.example.

from urllib.parse import urlsplit

for value in ["/books?limit=5", "//other.example/x", "https://other.example/", "/\\other.example"]:
    parts = urlsplit(value)
    print(value, "| scheme: [" + parts.scheme + "] host: [" + parts.netloc + "]")

It prints:

/books?limit=5 | scheme: [] host: []
//other.example/x | scheme: [] host: [other.example]
https://other.example/ | scheme: [https] host: [other.example]
/\other.example | scheme: [] host: []

The first three are what you would expect. The fourth is the trouble: Python sees no host in /\other.example, and a browser does. Two parsers read the same text and disagree, and the browser is the one that acts on it.

So the does not leave the decision to urlsplit. It checks the shape of the text itself first, and asks urlsplit last, as a second opinion. Last matters for one more reason: urlsplit raises for a few values that start with //, such as //[, and the slash rule has already answered those.

What does this print?

from urllib.parse import urlsplit

print("[" + urlsplit("//other.example/x").netloc + "]", "[" + urlsplit("/\\other.example").netloc + "]")

Allow one shape

Lesson 1 listed the keys a book may have and refused the rest. This is the same idea for shapes. Do not collect dangerous values. Describe the one shape that is safe, in four rules:

  1. Printable ASCII only: value.isascii() and value.isprintable(). isprintable() is False for a tab, a line break, and every other control character. The ASCII half also stops a letter such as é, because http_adapter writes headers as Latin-1 and such a character would not arrive as it was sent.

  2. No backslash anywhere.

  3. One slash at the start, and not two.

  4. No scheme and no host, according to urlsplit.

safe_redirect_target(value) returns the value when all four hold. For anything else it returns "/". It never raises, and the login still succeeds: the password was right, so a strange next is not a reason to refuse it. The reader lands on the front page.

A next with a line break is not printable, so from now on it becomes / as well, and the login answers 303. The 400 of Lesson 4 can no longer be reached through login. If your test from Lesson 4 asserts that status, it fails now. Assert what the headers hold, as Lesson 4 asked. header_value stays in redirect(), for every other caller and for the code you write next month.

A correct password arrives with next=https://other.example. What should the server do?

Your task

In reading_server.py:

  1. Write safe_redirect_target(value) with the four rules, in that order. Test the start with : value[:1] is the first character, or "" for an empty value, and value[:2] is the first two.

  2. In login_response, pass the value through it: redirect(303, safe_redirect_target(params["next"][0])). Keep the try around it.

Run python -m pytest -q. It still ends with 23 passed: the attack test for this guard is part of the project in Lesson 8. Then run the scenario again:

--- a redirect: ana logs in, and next names another site ---
POST /login?next=%2F%2Fother.example%2Fwelcome  -> 303
  location: /
the redirect stays on this site

The first part now shows a 303 with location: / as well, and the result line says 2 of 4 attacks worked.

Then press Check my work. The third part of the scenario fetches a file that is not a cover. That is the next lesson.

Task

Make the login redirect only to a path on this site.

In reading_server.py:

  • safe_redirect_target(value) returns value unchanged when all four rules hold, and "/" for every other . It never raises and never repairs a value.

    1. The value is printable ASCII: value.isascii() and value.isprintable().

    2. It holds no backslash.

    3. It starts with one slash and not with two: value[:1] == "/" and value[:2] != "//".

    4. urlsplit(value) finds no scheme and no host: scheme and netloc are both "".

  • login_response(request, user) writes redirect(303, safe_redirect_target(params["next"][0])), still inside its try.

Run python -m pytest -q and python pl_sec.py injection in the .

Then press Check my work. It calls safe_redirect_target() with values you have not seen: paths on this site, with a query, a fragment, a colon, or an encoded space, which must come back unchanged, and values that name another host, carry a scheme, hold a backslash, a tab, a line break, or a letter outside ASCII, or do not start with exactly one slash, which must all come back as "/". It calls handle() with its own users: a correct login whose next names another site, or holds an encoded line break, must answer 303 with location: / and start one ; a clean next, a login without next, and a wrong password must answer as before. Nothing from your terminal is graded.