Skip to content

Commit 570b54c

Browse files
authored
feat: Add support for relaxed header value parsing (#787)
1 parent 101247e commit 570b54c

9 files changed

Lines changed: 233 additions & 1 deletion

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,16 @@ With this flag this check is disabled.
397397
398398
**Enabling this flag can pose a security issue since you will be exposed to request smuggling attacks. USE WITH CAUTION!**
399399
400+
### `void llhttp_set_lenient_header_value_relaxed(llhttp_t* parser, int enabled)`
401+
402+
Enables/disables relaxed handling of control characters in header values.
403+
404+
Normally `llhttp` would error when header values contain characters not in the valid set (HTAB, SP, VCHAR, OBS_TEXT). With
405+
this flag, control characters (except for NULL, CR & LF) will be accepted in header values.
406+
407+
This does not create any known security issue, but does allow content considered 'invalid' by
408+
[RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#name-field-values) and so should be avoided by default.
409+
400410
## Build Instructions
401411
402412
Make sure you have [Node.js](https://nodejs.org/), npm and npx installed. Then under project directory run:

src/llhttp/constants.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export const LENIENT_FLAGS = {
7979
OPTIONAL_CRLF_AFTER_CHUNK: 1 << 7,
8080
OPTIONAL_CR_BEFORE_LF: 1 << 8,
8181
SPACES_AFTER_CHUNK_SIZE: 1 << 9,
82+
HEADER_VALUE_RELAXED: 1 << 10,
8283
} as const;
8384

8485
export const STATUSES = {
@@ -441,6 +442,19 @@ export const HTAB_SP_VCHAR_OBS_TEXT = [ ...HTAB, ...SP, ...VCHAR, ...OBS_TEXT ]
441442

442443
export const HEADER_CHARS = HTAB_SP_VCHAR_OBS_TEXT;
443444

445+
const RELAXED_CTRL_CHARS = [
446+
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, // Before TAB
447+
0x0b, 0x0c, // VT, FF (between TAB and CR)
448+
0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, // After CR/LF, before space
449+
0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d,
450+
0x1e, 0x1f,
451+
0x7f, // DEL
452+
] as const;
453+
454+
// Relaxed header chars includes control characters (above) that are not allowed
455+
// by default. This excludes only NULL (0x00), CR (0x0d), LF (0x0a).
456+
export const RELAXED_HEADER_CHARS = [ ...RELAXED_CTRL_CHARS, ...HEADER_CHARS ] as const;
457+
444458
// ',' = \x2c
445459
export const CONNECTION_TOKEN_CHARS = [
446460
...HTAB, ...SP,
@@ -509,6 +523,7 @@ export default {
509523
HEX,
510524
TOKEN,
511525
HEADER_CHARS,
526+
RELAXED_HEADER_CHARS,
512527
CONNECTION_TOKEN_CHARS,
513528
QDTEXT,
514529
HTAB_SP_VCHAR_OBS_TEXT,

src/llhttp/http.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type IntDict,
1111
CONNECTION_TOKEN_CHARS, ERROR, FINISH, FLAGS, HEADER_CHARS,
1212
HEADER_STATE, HEX_MAP, HTAB_SP_VCHAR_OBS_TEXT,
13+
RELAXED_HEADER_CHARS,
1314
LENIENT_FLAGS,
1415
MAJOR,
1516
METHODS, METHODS_HTTP, METHODS_HTTP1_HEAD, METHODS_ICECAST, METHODS_RTSP,
@@ -72,6 +73,7 @@ const NODES = [
7273
'header_value',
7374
'header_value_otherwise',
7475
'header_value_lenient',
76+
'header_value_relaxed',
7577
'header_value_lenient_failed',
7678
'header_value_lws',
7779
'header_value_te_chunked',
@@ -832,9 +834,13 @@ export class HTTP {
832834
return this.testLenientFlags(LENIENT_FLAGS.OPTIONAL_CR_BEFORE_LF, { 1: success }, failure);
833835
};
834836

837+
// LENIENT.HEADERS: accepts anything in values, drops some other header validation too
838+
// LENIENT.HEADER_VALUE_RELAXED: accepts only specific extra (common but discouraged) chars in values
835839
const checkLenient = this.testLenientFlags(LENIENT_FLAGS.HEADERS, {
836840
1: n('header_value_lenient'),
837-
}, span.headerValue.end(p.error(ERROR.INVALID_HEADER_TOKEN, 'Invalid header value char')));
841+
}, this.testLenientFlags(LENIENT_FLAGS.HEADER_VALUE_RELAXED, {
842+
1: n('header_value_relaxed'),
843+
}, span.headerValue.end(p.error(ERROR.INVALID_HEADER_TOKEN, 'Invalid header value char'))));
838844

839845
n('header_value_otherwise')
840846
.peek('\r', span.headerValue.end().skipTo(n('header_value_almost_done')))
@@ -854,6 +860,10 @@ export class HTTP {
854860
.peek('\n', span.headerValue.end(n('header_value_almost_done')))
855861
.skipTo(n('header_value_lenient'));
856862

863+
n('header_value_relaxed')
864+
.match(RELAXED_HEADER_CHARS, n('header_value_relaxed'))
865+
.otherwise(n('header_value_otherwise'));
866+
857867
n('header_value_almost_done')
858868
.match('\n', n('header_value_lws'))
859869
.otherwise(p.error(ERROR.LF_EXPECTED,

src/native/api.c

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,14 @@ void llhttp_set_lenient_spaces_after_chunk_size(llhttp_t* parser, int enabled) {
316316
}
317317
}
318318

319+
void llhttp_set_lenient_header_value_relaxed(llhttp_t* parser, int enabled) {
320+
if (enabled) {
321+
parser->lenient_flags |= LENIENT_HEADER_VALUE_RELAXED;
322+
} else {
323+
parser->lenient_flags &= ~LENIENT_HEADER_VALUE_RELAXED;
324+
}
325+
}
326+
319327
/* Callbacks */
320328

321329

src/native/api.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,23 @@ void llhttp_set_lenient_optional_crlf_after_chunk(llhttp_t* parser, int enabled)
353353
LLHTTP_EXPORT
354354
void llhttp_set_lenient_spaces_after_chunk_size(llhttp_t* parser, int enabled);
355355

356+
/* Enables/disables relaxed handling of unusual characters in header values.
357+
*
358+
* RFC 9110 describes NULL, CR and LF as 'dangerous' and says they MUST be
359+
* rejected, while other control characters are merely 'invalid' and discouraged,
360+
* and are explicitly allowed by other standards (e.g. WHATWG Fetch) and
361+
* in surprisingly common use on the web.
362+
*
363+
* This flag enables these 'invalid but common' characters, aiming to
364+
* maximize compatibility without enabling any potentially dangerous scenarios.
365+
*
366+
* Unlike `llhttp_set_lenient_headers()`, this does NOT enable any other
367+
* potentially unsafe behaviors (like accepting whitespace before colons
368+
* or after the start line).
369+
*/
370+
LLHTTP_EXPORT
371+
void llhttp_set_lenient_header_value_relaxed(llhttp_t* parser, int enabled);
372+
356373
#ifdef __cplusplus
357374
} /* extern "C" */
358375
#endif

test/fixtures/extra.c

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,16 @@ void llhttp__test_init_response_lenient_spaces_after_chunk_size(llparse_t* s) {
191191
s->lenient_flags |= LENIENT_SPACES_AFTER_CHUNK_SIZE;
192192
}
193193

194+
void llhttp__test_init_request_lenient_header_value_relaxed(llparse_t* s) {
195+
llhttp__test_init_request(s);
196+
s->lenient_flags |= LENIENT_HEADER_VALUE_RELAXED;
197+
}
198+
199+
void llhttp__test_init_response_lenient_header_value_relaxed(llparse_t* s) {
200+
llhttp__test_init_response(s);
201+
s->lenient_flags |= LENIENT_HEADER_VALUE_RELAXED;
202+
}
203+
194204

195205
void llhttp__test_finish(llparse_t* s) {
196206
llparse__print(NULL, NULL, "finish=%d", s->finish);

test/fixtures/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type TestType = 'request' | 'response' | 'request-finish' | 'response-fin
2323
'request-lenient-optional-cr-before-lf' | 'response-lenient-optional-cr-before-lf' |
2424
'request-lenient-optional-crlf-after-chunk' | 'response-lenient-optional-crlf-after-chunk' |
2525
'request-lenient-spaces-after-chunk-size' | 'response-lenient-spaces-after-chunk-size' |
26+
'request-lenient-header-value-relaxed' | 'response-lenient-header-value-relaxed' |
2627
'none' | 'url';
2728

2829
export const allowedTypes: TestType[] = [
@@ -50,6 +51,8 @@ export const allowedTypes: TestType[] = [
5051
'response-lenient-optional-crlf-after-chunk',
5152
'request-lenient-spaces-after-chunk-size',
5253
'response-lenient-spaces-after-chunk-size',
54+
'request-lenient-header-value-relaxed',
55+
'response-lenient-header-value-relaxed',
5356
];
5457

5558
const BUILD_DIR = path.join(__dirname, '..', 'tmp');

test/md-test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ function run(name: string): void {
229229

230230
run('request/sample');
231231
run('request/lenient-headers');
232+
run('request/lenient-header-value-relaxed');
232233
run('request/lenient-version');
233234
run('request/method');
234235
run('request/uri');
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
Relaxed header value character parsing
2+
=======================================
3+
4+
Relaxed parsing mode: accepts unusual characters (like control chars)
5+
but still rejects specifally dangerous ones (NULL, CR, LF) that could enable
6+
smuggling attacks.
7+
8+
## Control char in header value (relaxed)
9+
10+
Control characters like form feed should be accepted in relaxed mode.
11+
12+
<!-- meta={"type": "request-lenient-header-value-relaxed"} -->
13+
```http
14+
GET /url HTTP/1.1
15+
Header1: hello\fworld
16+
17+
18+
```
19+
20+
```log
21+
off=0 message begin
22+
off=0 len=3 span[method]="GET"
23+
off=3 method complete
24+
off=4 len=4 span[url]="/url"
25+
off=9 url complete
26+
off=9 len=4 span[protocol]="HTTP"
27+
off=13 protocol complete
28+
off=14 len=3 span[version]="1.1"
29+
off=17 version complete
30+
off=19 len=7 span[header_field]="Header1"
31+
off=27 header_field complete
32+
off=28 len=11 span[header_value]="hello\fworld"
33+
off=41 header_value complete
34+
off=43 headers complete method=1 v=1/1 flags=0 content_length=0
35+
off=43 message complete
36+
```
37+
38+
## Control char in header value (strict)
39+
40+
Control characters should be rejected in strict mode.
41+
42+
<!-- meta={"type": "request"} -->
43+
```http
44+
GET /url HTTP/1.1
45+
Header1: hello\fworld
46+
47+
48+
```
49+
50+
```log
51+
off=0 message begin
52+
off=0 len=3 span[method]="GET"
53+
off=3 method complete
54+
off=4 len=4 span[url]="/url"
55+
off=9 url complete
56+
off=9 len=4 span[protocol]="HTTP"
57+
off=13 protocol complete
58+
off=14 len=3 span[version]="1.1"
59+
off=17 version complete
60+
off=19 len=7 span[header_field]="Header1"
61+
off=27 header_field complete
62+
off=28 len=5 span[header_value]="hello"
63+
off=33 error code=10 reason="Invalid header value char"
64+
```
65+
66+
## LF in header value should be rejected even with relaxed flag
67+
68+
Invalid newlines could enable smuggling and must still be rejected.
69+
70+
<!-- meta={"type": "request-lenient-header-value-relaxed"} -->
71+
```http
72+
POST / HTTP/1.1
73+
Host: localhost:5000
74+
x:\nTransfer-Encoding: chunked
75+
76+
1
77+
A
78+
0
79+
80+
```
81+
82+
```log
83+
off=0 message begin
84+
off=0 len=4 span[method]="POST"
85+
off=4 method complete
86+
off=5 len=1 span[url]="/"
87+
off=7 url complete
88+
off=7 len=4 span[protocol]="HTTP"
89+
off=11 protocol complete
90+
off=12 len=3 span[version]="1.1"
91+
off=15 version complete
92+
off=17 len=4 span[header_field]="Host"
93+
off=22 header_field complete
94+
off=23 len=14 span[header_value]="localhost:5000"
95+
off=39 header_value complete
96+
off=39 len=1 span[header_field]="x"
97+
off=41 header_field complete
98+
off=42 error code=10 reason="Invalid header value char"
99+
```
100+
101+
## CR without LF in header value should be rejected even with relaxed flag
102+
103+
Invalid newlines could enable smuggling and must still be rejected.
104+
105+
<!-- meta={"type": "request-lenient-header-value-relaxed"} -->
106+
```http
107+
POST / HTTP/1.1
108+
Host: localhost:5000
109+
x:\rTransfer-Encoding: chunked
110+
111+
1
112+
A
113+
0
114+
115+
```
116+
117+
```log
118+
off=0 message begin
119+
off=0 len=4 span[method]="POST"
120+
off=4 method complete
121+
off=5 len=1 span[url]="/"
122+
off=7 url complete
123+
off=7 len=4 span[protocol]="HTTP"
124+
off=11 protocol complete
125+
off=12 len=3 span[version]="1.1"
126+
off=15 version complete
127+
off=17 len=4 span[header_field]="Host"
128+
off=22 header_field complete
129+
off=23 len=14 span[header_value]="localhost:5000"
130+
off=39 header_value complete
131+
off=39 len=1 span[header_field]="x"
132+
off=41 header_field complete
133+
off=42 error code=2 reason="Expected LF after CR"
134+
```
135+
136+
## Space after start line must still fail
137+
138+
Unlike LENIENT_HEADERS, this flag should NOT allow space after start line.
139+
140+
<!-- meta={"type": "request-lenient-header-value-relaxed"} -->
141+
```http
142+
GET /url HTTP/1.1
143+
Header1: value
144+
145+
```
146+
147+
```log
148+
off=0 message begin
149+
off=0 len=3 span[method]="GET"
150+
off=3 method complete
151+
off=4 len=4 span[url]="/url"
152+
off=9 url complete
153+
off=9 len=4 span[protocol]="HTTP"
154+
off=13 protocol complete
155+
off=14 len=3 span[version]="1.1"
156+
off=17 version complete
157+
off=20 error code=30 reason="Unexpected space after start line"
158+
```

0 commit comments

Comments
 (0)