diff --git a/README.md b/README.md index 008b7e62..2c77ba8a 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/llhttp/constants.ts b/src/llhttp/constants.ts index fa247127..dd64c623 100644 --- a/src/llhttp/constants.ts +++ b/src/llhttp/constants.ts @@ -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 = { @@ -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, @@ -509,6 +523,7 @@ export default { HEX, TOKEN, HEADER_CHARS, + RELAXED_HEADER_CHARS, CONNECTION_TOKEN_CHARS, QDTEXT, HTAB_SP_VCHAR_OBS_TEXT, diff --git a/src/llhttp/http.ts b/src/llhttp/http.ts index d7b61a63..c8d7e8b7 100644 --- a/src/llhttp/http.ts +++ b/src/llhttp/http.ts @@ -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, @@ -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', @@ -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'))) @@ -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, diff --git a/src/native/api.c b/src/native/api.c index 02452541..ae5e862d 100644 --- a/src/native/api.c +++ b/src/native/api.c @@ -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 */ diff --git a/src/native/api.h b/src/native/api.h index 5ee7967d..0a58d4e0 100644 --- a/src/native/api.h +++ b/src/native/api.h @@ -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 diff --git a/test/fixtures/extra.c b/test/fixtures/extra.c index 4134946e..5f14cb2f 100644 --- a/test/fixtures/extra.c +++ b/test/fixtures/extra.c @@ -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); diff --git a/test/fixtures/index.ts b/test/fixtures/index.ts index 264896a6..ec5b57d5 100644 --- a/test/fixtures/index.ts +++ b/test/fixtures/index.ts @@ -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[] = [ @@ -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'); diff --git a/test/md-test.ts b/test/md-test.ts index a96745cb..5bdf2dce 100644 --- a/test/md-test.ts +++ b/test/md-test.ts @@ -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'); diff --git a/test/request/lenient-header-value-relaxed.md b/test/request/lenient-header-value-relaxed.md new file mode 100644 index 00000000..bc0cecb5 --- /dev/null +++ b/test/request/lenient-header-value-relaxed.md @@ -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. + + +```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. + + +```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. + + +```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. + + +```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. + + +```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" +``` \ No newline at end of file