From 786c918b54f2c032b2f320ef8d9a7d7ab8c59388 Mon Sep 17 00:00:00 2001 From: Brian Neradt Date: Thu, 4 Jan 2024 22:22:15 +0000 Subject: [PATCH 1/2] Transforms: Handle INT64_MAX TSVConnWrite values Our example null_transform.cc and multiplexer plugins (and likely others) perform their initial TSVConnWrite call with INT64_MAX, meaning that nbytes should be set large at first and then adjusted later. It was found with the multiplexer plugin that this works for small bodies, but for larger bodies that spanned multiple WRITE_READY events, this mechanism wasn't working. This was due to setup_server_send_request_api() not being called when size was set to INT64_MAX because it was enforcing a Content-Length header value. This updates the HttpSM transform logic for these plugins so that it only fails if the Content-Length header is actually missing from the server side request. This patch adds large body requests and responses to verify that multiplexer handles them appropriately. --- include/tscore/ink_string++.h | 1 + src/proxy/http/HttpSM.cc | 27 ++++++++++++++----- .../multiplexer/multiplexer.test.py | 22 ++++++++++++--- .../replays/multiplexer_copy.replay.yaml | 6 ++--- .../replays/multiplexer_original.replay.yaml | 10 +++---- 5 files changed, 48 insertions(+), 18 deletions(-) diff --git a/include/tscore/ink_string++.h b/include/tscore/ink_string++.h index 9d737edcc12..c8e9a1ada95 100644 --- a/include/tscore/ink_string++.h +++ b/include/tscore/ink_string++.h @@ -32,6 +32,7 @@ #pragma once #include +#include #include /*********************************************************************** diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc index 5cf84ca2728..d6eb046f386 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -23,6 +23,7 @@ */ #include "tsutil/ts_bw_format.h" +#include "proxy/hdrs/MIME.h" #include "proxy/ProxyTransaction.h" #include "proxy/http/HttpSM.h" #include "proxy/http/ConnectingEntry.h" @@ -1128,12 +1129,26 @@ HttpSM::state_request_wait_for_transform_read(int event, void *data) setup_server_send_request_api(); break; } else { - // No content length from the post. This is a no go - // since http spec requires content length when - // sending a request message body. Change the event - // to an error and fall through - event = VC_EVENT_ERROR; - Log::error("Request transformation failed to set content length"); + // The caller of the API did not specify a size, but there may be a + // Content-Length header in the request. Our plugin transform example + // plugin suggests using INT64_MAX, for example, to transform the entire + // body from the upstream to the downstream VIO. For these situations, + // check for an existing Content-Length header on behalf of the caller. + if (nullptr != t_state.hdr_info.server_request.field_find(MIME_FIELD_CONTENT_LENGTH, MIME_LEN_CONTENT_LENGTH)) { + int64_t const cl = t_state.hdr_info.server_request.value_get_int64(MIME_FIELD_CONTENT_LENGTH, MIME_LEN_CONTENT_LENGTH); + SMDbg(dbg_ctl_http, "No size passed via the callback. Using the Content-Length header value: %" PRId64, cl); + t_state.hdr_info.transform_request_cl = cl; + setup_server_send_request_api(); + break; + } else { + // No content length from the post. This is a no go + // since http spec requires content length when + // sending a request message body. Change the event + // to an error and fall through + event = VC_EVENT_ERROR; + SMDbg(dbg_ctl_http, "No size passed via the callback and there is no Content-Length header. Aborting the transform."); + Log::error("Request transformation failed to set content length"); + } } // FALLTHROUGH default: diff --git a/tests/gold_tests/pluginTest/multiplexer/multiplexer.test.py b/tests/gold_tests/pluginTest/multiplexer/multiplexer.test.py index 6823e533df8..d9edc61f55c 100644 --- a/tests/gold_tests/pluginTest/multiplexer/multiplexer.test.py +++ b/tests/gold_tests/pluginTest/multiplexer/multiplexer.test.py @@ -31,6 +31,7 @@ class MultiplexerTestBase: """ client_counter = 0 + dns_counter = 0 server_counter = 0 ts_counter = 0 @@ -39,8 +40,14 @@ def __init__(self, replay_file, multiplexed_host_replay_file, skip_post): self.multiplexed_host_replay_file = multiplexed_host_replay_file self.setupServers() + self.setupDns() self.setupTS(skip_post) + def setupDns(self): + counter = MultiplexerTestBase.dns_counter + MultiplexerTestBase.dns_counter += 1 + self.dns = Test.MakeDNServer(f"dns_{counter}", default='127.0.0.1') + def setupServers(self): counter = MultiplexerTestBase.server_counter MultiplexerTestBase.server_counter += 1 @@ -57,6 +64,7 @@ def setupServers(self): 'X-Multiplexer: original', 'Verify the HTTP multiplexed host does not receive an "original".') self.server_https.Streams.All += Testers.ExcludesExpression( 'X-Multiplexer: original', 'Verify the HTTPS multiplexed host does not receive an "original".') + self.server_https.Streams.All += Testers.ExcludesExpression(r'\[ERROR\]', 'Verify there were no errors in the replay.') # In addition, the original server should always receive the POST and # PUT requests. @@ -64,6 +72,7 @@ def setupServers(self): 'uuid: POST', "Verify the client's original target received the POST transaction.") self.server_origin.Streams.All += Testers.ContainsExpression( 'uuid: PUT', "Verify the client's original target received the PUT transaction.") + self.server_origin.Streams.All += Testers.ExcludesExpression(r'\[ERROR\]', 'Verify there were no errors in the replay.') # Under all configurations, the GET request should be multiplexed. self.server_origin.Streams.All += Testers.ContainsExpression( @@ -74,6 +83,7 @@ def setupServers(self): self.server_http.Streams.All += Testers.ContainsExpression( 'X-Multiplexer: copy', 'Verify the HTTP server received a "copy" of the request.') self.server_http.Streams.All += Testers.ContainsExpression('uuid: GET', "Verify the HTTP server received the GET request.") + self.server_http.Streams.All += Testers.ExcludesExpression(r'\[ERROR\]', 'Verify there were no errors in the replay.') self.server_https.Streams.All += Testers.ContainsExpression( 'X-Multiplexer: copy', 'Verify the HTTPS server received a "copy" of the request.') @@ -96,6 +106,8 @@ def setupTS(self, skip_post): "proxy.config.ssl.client.verify.server.policy": 'PERMISSIVE', 'proxy.config.diags.debug.enabled': 1, 'proxy.config.diags.debug.tags': 'http|multiplexer', + 'proxy.config.dns.nameservers': f'127.0.0.1:{self.dns.Variables.Port}', + 'proxy.config.dns.resolv_conf': 'NULL', }) self.ts.Disk.ssl_multicert_config.AddLine('dest_ip=* ssl_cert_name=server.pem ssl_key_name=server.key') skip_remap_param = '' @@ -103,18 +115,19 @@ def setupTS(self, skip_post): skip_remap_param = ' @pparam=proxy.config.multiplexer.skip_post_put=1' self.ts.Disk.remap_config.AddLines( [ - f'map https://origin.server.com https://127.0.0.1:{self.server_origin.Variables.https_port} ' + f'map https://origin.server.com https://backend.origin.server.com:{self.server_origin.Variables.https_port} ' f'@plugin=multiplexer.so @pparam=nontls.server.com @pparam=tls.server.com' f'{skip_remap_param}', # Now create remap entries for the multiplexed hosts: one that # verifies HTTP, and another that verifies HTTPS. - f'map http://nontls.server.com http://127.0.0.1:{self.server_http.Variables.http_port}', - f'map http://tls.server.com https://127.0.0.1:{self.server_https.Variables.https_port}', + f'map http://nontls.server.com http://backend.nontls.server.com:{self.server_http.Variables.http_port}', + f'map http://tls.server.com https://backend.tls.server.com:{self.server_https.Variables.https_port}', ]) def run(self): tr = Test.AddTestRun() + self.ts.StartBefore(self.dns) tr.Processes.Default.StartBefore(self.server_origin) tr.Processes.Default.StartBefore(self.server_http) tr.Processes.Default.StartBefore(self.server_https) @@ -122,7 +135,8 @@ def run(self): counter = MultiplexerTestBase.client_counter MultiplexerTestBase.client_counter += 1 - tr.AddVerifierClientProcess(f"client_{counter}", self.replay_file, https_ports=[self.ts.Variables.ssl_port]) + client = tr.AddVerifierClientProcess(f"client_{counter}", self.replay_file, https_ports=[self.ts.Variables.ssl_port]) + client.Streams.All += Testers.ExcludesExpression(r'\[ERROR\]', 'Verify there were no errors in the replay.') class MultiplexerTest(MultiplexerTestBase): diff --git a/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_copy.replay.yaml b/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_copy.replay.yaml index c4ceb90943a..ddc0403a20c 100644 --- a/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_copy.replay.yaml +++ b/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_copy.replay.yaml @@ -48,7 +48,7 @@ sessions: reason: OK headers: fields: - - [ Content-Length, 32 ] + - [ Content-Length, 320000 ] - [ X-Response, first ] # There is no client since this response terminates at ATS, so no need for @@ -77,7 +77,7 @@ sessions: reason: OK headers: fields: - - [ Content-Length, 32 ] + - [ Content-Length, 320000 ] - [ X-Response, second ] # There is no client since this response terminates at ATS, so no need for @@ -106,7 +106,7 @@ sessions: reason: OK headers: fields: - - [ Content-Length, 32 ] + - [ Content-Length, 320000 ] - [ X-Response, third ] # There is no client since this response terminates at ATS, so no need for diff --git a/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_original.replay.yaml b/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_original.replay.yaml index 6db3db834d6..f51f71ead6c 100644 --- a/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_original.replay.yaml +++ b/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_original.replay.yaml @@ -48,7 +48,7 @@ sessions: reason: OK headers: fields: - - [ Content-Length, 32 ] + - [ Content-Length, 320000 ] - [ X-Response, first ] proxy-response: @@ -64,7 +64,7 @@ sessions: headers: fields: - [ Host, origin.server.com ] - - [ Content-Length, 8 ] + - [ Content-Length, 320000 ] - [ X-Request, second ] - [ uuid, POST ] @@ -80,7 +80,7 @@ sessions: reason: OK headers: fields: - - [ Content-Length, 32 ] + - [ Content-Length, 320000 ] - [ X-Response, second ] proxy-response: @@ -96,7 +96,7 @@ sessions: headers: fields: - [ Host, origin.server.com ] - - [ Content-Length, 8 ] + - [ Content-Length, 320000 ] - [ X-Request, third ] - [ uuid, PUT ] @@ -112,7 +112,7 @@ sessions: reason: OK headers: fields: - - [ Content-Length, 32 ] + - [ Content-Length, 320000 ] - [ X-Response, third ] proxy-response: From d096a65d6a5df7f0f45016c988b3a8c7c5804be1 Mon Sep 17 00:00:00 2001 From: Brian Neradt Date: Sun, 7 Jan 2024 01:54:31 +0000 Subject: [PATCH 2/2] Have multiplexer pass in CL of requests. This reverts the HttpSM change to set nbytes from CL and has multiplexer handle this on its own. Using INT64_MAX should correspond with chunked encoding. That can be handled as a separate change. --- include/tscore/ink_string++.h | 1 - plugins/multiplexer/ats-multiplexer.cc | 38 ++++++++++++------- plugins/multiplexer/post.cc | 8 +++- plugins/multiplexer/post.h | 5 ++- src/proxy/http/HttpSM.cc | 27 +++---------- .../multiplexer/multiplexer.test.py | 9 +++++ .../replays/multiplexer_original.replay.yaml | 36 ++++++++++++++++++ ...multiplexer_original_skip_post.replay.yaml | 36 ++++++++++++++++++ 8 files changed, 122 insertions(+), 38 deletions(-) diff --git a/include/tscore/ink_string++.h b/include/tscore/ink_string++.h index c8e9a1ada95..9d737edcc12 100644 --- a/include/tscore/ink_string++.h +++ b/include/tscore/ink_string++.h @@ -32,7 +32,6 @@ #pragma once #include -#include #include /*********************************************************************** diff --git a/plugins/multiplexer/ats-multiplexer.cc b/plugins/multiplexer/ats-multiplexer.cc index 6bc0fd7ca0d..5bbd8a31c44 100644 --- a/plugins/multiplexer/ats-multiplexer.cc +++ b/plugins/multiplexer/ats-multiplexer.cc @@ -119,14 +119,27 @@ DoRemap(const Instance &i, TSHttpTxn t) assert(buffer != nullptr); assert(location != nullptr); - int length; - const char *const method = TSHttpHdrMethodGet(buffer, location, &length); - - Dbg(dbg_ctl, "Method is %s.", std::string(method, length).c_str()); - - if (i.skipPostPut && ((length == TS_HTTP_LEN_POST && memcmp(TS_HTTP_METHOD_POST, method, TS_HTTP_LEN_POST) == 0) || - (length == TS_HTTP_LEN_PUT && memcmp(TS_HTTP_METHOD_PUT, method, TS_HTTP_LEN_PUT) == 0))) { - TSHandleMLocRelease(buffer, TS_NULL_MLOC, location); + int method_length; + const char *const method = TSHttpHdrMethodGet(buffer, location, &method_length); + + Dbg(dbg_ctl, "Method is %s.", std::string(method, method_length).c_str()); + + // A value of -1 is used to indicate there was no Content-Length header. + int content_length = -1; + // Retrieve the value of the Content-Length header. + auto field_loc = TSMimeHdrFieldFind(buffer, location, TS_MIME_FIELD_CONTENT_LENGTH, TS_MIME_LEN_CONTENT_LENGTH); + if (field_loc != TS_NULL_MLOC) { + content_length = TSMimeHdrFieldValueUintGet(buffer, location, field_loc, -1); + TSHandleMLocRelease(buffer, location, field_loc); + } + bool const is_post_or_put = (method_length == TS_HTTP_LEN_POST && memcmp(TS_HTTP_METHOD_POST, method, TS_HTTP_LEN_POST) == 0) || + (method_length == TS_HTTP_LEN_PUT && memcmp(TS_HTTP_METHOD_PUT, method, TS_HTTP_LEN_PUT) == 0); + if (i.skipPostPut && is_post_or_put) { + Dbg(dbg_ctl, "skip_post_put: skipping a POST or PUT request."); + } else if (content_length < 0 && is_post_or_put) { + // HttpSM would need an update for POST request transforms to support + // chunked request bodies. It currently does not support this. + Dbg(dbg_ctl, "Skipping a non-Content-Length POST or PUT request."); } else { { TSMLoc field; @@ -145,21 +158,20 @@ DoRemap(const Instance &i, TSHttpTxn t) generateRequests(i.origins, buffer, location, requests); assert(requests.size() == i.origins.size()); - if ((length == TS_HTTP_LEN_POST && memcmp(TS_HTTP_METHOD_POST, method, TS_HTTP_LEN_POST) == 0) || - (length == TS_HTTP_LEN_PUT && memcmp(TS_HTTP_METHOD_PUT, method, TS_HTTP_LEN_PUT) == 0)) { + if (is_post_or_put) { const TSVConn vconnection = TSTransformCreate(handlePost, t); assert(vconnection != nullptr); - TSContDataSet(vconnection, new PostState(requests)); + PostState *state = new PostState(requests, content_length); + TSContDataSet(vconnection, state); assert(requests.empty()); TSHttpTxnHookAdd(t, TS_HTTP_REQUEST_TRANSFORM_HOOK, vconnection); } else { dispatch(requests, timeout); } - TSHandleMLocRelease(buffer, TS_NULL_MLOC, location); - TSStatIntIncrement(statistics.requests, 1); } + TSHandleMLocRelease(buffer, TS_NULL_MLOC, location); } TSRemapStatus diff --git a/plugins/multiplexer/post.cc b/plugins/multiplexer/post.cc index 3e61cc6552a..02e9c4f2eab 100644 --- a/plugins/multiplexer/post.cc +++ b/plugins/multiplexer/post.cc @@ -41,7 +41,8 @@ PostState::~PostState() } } -PostState::PostState(Requests &r) : origin_buffer(nullptr), clone_reader(nullptr), output_vio(nullptr) +PostState::PostState(Requests &r, int content_length) + : content_length{content_length}, origin_buffer(nullptr), clone_reader(nullptr), output_vio(nullptr) { assert(!r.empty()); requests.swap(r); @@ -72,7 +73,10 @@ postTransform(const TSCont c, PostState &s) s.clone_reader = TSIOBufferReaderClone(origin_reader); assert(s.clone_reader != nullptr); - s.output_vio = TSVConnWrite(output_vconn, c, origin_reader, std::numeric_limits::max()); + // A future patch should support chunked POST bodies. In those cases, we + // can use INT64_MAX instead of s.content_length. + assert(s.content_length > 0); + s.output_vio = TSVConnWrite(output_vconn, c, origin_reader, s.content_length); assert(s.output_vio != nullptr); } diff --git a/plugins/multiplexer/post.h b/plugins/multiplexer/post.h index 800d1eec42d..331d30640a6 100644 --- a/plugins/multiplexer/post.h +++ b/plugins/multiplexer/post.h @@ -30,13 +30,16 @@ struct PostState { Requests requests; + /// The Content-Length value of the POST/PUT request. + int content_length = -1; + TSIOBuffer origin_buffer; TSIOBufferReader clone_reader; /// The VIO for the original (non-clone) origin. TSVIO output_vio; ~PostState(); - PostState(Requests &); + PostState(Requests &, int content_length); }; int handlePost(TSCont, TSEvent, void *); diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc index d6eb046f386..5cf84ca2728 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -23,7 +23,6 @@ */ #include "tsutil/ts_bw_format.h" -#include "proxy/hdrs/MIME.h" #include "proxy/ProxyTransaction.h" #include "proxy/http/HttpSM.h" #include "proxy/http/ConnectingEntry.h" @@ -1129,26 +1128,12 @@ HttpSM::state_request_wait_for_transform_read(int event, void *data) setup_server_send_request_api(); break; } else { - // The caller of the API did not specify a size, but there may be a - // Content-Length header in the request. Our plugin transform example - // plugin suggests using INT64_MAX, for example, to transform the entire - // body from the upstream to the downstream VIO. For these situations, - // check for an existing Content-Length header on behalf of the caller. - if (nullptr != t_state.hdr_info.server_request.field_find(MIME_FIELD_CONTENT_LENGTH, MIME_LEN_CONTENT_LENGTH)) { - int64_t const cl = t_state.hdr_info.server_request.value_get_int64(MIME_FIELD_CONTENT_LENGTH, MIME_LEN_CONTENT_LENGTH); - SMDbg(dbg_ctl_http, "No size passed via the callback. Using the Content-Length header value: %" PRId64, cl); - t_state.hdr_info.transform_request_cl = cl; - setup_server_send_request_api(); - break; - } else { - // No content length from the post. This is a no go - // since http spec requires content length when - // sending a request message body. Change the event - // to an error and fall through - event = VC_EVENT_ERROR; - SMDbg(dbg_ctl_http, "No size passed via the callback and there is no Content-Length header. Aborting the transform."); - Log::error("Request transformation failed to set content length"); - } + // No content length from the post. This is a no go + // since http spec requires content length when + // sending a request message body. Change the event + // to an error and fall through + event = VC_EVENT_ERROR; + Log::error("Request transformation failed to set content length"); } // FALLTHROUGH default: diff --git a/tests/gold_tests/pluginTest/multiplexer/multiplexer.test.py b/tests/gold_tests/pluginTest/multiplexer/multiplexer.test.py index d9edc61f55c..5e52c3fae73 100644 --- a/tests/gold_tests/pluginTest/multiplexer/multiplexer.test.py +++ b/tests/gold_tests/pluginTest/multiplexer/multiplexer.test.py @@ -73,6 +73,9 @@ def setupServers(self): self.server_origin.Streams.All += Testers.ContainsExpression( 'uuid: PUT', "Verify the client's original target received the PUT transaction.") self.server_origin.Streams.All += Testers.ExcludesExpression(r'\[ERROR\]', 'Verify there were no errors in the replay.') + # The chunked POST should go to the origin. + self.server_origin.Streams.All += Testers.ContainsExpression( + 'uuid: CHUNKED_POST', "Verify the client's original target received the chunked POST transaction.") # Under all configurations, the GET request should be multiplexed. self.server_origin.Streams.All += Testers.ContainsExpression( @@ -84,11 +87,17 @@ def setupServers(self): 'X-Multiplexer: copy', 'Verify the HTTP server received a "copy" of the request.') self.server_http.Streams.All += Testers.ContainsExpression('uuid: GET', "Verify the HTTP server received the GET request.") self.server_http.Streams.All += Testers.ExcludesExpression(r'\[ERROR\]', 'Verify there were no errors in the replay.') + # Chunked POST requests are not supported for multiplexing. + self.server_http.Streams.All += Testers.ExcludesExpression( + 'uuid: CHUNKED_POST', 'We do not expect a multiplexed chunked POST.') self.server_https.Streams.All += Testers.ContainsExpression( 'X-Multiplexer: copy', 'Verify the HTTPS server received a "copy" of the request.') self.server_https.Streams.All += Testers.ContainsExpression( 'uuid: GET', "Verify the HTTPS server received the GET request.") + # Chunked POST requests are not supported for multiplexing. + self.server_https.Streams.All += Testers.ExcludesExpression( + 'uuid: CHUNKED_POST', 'We do not expect a multiplexed chunked POST.') # Verify that the HTTPS server receives a TLS connection. self.server_https.Streams.All += Testers.ContainsExpression( diff --git a/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_original.replay.yaml b/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_original.replay.yaml index f51f71ead6c..daeead023e1 100644 --- a/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_original.replay.yaml +++ b/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_original.replay.yaml @@ -120,3 +120,39 @@ sessions: headers: fields: - [ X-Response, { value: third, as: equal } ] + + # POST that is chunked. We do not support multiplexing chunked request bodies, + # but it should go to the origin fine. + - client-request: + method: "POST" + version: "1.1" + url: /path/chunked_post + headers: + fields: + - [ Host, origin.server.com ] + - [ Transfer-Encoding, chunked ] + - [ X-Request, fourth ] + - [ uuid, CHUNKED_POST ] + content: + size: 320000 + + proxy-request: + method: "POST" + headers: + fields: + - [ X-Request, { value: fourth, as: equal } ] + - [ X-Multiplexer, { as: absent } ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Length, 320000 ] + - [ X-Response, fourth ] + + proxy-response: + status: 200 + headers: + fields: + - [ X-Response, { value: fourth, as: equal } ] diff --git a/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_original_skip_post.replay.yaml b/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_original_skip_post.replay.yaml index 4609a5f18c3..86f2f14224d 100644 --- a/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_original_skip_post.replay.yaml +++ b/tests/gold_tests/pluginTest/multiplexer/replays/multiplexer_original_skip_post.replay.yaml @@ -120,3 +120,39 @@ sessions: headers: fields: - [ X-Response, { value: third, as: equal } ] + + # POST that is chunked. We do not support multiplexing chunked request bodies, + # but it should go to the origin fine. + - client-request: + method: "POST" + version: "1.1" + url: /path/chunked_post + headers: + fields: + - [ Host, origin.server.com ] + - [ Transfer-Encoding, chunked ] + - [ X-Request, fourth ] + - [ uuid, CHUNKED_POST ] + content: + size: 320000 + + proxy-request: + method: "POST" + headers: + fields: + - [ X-Request, { value: fourth, as: equal } ] + - [ X-Multiplexer, { as: absent } ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Length, 320000 ] + - [ X-Response, fourth ] + + proxy-response: + status: 200 + headers: + fields: + - [ X-Response, { value: fourth, as: equal } ]