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 POST /login?next=/books answers a correct login with 303 and a location header. The
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 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
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 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:
Build the state with
reading_server.sample_state(), and the headers with{"Authorization": helpers.basic("ana", "correct horse")}.Log in with the target
/login?next=/booksand assert that the status is 303. This shows that a cleannextworks.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 textwritten-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:
Write
header_value(text).In
redirect(), writeheader_value(location)into the header.In
login_response, catch theValueErrorand 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=/booksthroughreading_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 inresponse["headers"]assert that theholds no "\r", no"\n", and not the textwritten-by-the-attacker.
Run python -m pytest -q in the
In reading_server.py:
header_value(text)raisesValueError("a header value must be one line")whentextholds"\r","\n", or"\x00", and returnstextunchanged otherwise. It never removes or replaces a character.redirect(status, location)writesheader_value(location)into thelocationheader.login_response(request, user)catches theand returns json_error(400, "next must be one line"). The login branch ofroute()stays as it is: it starts aonly 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 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.