mirror of
https://github.com/kennethreitz/responder.git
synced 2026-07-21 18:39:29 +00:00
a5e0cdc0f3
Strip CR/LF/NUL from every response header value at the Response.__call__ choke point (covers 304/412/main paths plus ETag/Last-Modified), closing an HTTP response-splitting vector reachable via redirect()/created() Location and any user-controlled resp.headers[...] assignment. Also strip control bytes from the logged request method/path (ASGI percent-decodes scope["path"], so a path could carry a raw CR/LF and forge log records). Both are defense-in-depth and change no legitimate behavior; regression tests added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
66 lines
2.3 KiB
Python
66 lines
2.3 KiB
Python
"""Response headers and access logs are scrubbed of CR/LF/NUL so attacker-
|
|
controlled values (a user-supplied redirect target, a reflected header, a
|
|
decoded request path) cannot inject headers, split the response, or forge log
|
|
records."""
|
|
|
|
|
|
def test_redirect_location_strips_crlf(api):
|
|
@api.route("/go")
|
|
def go(req, resp):
|
|
resp.redirect("/next\r\nX-Injected: pwned", allow_external=False)
|
|
|
|
r = api.requests.get("/go", follow_redirects=False)
|
|
assert r.status_code == 307
|
|
assert "\r" not in r.headers["Location"] and "\n" not in r.headers["Location"]
|
|
assert r.headers.get("x-injected") is None
|
|
|
|
|
|
def test_reflected_header_value_strips_crlf(api):
|
|
@api.route("/hdr")
|
|
def hdr(req, resp):
|
|
resp.headers["X-Custom"] = "val\r\nX-Evil: injected"
|
|
resp.text = "ok"
|
|
|
|
r = api.requests.get("/hdr")
|
|
assert r.headers["x-custom"] == "valX-Evil: injected"
|
|
assert r.headers.get("x-evil") is None
|
|
|
|
|
|
def test_created_location_strips_crlf(api):
|
|
@api.route("/thing")
|
|
def thing(req, resp):
|
|
resp.created({"ok": True}, location="/items/1\r\nSet-Cookie: a=b")
|
|
|
|
r = api.requests.get("/thing")
|
|
assert r.status_code == 201
|
|
assert "\r" not in r.headers["Location"] and "\n" not in r.headers["Location"]
|
|
# The forged Set-Cookie must not have materialized as its own header.
|
|
assert "a=b" not in (r.headers.get("set-cookie") or "")
|
|
|
|
|
|
def test_nul_byte_stripped_from_header(api):
|
|
@api.route("/nul")
|
|
def nul(req, resp):
|
|
resp.headers["X-Thing"] = "a\x00b"
|
|
resp.text = "ok"
|
|
|
|
r = api.requests.get("/nul")
|
|
assert r.headers["x-thing"] == "ab"
|
|
|
|
|
|
def test_scrub_header_value_helper():
|
|
from responder.models import _scrub_header_value
|
|
|
|
assert _scrub_header_value("a\r\nb\x00c") == "abc"
|
|
# Non-string values pass through untouched (Starlette rejects them later).
|
|
assert _scrub_header_value(123) == 123
|
|
|
|
|
|
def test_access_log_path_scrubbed_of_control_bytes():
|
|
from responder.ext.logging import _scrub_log_field
|
|
|
|
# Control bytes are stripped, legitimate content preserved.
|
|
assert _scrub_log_field("/foo\r\n[INFO] fake") == "/foo[INFO] fake"
|
|
assert _scrub_log_field("/path with space/ünïcode") == "/path with space/ünïcode"
|
|
assert _scrub_log_field("/x\ttab\x7fdel") == "/xtabdel"
|