0%

Input That Becomes Code · practice

Keep a Line Break Out of a Header

A login often has to send the reader on: back to the page they wanted before they were asked to log in. Your reading can do that since this chapter began. POST /login?next=/books answers a correct login with 303 and a location header. The that builds the answer is new, and it came with this chapter’s project:

def login_response(request, user):
    """Return the answer to a correct login: 303 to next when the target names one, or 200."""
    params = parse_qs(urlsplit(request["target"]).query)
    if "next" in params:
        return redirect(303, params["next"][0])
    return json_response(200, {"user": user})

It takes text from the request and writes it into a header. Lesson 3 called that a place where data crosses into text that is parsed. Watch it happen on your own server:

python pl_sec.py injection

The scenario has four parts, one for each place this chapter guards. This lesson is about the first:

--- a header: ana logs in, and next carries an encoded line break ---
POST /login?next=/%0d%0aSet-Cookie:%20note=written-by-the-attacker  -> 303
the head of the reply, line by line:
  location: /
  Set-Cookie: note=written-by-the-attacker
  content-length: 0
  set-cookie: __Host-session=4AxVgGU18_wz0Copyj-ihA; HttpOnly; Max-Age=28800; Path=/; SameSite=Lax; Secure
  cache-control: no-store
the request wrote a line of your reply: Set-Cookie: note=written-by-the-attacker

Your will differ. The second is not yours. Your server has no called note. The result line says 4 of 4 attacks worked, and the other three parts wait for the next lessons.

How the line got there

Follow the through three steps.

The request target holds %0d%0a, six harmless characters. parse_qs percent-decodes query values, as it should, and %0d%0a decodes to a carriage return and a line feed, CR and LF for short. So params["next"][0] is the text /, a real line break, and then Set-Cookie: note=written-by-the-attacker.

redirect() puts that text into the location header as it is. http_adapter writes each header as its name, a colon, the value, and a line break, and it writes the value as given.

A client ends a header at the line break. It reads location: /, and then a second header.

A header is one line, and your server let text from a request decide where that line ends. This is header injection. An attacker who gets someone to send such a request can put a cookie of the attacker’s choosing into that person’s browser, and with two line breaks in a row can end the head and write a body. Chapter 4’s session fixation started from a cookie that an attacker chose.

The request target contains %0d%0a, not a line break. Where does the line break come from?

Refuse, do not repair

One fix is to delete the line breaks and keep the rest. Repairs like that invite a second trick. Delete only \n, and a lone \r is still there. Delete the pair \r\n, and this happens:

repaired = "/\r\r\n\nSet-Cookie: note=x".replace("\r\n", "")
print(repaired == "/\r\nSet-Cookie: note=x")

It prints True. Removing one pair pushed the characters around it together into a new pair, and the repaired value has a line break again.

There is a simpler rule, and it costs nothing, because a header value with a line break in it is never honest input. header_value(text) raises ValueError("a header value must be one line") when the text holds \r, \n, or the NUL character \x00, which has no place in a header value either. Otherwise it returns the text unchanged.

Why does header_value raise instead of deleting the line breaks?

Guard the place that writes

The check could go into login_response, next to the value that caused the trouble. Lesson 3 gave the better rule: the defense belongs where the text is used. redirect() is the one function that turns text into a location header, so it writes header_value(location). Every redirect of the service then passes the check, the ones for the old /v0/books paths included, and so will any redirect you add later.

redirect() now raises for such a value, and login_response has to answer. It catches the and returns json_error(400, "next must be one line").

One supplied detail makes that refusal clean. The login branch of route() calls login_response first, and starts the session only when the answer’s status is below 400. A login that is refused with 400 leaves no session behind and sets no cookie.

Why is the guard inside redirect() and not only in the login branch?

Your task

Write the attack first. In tests/test_attacks.py, add test_attack_next_cannot_add_a_header_line:

  1. Build the state with reading_server.sample_state(), and the headers with {"Authorization": helpers.basic("ana", "correct horse")}.

  2. Log in with the target /login?next=/books and assert that the status is 303. This shows that a clean next works.

  3. Log in with the target /login?next=/%0d%0aSet-Cookie:%20note=written-by-the-attacker. For every header of the response, assert that its value holds no "\r", no "\n", and not the text written-by-the-attacker.

The test asserts what must never happen, and not which status the server chooses. Run it:

python -m pytest -q

It ends with 1 failed, 22 passed, and the failure is a carriage return found in the location value.

Then, in reading_server.py:

  1. Write header_value(text).

  2. In redirect(), write header_value(location) into the header.

  3. In login_response, catch the ValueError and answer 400.

Run the tests again, 23 passed, and then the scenario:

--- a header: ana logs in, and next carries an encoded line break ---
POST /login?next=/%0d%0aSet-Cookie:%20note=written-by-the-attacker  -> 400
the head of the reply, line by line:
  content-type: application/json
  content-length: 34
  cache-control: no-store
the body of the reply: {"error": "next must be one line"}
no line of the reply came from the request

The result line now says 3 of 4 attacks worked. A wrong password is answered as before: 401, whatever next holds.

Then press Check my work. Read the scenario’s second part before you go on. A next of //other.example/welcome is one clean line, so header_value lets it through, and the reply sends the reader to another site. That is the next lesson.

Task

Keep text from a request from adding a line to a response, and prove it with a test.

In tests/test_attacks.py, below the Chapter 7 comment, write test_attack_next_cannot_add_a_header_line:

  • build the state with reading_server.sample_state(), and the headers {"Authorization": helpers.basic("ana", "correct horse")};

  • send POST /login?next=/books through reading_server.handle(helpers.make_request("POST", target, headers=headers), state) and assert that the status is 303;

  • send a second login with the target "/login?next=/%0d%0aSet-Cookie:%20note=written-by-the-attacker", and for every name in response["headers"] assert that the holds no "\r", no "\n", and not the text written-by-the-attacker.

Run python -m pytest -q in the : the test must fail first.

In reading_server.py:

  • header_value(text) raises ValueError("a header value must be one line") when text holds "\r", "\n", or "\x00", and returns text unchanged otherwise. It never removes or replaces a character.

  • redirect(status, location) writes header_value(location) into the location header.

  • login_response(request, user) catches the and returns json_error(400, "next must be one line"). The login branch of route() stays as it is: it starts a only when the answer’s status is below 400.

Run python -m pytest -q and python pl_sec.py injection again.

Then press Check my work. It calls header_value() with values you have not seen: values of one line, with spaces, a tab, and letters with accents, which must come back unchanged, and values with a carriage return, a line feed, both, or a NUL, which must raise ValueError. It calls redirect() with a location that holds a line break. It calls handle() with its own users: a correct login whose next holds an encoded line break must get 400 with no session and no , and no header of the reply may hold a line break; a clean next, a login without next, a wrong password, and an old /v0/books path must answer as before. It sends the same login as bytes through http_adapter, in memory, and reads the lines that come back. Last, it runs your test by name against servers of its own: it must pass against one that refuses such a next, and fail against one that writes next into location as it came and against one that answers 401 to every login. If you check this lesson again after Lesson 5, a 303 to / is accepted in place of the 400. Nothing from your terminal is graded.