Skip to content

Commit ec33744

Browse files
committed
fix(parser): disallow requests without CR/LF linefeeds
in strict mode by default. Keep a unstrict mode, for easier CLI parsing on linux. prevents HTTP Request Smuggling via LF-only line endings. RFC 7230 requires CRLF.
1 parent c5428a9 commit ec33744

4 files changed

Lines changed: 25 additions & 6 deletions

File tree

httoop/header/headers.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@
1111

1212
class Headers(CaseInsensitiveDict, Semantic):
1313

14-
__slots__ = ()
14+
__slots__ = ('line_end',)
1515

1616
# disallowed bytes for HTTP header field names
1717
HEADER_RE = re.compile(rb'[\x00-\x1F\x7F()<>@,;:\\\\\"/\[\]?={} \t\x80-\xFF]')
1818

19+
def __init__(self, *args, **kwargs) -> None:
20+
super().__init__(*args, **kwargs)
21+
self.line_end = b'\r\n'
22+
1923
@staticmethod
2024
def formatvalue(value: Any) -> bytes:
2125
if isinstance(value, str):
@@ -122,7 +126,7 @@ def parse(self, data: bytes) -> None:
122126
without trailing "\r\n"
123127
:type data: bytes
124128
"""
125-
lines = data.split(b'\r\n')
129+
lines = data.split(self.line_end)
126130

127131
while lines:
128132
curr = lines.pop(0)

httoop/parser.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ class StateMachine:
3131

3232
Message = Message # subclass provides the type
3333

34-
def __init__(self) -> None:
34+
def __init__(self, strict: bool = True) -> None:
3535
self.buffer = bytearray()
3636
self.message = None
37+
self.strict = strict
3738

3839
def _reset_state(self) -> None:
3940
self.message = self.Message()
@@ -126,7 +127,9 @@ def parse_startline(self) -> bool | None:
126127
if CRLF not in self.buffer:
127128
if LF not in self.buffer:
128129
return NOT_RECEIVED_YET
129-
self.line_end = LF
130+
if self.strict:
131+
raise BAD_REQUEST(_('HTTP requires CR/LF line feeds.'))
132+
self.line_end = self.message.headers.line_end = LF
130133

131134
requestline, self.buffer = self.buffer.split(self.line_end, 1)
132135

httoop/server/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ class ServerStateMachine(StateMachine):
2020
Message = Request
2121
HTTP2 = None
2222

23-
def __init__(self, scheme: str, host: str, port: int) -> None:
24-
super().__init__()
23+
def __init__(self, scheme: str, host: str, port: int, strict: bool = True) -> None:
24+
super().__init__(strict=strict)
2525
self.MAX_URI_LENGTH = float('inf') # 8000
2626
self._default_scheme = scheme
2727
self._default_host = host

tests/api/test_statemachine.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,15 @@ def test_parse_cl_te_combination(statemachine):
209209
with pytest.raises(BAD_REQUEST) as exc:
210210
statemachine.parse(b'POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\nContent-Length: 0\r\nAccept: */*\r\nUser-Agent: httoop/0.0\r\nHost: localhost\r\nContent-Type: text/plain; charset="UTF-8"\r\n\r\n')
211211
assert 'Invalid Content-Length and Transfer-Encoding combination' in str(exc.value)
212+
213+
214+
def test_parse_lf_messages(statemachine):
215+
with pytest.raises(BAD_REQUEST) as exc:
216+
statemachine.parse(b'GET / HTTP/1.1\nAccept: */*\nUser-Agent: httoop/0.0\nHost: localhost\nContent-Type: text/plain; charset="UTF-8"\n\n')
217+
assert 'HTTP requires CR/LF line feeds' in str(exc.value)
218+
219+
220+
def test_parse_lf_messages_unstrict(statemachine):
221+
statemachine.strict = False
222+
assert statemachine.parse(b'GET / HTTP/1.1\nAccept: */*\nUser-Agent: httoop/0.0\nHost: localhost\nContent-Type: text/plain; charset="UTF-8"\n\n')[0]
223+
assert statemachine.parse(b'GET / HTTP/1.1\nHost: x\n\n')[0]

0 commit comments

Comments
 (0)