Skip to content

Commit 369c67e

Browse files
committed
fix(security): prevent http header crlf injection
1 parent b3282f5 commit 369c67e

File tree

2 files changed

+283
-1
lines changed

2 files changed

+283
-1
lines changed

gakido/headers.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@
33
from collections.abc import Iterable
44

55

6+
def _sanitize_header(name: str, value: str) -> tuple[str, str]:
7+
"""
8+
Sanitize header name and value to prevent HTTP header injection (CRLF injection).
9+
Strips CR, LF, and null bytes from both name and value.
10+
"""
11+
# Remove \r, \n, and \x00 from header name and value
12+
clean_name = name.replace("\r", "").replace("\n", "").replace("\x00", "")
13+
clean_value = value.replace("\r", "").replace("\n", "").replace("\x00", "")
14+
return clean_name, clean_value
15+
16+
617
def canonicalize_headers(
718
default_headers: Iterable[tuple[str, str]],
819
user_headers: dict[str, str] | None,
@@ -15,9 +26,11 @@ def canonicalize_headers(
1526
"""
1627
merged: dict[str, tuple[str, str]] = {}
1728
for name, value in default_headers:
29+
name, value = _sanitize_header(name, value)
1830
merged[name.lower()] = (name, value)
1931
if user_headers:
2032
for name, value in user_headers.items():
33+
name, value = _sanitize_header(name, value)
2134
merged[name.lower()] = (name, value)
2235

2336
ordered: list[tuple[str, str]] = []

tests/test_headers.py

Lines changed: 270 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Tests for gakido.headers module."""
22

33
import pytest
4-
from gakido.headers import canonicalize_headers
4+
from gakido.headers import canonicalize_headers, _sanitize_header
55

66

77
class TestCanonicalizeHeaders:
@@ -120,3 +120,272 @@ def test_order_with_missing_headers(self):
120120
merged = canonicalize_headers(default, None, order)
121121
# Should only have A
122122
assert merged == [("A", "1")]
123+
124+
125+
class TestSanitizeHeader:
126+
"""Tests for the _sanitize_header function."""
127+
128+
def test_clean_header_unchanged(self):
129+
"""Clean headers should pass through unchanged."""
130+
name, value = _sanitize_header("Content-Type", "application/json")
131+
assert name == "Content-Type"
132+
assert value == "application/json"
133+
134+
def test_crlf_in_value_stripped(self):
135+
"""CRLF sequences in header values should be stripped."""
136+
name, value = _sanitize_header("User-Agent", "test\r\nX-Injected: pwned")
137+
assert name == "User-Agent"
138+
assert value == "testX-Injected: pwned"
139+
assert "\r" not in value
140+
assert "\n" not in value
141+
142+
def test_crlf_in_name_stripped(self):
143+
"""CRLF sequences in header names should be stripped."""
144+
name, value = _sanitize_header("User-Agent\r\nX-Injected", "pwned")
145+
assert name == "User-AgentX-Injected"
146+
assert value == "pwned"
147+
assert "\r" not in name
148+
assert "\n" not in name
149+
150+
def test_lf_only_stripped(self):
151+
"""LF-only sequences should be stripped."""
152+
name, value = _sanitize_header("User-Agent", "test\nX-Injected: pwned")
153+
assert value == "testX-Injected: pwned"
154+
assert "\n" not in value
155+
156+
def test_cr_only_stripped(self):
157+
"""CR-only sequences should be stripped."""
158+
name, value = _sanitize_header("User-Agent", "test\rX-Injected: pwned")
159+
assert value == "testX-Injected: pwned"
160+
assert "\r" not in value
161+
162+
def test_null_byte_stripped(self):
163+
"""Null bytes should be stripped."""
164+
name, value = _sanitize_header("User-Agent", "test\x00X-Injected: pwned")
165+
assert value == "testX-Injected: pwned"
166+
assert "\x00" not in value
167+
168+
def test_multiple_crlf_stripped(self):
169+
"""Multiple CRLF sequences should all be stripped."""
170+
name, value = _sanitize_header(
171+
"Header",
172+
"value1\r\nHeader2: value2\r\nHeader3: value3"
173+
)
174+
assert value == "value1Header2: value2Header3: value3"
175+
assert "\r" not in value
176+
assert "\n" not in value
177+
178+
def test_mixed_injection_chars_stripped(self):
179+
"""Mixed injection characters should all be stripped."""
180+
name, value = _sanitize_header(
181+
"Header\r\n\x00Evil",
182+
"value\r\n\x00\r\ninjected"
183+
)
184+
assert "\r" not in name
185+
assert "\n" not in name
186+
assert "\x00" not in name
187+
assert "\r" not in value
188+
assert "\n" not in value
189+
assert "\x00" not in value
190+
191+
def test_empty_header_unchanged(self):
192+
"""Empty headers should remain empty."""
193+
name, value = _sanitize_header("", "")
194+
assert name == ""
195+
assert value == ""
196+
197+
def test_only_crlf_becomes_empty(self):
198+
"""Header with only CRLF chars should become empty."""
199+
name, value = _sanitize_header("\r\n", "\r\n\r\n")
200+
assert name == ""
201+
assert value == ""
202+
203+
204+
class TestCanonicalizeHeadersInjection:
205+
"""Tests for header injection prevention in canonicalize_headers."""
206+
207+
def test_user_header_crlf_sanitized(self):
208+
"""User-provided headers with CRLF should be sanitized."""
209+
default_headers = [("Accept", "text/html")]
210+
user_headers = {"User-Agent": "test\r\nX-Injected: pwned"}
211+
order = ["Accept", "User-Agent"]
212+
213+
result = canonicalize_headers(default_headers, user_headers, order)
214+
215+
# Find User-Agent in result
216+
ua_value = None
217+
for name, value in result:
218+
if name == "User-Agent":
219+
ua_value = value
220+
break
221+
222+
assert ua_value is not None
223+
assert "\r" not in ua_value
224+
assert "\n" not in ua_value
225+
assert ua_value == "testX-Injected: pwned"
226+
227+
def test_default_header_crlf_sanitized(self):
228+
"""Default headers with CRLF should be sanitized."""
229+
default_headers = [("User-Agent", "test\r\nX-Injected: pwned")]
230+
user_headers = None
231+
order = ["User-Agent"]
232+
233+
result = canonicalize_headers(default_headers, user_headers, order)
234+
235+
ua_value = result[0][1]
236+
assert "\r" not in ua_value
237+
assert "\n" not in ua_value
238+
239+
def test_header_name_injection_sanitized(self):
240+
"""Header names with CRLF should be sanitized."""
241+
default_headers = []
242+
user_headers = {"X-Custom\r\nX-Injected": "value"}
243+
order = []
244+
245+
result = canonicalize_headers(default_headers, user_headers, order)
246+
247+
# Check that no header name contains CRLF
248+
for name, value in result:
249+
assert "\r" not in name
250+
assert "\n" not in name
251+
252+
def test_no_separate_injected_header(self):
253+
"""CRLF injection should not create separate headers."""
254+
default_headers = []
255+
user_headers = {"User-Agent": "Mozilla\r\nX-Injected: pwned\r\nX-Another: header"}
256+
order = []
257+
258+
result = canonicalize_headers(default_headers, user_headers, order)
259+
260+
# Should only have one header, not three
261+
assert len(result) == 1
262+
263+
# Check no header named X-Injected or X-Another exists
264+
header_names = [name.lower() for name, _ in result]
265+
assert "x-injected" not in header_names
266+
assert "x-another" not in header_names
267+
268+
269+
class TestInjectionPayloads:
270+
"""Test various known injection payloads."""
271+
272+
@pytest.mark.parametrize("payload,description", [
273+
# Basic CRLF
274+
("test\r\nX-Injected: pwned", "Basic CRLF injection"),
275+
("test\r\n\r\n<html>body</html>", "CRLF with body injection"),
276+
277+
# LF only (Unix-style)
278+
("test\nX-Injected: pwned", "LF-only injection"),
279+
("test\n\n<html>body</html>", "LF-only with body"),
280+
281+
# CR only
282+
("test\rX-Injected: pwned", "CR-only injection"),
283+
284+
# Null byte
285+
("test\x00X-Injected: pwned", "Null byte injection"),
286+
287+
# Multiple injections
288+
("test\r\nHeader1: val1\r\nHeader2: val2", "Multiple header injection"),
289+
290+
# URL encoded (should NOT be decoded - these are literal chars)
291+
("test%0d%0aX-Injected: pwned", "URL encoded CRLF (literal)"),
292+
293+
# Mixed
294+
("test\r\n\x00\nX-Injected: pwned", "Mixed injection chars"),
295+
296+
# At start
297+
("\r\nX-Injected: pwned", "CRLF at start"),
298+
299+
# At end
300+
("test\r\n", "CRLF at end"),
301+
302+
# Only CRLF
303+
("\r\n\r\n", "Only CRLF chars"),
304+
305+
# Unicode variations (should pass through - not injection)
306+
("test\u000d\u000aX-Injected: pwned", "Unicode CRLF"),
307+
308+
# HTTP/2 pseudo-header injection attempt
309+
(":path\r\n:authority: evil.com", "HTTP/2 pseudo-header injection"),
310+
311+
# Cookie injection
312+
("session=abc\r\nSet-Cookie: evil=value", "Cookie injection attempt"),
313+
314+
# Host header injection
315+
("example.com\r\nHost: evil.com", "Host header injection"),
316+
317+
# Content-Length injection
318+
("100\r\nContent-Length: 0", "Content-Length injection"),
319+
320+
# Transfer-Encoding injection
321+
("gzip\r\nTransfer-Encoding: chunked", "Transfer-Encoding injection"),
322+
])
323+
def test_injection_payload_sanitized(self, payload, description):
324+
"""Test that various injection payloads are properly sanitized."""
325+
name, value = _sanitize_header("Test-Header", payload)
326+
327+
assert "\r" not in value, f"CR found in sanitized value for: {description}"
328+
assert "\n" not in value, f"LF found in sanitized value for: {description}"
329+
assert "\x00" not in value, f"Null byte found in sanitized value for: {description}"
330+
331+
@pytest.mark.parametrize("header_name,description", [
332+
("X-Custom\r\nX-Injected", "CRLF in header name"),
333+
("X-Custom\nX-Injected", "LF in header name"),
334+
("X-Custom\rX-Injected", "CR in header name"),
335+
("X-Custom\x00X-Injected", "Null in header name"),
336+
("\r\nX-Injected", "CRLF at start of name"),
337+
("X-Custom\r\n", "CRLF at end of name"),
338+
])
339+
def test_header_name_injection_sanitized(self, header_name, description):
340+
"""Test that header name injection payloads are sanitized."""
341+
name, value = _sanitize_header(header_name, "value")
342+
343+
assert "\r" not in name, f"CR found in sanitized name for: {description}"
344+
assert "\n" not in name, f"LF found in sanitized name for: {description}"
345+
assert "\x00" not in name, f"Null byte found in sanitized name for: {description}"
346+
347+
348+
class TestRealWorldScenarios:
349+
"""Test real-world attack scenarios."""
350+
351+
def test_response_splitting_prevented(self):
352+
"""HTTP response splitting attack should be prevented."""
353+
# Attacker tries to inject a complete HTTP response
354+
payload = "legitimate\r\n\r\nHTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<html>evil</html>"
355+
name, value = _sanitize_header("X-Redirect", payload)
356+
357+
assert "HTTP/1.1" not in value or "\r" not in value
358+
assert "\r" not in value
359+
assert "\n" not in value
360+
361+
def test_cache_poisoning_prevented(self):
362+
"""Cache poisoning via header injection should be prevented."""
363+
payload = "value\r\nX-Cache-Status: HIT\r\nAge: 0"
364+
name, value = _sanitize_header("X-Custom", payload)
365+
366+
assert "\r" not in value
367+
assert "\n" not in value
368+
369+
def test_session_fixation_prevented(self):
370+
"""Session fixation via Set-Cookie injection should be prevented."""
371+
payload = "value\r\nSet-Cookie: session=attacker_controlled"
372+
name, value = _sanitize_header("X-Custom", payload)
373+
374+
assert "\r" not in value
375+
assert "\n" not in value
376+
377+
def test_xss_via_header_prevented(self):
378+
"""XSS via header injection should be prevented."""
379+
payload = "value\r\nContent-Type: text/html\r\n\r\n<script>alert(1)</script>"
380+
name, value = _sanitize_header("X-Custom", payload)
381+
382+
assert "\r" not in value
383+
assert "\n" not in value
384+
385+
def test_smuggling_attempt_prevented(self):
386+
"""HTTP request smuggling attempt should be prevented."""
387+
payload = "value\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nGET /admin HTTP/1.1"
388+
name, value = _sanitize_header("X-Custom", payload)
389+
390+
assert "\r" not in value
391+
assert "\n" not in value

0 commit comments

Comments
 (0)