Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,16 @@ With this flag this check is disabled.

**Enabling this flag can pose a security issue since you will be exposed to request smuggling attacks. USE WITH CAUTION!**

### `void llhttp_set_lenient_header_value_relaxed(llhttp_t* parser, int enabled)`

Enables/disables relaxed handling of control characters in header values.

Normally `llhttp` would error when header values contain characters not in the valid set (HTAB, SP, VCHAR, OBS_TEXT). With
this flag, control characters (except for NULL, CR & LF) will be accepted in header values.

This does not create any known security issue, but does allow content considered 'invalid' by
[RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#name-field-values) and so should be avoided by default.

## Build Instructions

Make sure you have [Node.js](https://nodejs.org/), npm and npx installed. Then under project directory run:
Expand Down
15 changes: 15 additions & 0 deletions src/llhttp/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const LENIENT_FLAGS = {
OPTIONAL_CRLF_AFTER_CHUNK: 1 << 7,
OPTIONAL_CR_BEFORE_LF: 1 << 8,
SPACES_AFTER_CHUNK_SIZE: 1 << 9,
HEADER_VALUE_RELAXED: 1 << 10,
} as const;

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

export const HEADER_CHARS = HTAB_SP_VCHAR_OBS_TEXT;

const RELAXED_CTRL_CHARS = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, // Before TAB
0x0b, 0x0c, // VT, FF (between TAB and CR)
0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, // After CR/LF, before space
0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d,
0x1e, 0x1f,
0x7f, // DEL
] as const;

// Relaxed header chars includes control characters (above) that are not allowed
// by default. This excludes only NULL (0x00), CR (0x0d), LF (0x0a).
export const RELAXED_HEADER_CHARS = [ ...RELAXED_CTRL_CHARS, ...HEADER_CHARS ] as const;

// ',' = \x2c
export const CONNECTION_TOKEN_CHARS = [
...HTAB, ...SP,
Expand Down Expand Up @@ -509,6 +523,7 @@ export default {
HEX,
TOKEN,
HEADER_CHARS,
RELAXED_HEADER_CHARS,
CONNECTION_TOKEN_CHARS,
QDTEXT,
HTAB_SP_VCHAR_OBS_TEXT,
Expand Down
12 changes: 11 additions & 1 deletion src/llhttp/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type IntDict,
CONNECTION_TOKEN_CHARS, ERROR, FINISH, FLAGS, HEADER_CHARS,
HEADER_STATE, HEX_MAP, HTAB_SP_VCHAR_OBS_TEXT,
RELAXED_HEADER_CHARS,
LENIENT_FLAGS,
MAJOR,
METHODS, METHODS_HTTP, METHODS_HTTP1_HEAD, METHODS_ICECAST, METHODS_RTSP,
Expand Down Expand Up @@ -72,6 +73,7 @@ const NODES = [
'header_value',
'header_value_otherwise',
'header_value_lenient',
'header_value_relaxed',
'header_value_lenient_failed',
'header_value_lws',
'header_value_te_chunked',
Expand Down Expand Up @@ -832,9 +834,13 @@ export class HTTP {
return this.testLenientFlags(LENIENT_FLAGS.OPTIONAL_CR_BEFORE_LF, { 1: success }, failure);
};

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

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

n('header_value_relaxed')
.match(RELAXED_HEADER_CHARS, n('header_value_relaxed'))
.otherwise(n('header_value_otherwise'));

n('header_value_almost_done')
.match('\n', n('header_value_lws'))
.otherwise(p.error(ERROR.LF_EXPECTED,
Expand Down
8 changes: 8 additions & 0 deletions src/native/api.c
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,14 @@ void llhttp_set_lenient_spaces_after_chunk_size(llhttp_t* parser, int enabled) {
}
}

void llhttp_set_lenient_header_value_relaxed(llhttp_t* parser, int enabled) {
if (enabled) {
parser->lenient_flags |= LENIENT_HEADER_VALUE_RELAXED;
} else {
parser->lenient_flags &= ~LENIENT_HEADER_VALUE_RELAXED;
}
}

/* Callbacks */


Expand Down
17 changes: 17 additions & 0 deletions src/native/api.h
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,23 @@ void llhttp_set_lenient_optional_crlf_after_chunk(llhttp_t* parser, int enabled)
LLHTTP_EXPORT
void llhttp_set_lenient_spaces_after_chunk_size(llhttp_t* parser, int enabled);

/* Enables/disables relaxed handling of unusual characters in header values.
*
* RFC 9110 describes NULL, CR and LF as 'dangerous' and says they MUST be
* rejected, while other control characters are merely 'invalid' and discouraged,
* and are explicitly allowed by other standards (e.g. WHATWG Fetch) and
* in surprisingly common use on the web.
*
* This flag enables these 'invalid but common' characters, aiming to
* maximize compatibility without enabling any potentially dangerous scenarios.
*
* Unlike `llhttp_set_lenient_headers()`, this does NOT enable any other
* potentially unsafe behaviors (like accepting whitespace before colons
* or after the start line).
*/
LLHTTP_EXPORT
void llhttp_set_lenient_header_value_relaxed(llhttp_t* parser, int enabled);

#ifdef __cplusplus
} /* extern "C" */
#endif
Expand Down
10 changes: 10 additions & 0 deletions test/fixtures/extra.c
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,16 @@ void llhttp__test_init_response_lenient_spaces_after_chunk_size(llparse_t* s) {
s->lenient_flags |= LENIENT_SPACES_AFTER_CHUNK_SIZE;
}

void llhttp__test_init_request_lenient_header_value_relaxed(llparse_t* s) {
llhttp__test_init_request(s);
s->lenient_flags |= LENIENT_HEADER_VALUE_RELAXED;
}

void llhttp__test_init_response_lenient_header_value_relaxed(llparse_t* s) {
llhttp__test_init_response(s);
s->lenient_flags |= LENIENT_HEADER_VALUE_RELAXED;
}


void llhttp__test_finish(llparse_t* s) {
llparse__print(NULL, NULL, "finish=%d", s->finish);
Expand Down
3 changes: 3 additions & 0 deletions test/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type TestType = 'request' | 'response' | 'request-finish' | 'response-fin
'request-lenient-optional-cr-before-lf' | 'response-lenient-optional-cr-before-lf' |
'request-lenient-optional-crlf-after-chunk' | 'response-lenient-optional-crlf-after-chunk' |
'request-lenient-spaces-after-chunk-size' | 'response-lenient-spaces-after-chunk-size' |
'request-lenient-header-value-relaxed' | 'response-lenient-header-value-relaxed' |
'none' | 'url';

export const allowedTypes: TestType[] = [
Expand Down Expand Up @@ -50,6 +51,8 @@ export const allowedTypes: TestType[] = [
'response-lenient-optional-crlf-after-chunk',
'request-lenient-spaces-after-chunk-size',
'response-lenient-spaces-after-chunk-size',
'request-lenient-header-value-relaxed',
'response-lenient-header-value-relaxed',
];

const BUILD_DIR = path.join(__dirname, '..', 'tmp');
Expand Down
1 change: 1 addition & 0 deletions test/md-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ function run(name: string): void {

run('request/sample');
run('request/lenient-headers');
run('request/lenient-header-value-relaxed');
run('request/lenient-version');
run('request/method');
run('request/uri');
Expand Down
158 changes: 158 additions & 0 deletions test/request/lenient-header-value-relaxed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
Relaxed header value character parsing
=======================================

Relaxed parsing mode: accepts unusual characters (like control chars)
but still rejects specifally dangerous ones (NULL, CR, LF) that could enable
smuggling attacks.

## Control char in header value (relaxed)

Control characters like form feed should be accepted in relaxed mode.

<!-- meta={"type": "request-lenient-header-value-relaxed"} -->
```http
GET /url HTTP/1.1
Header1: hello\fworld


```

```log
off=0 message begin
off=0 len=3 span[method]="GET"
off=3 method complete
off=4 len=4 span[url]="/url"
off=9 url complete
off=9 len=4 span[protocol]="HTTP"
off=13 protocol complete
off=14 len=3 span[version]="1.1"
off=17 version complete
off=19 len=7 span[header_field]="Header1"
off=27 header_field complete
off=28 len=11 span[header_value]="hello\fworld"
off=41 header_value complete
off=43 headers complete method=1 v=1/1 flags=0 content_length=0
off=43 message complete
```

## Control char in header value (strict)

Control characters should be rejected in strict mode.

<!-- meta={"type": "request"} -->
```http
GET /url HTTP/1.1
Header1: hello\fworld


```

```log
off=0 message begin
off=0 len=3 span[method]="GET"
off=3 method complete
off=4 len=4 span[url]="/url"
off=9 url complete
off=9 len=4 span[protocol]="HTTP"
off=13 protocol complete
off=14 len=3 span[version]="1.1"
off=17 version complete
off=19 len=7 span[header_field]="Header1"
off=27 header_field complete
off=28 len=5 span[header_value]="hello"
off=33 error code=10 reason="Invalid header value char"
```

## LF in header value should be rejected even with relaxed flag

Invalid newlines could enable smuggling and must still be rejected.

<!-- meta={"type": "request-lenient-header-value-relaxed"} -->
```http
POST / HTTP/1.1
Host: localhost:5000
x:\nTransfer-Encoding: chunked

1
A
0

```

```log
off=0 message begin
off=0 len=4 span[method]="POST"
off=4 method complete
off=5 len=1 span[url]="/"
off=7 url complete
off=7 len=4 span[protocol]="HTTP"
off=11 protocol complete
off=12 len=3 span[version]="1.1"
off=15 version complete
off=17 len=4 span[header_field]="Host"
off=22 header_field complete
off=23 len=14 span[header_value]="localhost:5000"
off=39 header_value complete
off=39 len=1 span[header_field]="x"
off=41 header_field complete
off=42 error code=10 reason="Invalid header value char"
```

## CR without LF in header value should be rejected even with relaxed flag

Invalid newlines could enable smuggling and must still be rejected.

<!-- meta={"type": "request-lenient-header-value-relaxed"} -->
```http
POST / HTTP/1.1
Host: localhost:5000
x:\rTransfer-Encoding: chunked

1
A
0

```

```log
off=0 message begin
off=0 len=4 span[method]="POST"
off=4 method complete
off=5 len=1 span[url]="/"
off=7 url complete
off=7 len=4 span[protocol]="HTTP"
off=11 protocol complete
off=12 len=3 span[version]="1.1"
off=15 version complete
off=17 len=4 span[header_field]="Host"
off=22 header_field complete
off=23 len=14 span[header_value]="localhost:5000"
off=39 header_value complete
off=39 len=1 span[header_field]="x"
off=41 header_field complete
off=42 error code=2 reason="Expected LF after CR"
```

## Space after start line must still fail

Unlike LENIENT_HEADERS, this flag should NOT allow space after start line.

<!-- meta={"type": "request-lenient-header-value-relaxed"} -->
```http
GET /url HTTP/1.1
Header1: value

```

```log
off=0 message begin
off=0 len=3 span[method]="GET"
off=3 method complete
off=4 len=4 span[url]="/url"
off=9 url complete
off=9 len=4 span[protocol]="HTTP"
off=13 protocol complete
off=14 len=3 span[version]="1.1"
off=17 version complete
off=20 error code=30 reason="Unexpected space after start line"
```
Loading