Skip to content

Commit 89cf4d8

Browse files
shukitchanShu Kit Chan
andauthored
Add max inclusion depth support for esi plugin 10.0.x (#12299)
Co-authored-by: Shu Kit Chan <kichan@c40b7d47.home-kichan.bf2.ows.oath.cloud>
1 parent e1fd38e commit 89cf4d8

4 files changed

Lines changed: 247 additions & 37 deletions

File tree

doc/admin-guide/plugins/esi.en.rst

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Enabling ESI
7676

7777
esi.so
7878

79-
2. There are four optional arguments that can be passed to the above ``esi.so`` entry:
79+
2. There are optional arguments that can be passed to the above ``esi.so`` entry:
8080

8181
- ``--private-response`` will add private cache control and expires headers to the processed ESI document.
8282
- ``--packed-node-support`` will enable the support for using the packed node feature, which will improve the
@@ -86,10 +86,12 @@ Enabling ESI
8686
- ``--first-byte-flush`` will enable the first byte flush feature, which will flush content to users as soon as the entire
8787
ESI document is received and parsed without all ESI includes fetched. The flushing will stop at the ESI include markup
8888
till that include is fetched.
89-
- ``--max-doc-size <number-of-bytes>`` gives the maximum size of the document, in bytes. The number of bytes must be
90-
be must an unsigned decimal integer, and can be followed (with no white space) by a K, to indicate the given number is
91-
multiplied by 1024, or by M, to indicate the given number is multiplied by 1024 * 1024. Example values: 500,
92-
5K, 2M. If this option is omitted, the maximum document size defaults to 1M.
89+
- ``--max-doc-size <number-of-bytes>`` (or ``--max-doc-size=<number-of-bytes>``) gives the maximum size of the document,
90+
in bytes. The number of bytes must be an unsigned decimal integer, and can be followed (with no white space) by
91+
a K, to indicate the given number is multiplied by 1024, or by M, to indicate the given number is multiplied by
92+
1024 * 1024. Example values: 500, 5K, 2M. If this option is omitted, the maximum document size defaults to 1M.
93+
- ``--max-inclusion-depth <max-depth>`` controls the maximum depth of recursive ESI inclusion allowed (between 0 and 9).
94+
Default is 3.
9395

9496
3. ``HTTP_COOKIE`` variable support is turned off by default. It can be turned on with ``-f <handler_config>`` or
9597
``-handler <handler_config>``. For example:

plugins/esi/esi.cc

Lines changed: 91 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ struct OptionInfo {
5858
bool disable_gzip_output{false};
5959
bool first_byte_flush{false};
6060
unsigned max_doc_size{1024 * 1024};
61+
unsigned max_inclusion_depth{3};
6162
};
6263

6364
static HandlerManager *gHandlerManager = nullptr;
@@ -69,6 +70,9 @@ static Utils::HeaderValueList gAllowlistCookies;
6970
#define MIME_FIELD_XESI "X-Esi"
7071
#define MIME_FIELD_XESI_LEN 5
7172

73+
#define MIME_FIELD_XESIDEPTH "X-Esi-Depth"
74+
#define MIME_FIELD_XESIDEPTH_LEN 11
75+
7276
#define HTTP_VALUE_PRIVATE_EXPIRES "-1"
7377
#define HTTP_VALUE_PRIVATE_CC "max-age=0, private"
7478

@@ -298,46 +302,66 @@ ContData::getClientState()
298302
}
299303
TSHandleMLocRelease(bufp, req_hdr_loc, url_loc);
300304
}
301-
TSMLoc field_loc = TSMimeHdrFieldGet(req_bufp, req_hdr_loc, 0);
305+
306+
TSMLoc field_loc = TSMimeHdrFieldGet(req_bufp, req_hdr_loc, 0);
307+
bool depth_field = false;
302308
while (field_loc) {
303309
TSMLoc next_field_loc;
304310
const char *name;
305311
int name_len;
306312

307313
name = TSMimeHdrFieldNameGet(req_bufp, req_hdr_loc, field_loc, &name_len);
308314
if (name) {
309-
int n_values;
310-
n_values = TSMimeHdrFieldValuesCount(req_bufp, req_hdr_loc, field_loc);
311-
if (n_values && (n_values != TS_ERROR)) {
312-
const char *value = nullptr;
313-
int value_len = 0;
314-
if (n_values == 1) {
315-
value = TSMimeHdrFieldValueStringGet(req_bufp, req_hdr_loc, field_loc, 0, &value_len);
316-
317-
if (nullptr != value && value_len) {
318-
if (Utils::areEqual(name, name_len, TS_MIME_FIELD_ACCEPT_ENCODING, TS_MIME_LEN_ACCEPT_ENCODING) &&
319-
Utils::areEqual(value, value_len, TS_HTTP_VALUE_GZIP, TS_HTTP_LEN_GZIP)) {
320-
gzip_output = true;
321-
}
322-
}
315+
if (Utils::areEqual(name, name_len, MIME_FIELD_XESIDEPTH, MIME_FIELD_XESIDEPTH_LEN)) {
316+
unsigned d = TSMimeHdrFieldValueUintGet(req_bufp, req_hdr_loc, field_loc, -1);
317+
d = (d + 1) % 10;
318+
char dstr[2];
319+
int const len = snprintf(dstr, sizeof(dstr), "%u", d);
320+
321+
HttpHeader header;
322+
if (len != 1) {
323+
header = HttpHeader(MIME_FIELD_XESIDEPTH, MIME_FIELD_XESIDEPTH_LEN, "1", 1);
323324
} else {
324-
for (int i = 0; i < n_values; ++i) {
325-
value = TSMimeHdrFieldValueStringGet(req_bufp, req_hdr_loc, field_loc, i, &value_len);
325+
header = HttpHeader(MIME_FIELD_XESIDEPTH, MIME_FIELD_XESIDEPTH_LEN, dstr, 1);
326+
}
327+
data_fetcher->useHeader(header);
328+
esi_vars->populate(header);
329+
depth_field = true;
330+
331+
} else {
332+
int n_values;
333+
n_values = TSMimeHdrFieldValuesCount(req_bufp, req_hdr_loc, field_loc);
334+
if (n_values && (n_values != TS_ERROR)) {
335+
const char *value = nullptr;
336+
int value_len = 0;
337+
if (n_values == 1) {
338+
value = TSMimeHdrFieldValueStringGet(req_bufp, req_hdr_loc, field_loc, 0, &value_len);
339+
326340
if (nullptr != value && value_len) {
327341
if (Utils::areEqual(name, name_len, TS_MIME_FIELD_ACCEPT_ENCODING, TS_MIME_LEN_ACCEPT_ENCODING) &&
328342
Utils::areEqual(value, value_len, TS_HTTP_VALUE_GZIP, TS_HTTP_LEN_GZIP)) {
329343
gzip_output = true;
330344
}
331345
}
332-
}
346+
} else {
347+
for (int i = 0; i < n_values; ++i) {
348+
value = TSMimeHdrFieldValueStringGet(req_bufp, req_hdr_loc, field_loc, i, &value_len);
349+
if (nullptr != value && value_len) {
350+
if (Utils::areEqual(name, name_len, TS_MIME_FIELD_ACCEPT_ENCODING, TS_MIME_LEN_ACCEPT_ENCODING) &&
351+
Utils::areEqual(value, value_len, TS_HTTP_VALUE_GZIP, TS_HTTP_LEN_GZIP)) {
352+
gzip_output = true;
353+
}
354+
}
355+
}
333356

334-
value = TSMimeHdrFieldValueStringGet(req_bufp, req_hdr_loc, field_loc, -1, &value_len);
335-
}
357+
value = TSMimeHdrFieldValueStringGet(req_bufp, req_hdr_loc, field_loc, -1, &value_len);
358+
}
336359

337-
if (value != nullptr) {
338-
HttpHeader header(name, name_len, value, value_len);
339-
data_fetcher->useHeader(header);
340-
esi_vars->populate(header);
360+
if (value != nullptr) {
361+
HttpHeader header(name, name_len, value, value_len);
362+
data_fetcher->useHeader(header);
363+
esi_vars->populate(header);
364+
}
341365
}
342366
}
343367
}
@@ -346,6 +370,12 @@ ContData::getClientState()
346370
TSHandleMLocRelease(req_bufp, req_hdr_loc, field_loc);
347371
field_loc = next_field_loc;
348372
}
373+
374+
if (depth_field == false) {
375+
HttpHeader header(MIME_FIELD_XESIDEPTH, MIME_FIELD_XESIDEPTH_LEN, "1", 1);
376+
data_fetcher->useHeader(header);
377+
esi_vars->populate(header);
378+
}
349379
}
350380

351381
if (gzip_output) {
@@ -1229,7 +1259,7 @@ maskOsCacheHeaders(TSHttpTxn txnp)
12291259
}
12301260

12311261
static bool
1232-
isTxnTransformable(TSHttpTxn txnp, bool is_cache_txn, bool *intercept_header, bool *head_only)
1262+
isTxnTransformable(TSHttpTxn txnp, bool is_cache_txn, const OptionInfo *pOptionInfo, bool *intercept_header, bool *head_only)
12331263
{
12341264
// We are only interested in transforming "200 OK" responses with a
12351265
// Content-Type: text/ header and with X-Esi header
@@ -1244,6 +1274,21 @@ isTxnTransformable(TSHttpTxn txnp, bool is_cache_txn, bool *intercept_header, bo
12441274
return false;
12451275
}
12461276

1277+
TSMLoc loc;
1278+
unsigned d;
1279+
1280+
d = 0;
1281+
loc = TSMimeHdrFieldFind(bufp, hdr_loc, MIME_FIELD_XESIDEPTH, MIME_FIELD_XESIDEPTH_LEN);
1282+
if (loc != TS_NULL_MLOC) {
1283+
d = TSMimeHdrFieldValueUintGet(bufp, hdr_loc, loc, -1);
1284+
}
1285+
TSHandleMLocRelease(bufp, hdr_loc, loc);
1286+
if (d >= pOptionInfo->max_inclusion_depth) {
1287+
TSError("[esi][%s] The current esi inclusion depth (%u) is larger than or equal to the max (%u)", __FUNCTION__, d,
1288+
pOptionInfo->max_inclusion_depth);
1289+
return false;
1290+
}
1291+
12471292
int method_len;
12481293
const char *method;
12491294
method = TSHttpHdrMethodGet(bufp, hdr_loc, &method_len);
@@ -1318,7 +1363,7 @@ isTxnTransformable(TSHttpTxn txnp, bool is_cache_txn, bool *intercept_header, bo
13181363
}
13191364

13201365
static bool
1321-
isCacheObjTransformable(TSHttpTxn txnp, bool *intercept_header, bool *head_only)
1366+
isCacheObjTransformable(TSHttpTxn txnp, const OptionInfo *pOptionInfo, bool *intercept_header, bool *head_only)
13221367
{
13231368
int obj_status;
13241369
if (TSHttpTxnCacheLookupStatusGet(txnp, &obj_status) == TS_ERROR) {
@@ -1327,7 +1372,7 @@ isCacheObjTransformable(TSHttpTxn txnp, bool *intercept_header, bool *head_only)
13271372
}
13281373
if (obj_status == TS_CACHE_LOOKUP_HIT_FRESH) {
13291374
Dbg(dbg_ctl_local, "[%s] doc found in cache, will add transformation", __FUNCTION__);
1330-
return isTxnTransformable(txnp, true, intercept_header, head_only);
1375+
return isTxnTransformable(txnp, true, pOptionInfo, intercept_header, head_only);
13311376
}
13321377
Dbg(dbg_ctl_local, "[%s] cache object's status is %d; not transformable", __FUNCTION__, obj_status);
13331378
return false;
@@ -1499,7 +1544,7 @@ globalHookHandler(TSCont contp, TSEvent event, void *edata)
14991544
if (event == TS_EVENT_HTTP_READ_RESPONSE_HDR) {
15001545
bool mask_cache_headers = false;
15011546
Dbg(dbg_ctl_local, "[%s] handling read response header event", __FUNCTION__);
1502-
if (isTxnTransformable(txnp, false, &intercept_header, &head_only)) {
1547+
if (isTxnTransformable(txnp, false, pOptionInfo, &intercept_header, &head_only)) {
15031548
addTransform(txnp, true, intercept_header, head_only, pOptionInfo);
15041549
Stats::increment(Stats::N_OS_DOCS);
15051550
mask_cache_headers = true;
@@ -1512,7 +1557,7 @@ globalHookHandler(TSCont contp, TSEvent event, void *edata)
15121557
}
15131558
} else {
15141559
Dbg(dbg_ctl_local, "[%s] handling cache lookup complete event", __FUNCTION__);
1515-
if (isCacheObjTransformable(txnp, &intercept_header, &head_only)) {
1560+
if (isCacheObjTransformable(txnp, pOptionInfo, &intercept_header, &head_only)) {
15161561
// we make the assumption above that a transformable cache
15171562
// object would already have a transformation. We should revisit
15181563
// that assumption in case we change the statement below
@@ -1575,11 +1620,12 @@ esiPluginInit(int argc, const char *argv[], OptionInfo *pOptionInfo)
15751620
{const_cast<char *>("first-byte-flush"), no_argument, nullptr, 'b'},
15761621
{const_cast<char *>("handler-filename"), required_argument, nullptr, 'f'},
15771622
{const_cast<char *>("max-doc-size"), required_argument, nullptr, 'd'},
1623+
{const_cast<char *>("max-inclusion-depth"), required_argument, nullptr, 'i'},
15781624
{nullptr, 0, nullptr, 0 },
15791625
};
15801626

15811627
int longindex = 0;
1582-
while ((c = getopt_long(argc, const_cast<char *const *>(argv), "npzbf:d:", longopts, &longindex)) != -1) {
1628+
while ((c = getopt_long(argc, const_cast<char *const *>(argv), "npzbf:d:i:", longopts, &longindex)) != -1) {
15831629
switch (c) {
15841630
case 'n':
15851631
pOptionInfo->packed_node_support = true;
@@ -1621,6 +1667,18 @@ esiPluginInit(int argc, const char *argv[], OptionInfo *pOptionInfo)
16211667
pOptionInfo->max_doc_size = max * coeff;
16221668
break;
16231669
}
1670+
case 'i': {
1671+
unsigned max;
1672+
auto num = std::sscanf(optarg, "%u", &max);
1673+
if (num != 1) {
1674+
TSEmergency("[esi][%s] value for maximum inclusion depth (%s) is not unsigned integer", __FUNCTION__, optarg);
1675+
}
1676+
if (max > 9) {
1677+
TSEmergency("[esi][%s] maximum inclusion depth (%s) large than 9", __FUNCTION__, optarg);
1678+
}
1679+
pOptionInfo->max_inclusion_depth = max;
1680+
break;
1681+
}
16241682
default:
16251683
TSEmergency("[esi][%s] bad option", __FUNCTION__);
16261684
return -1;
@@ -1630,9 +1688,10 @@ esiPluginInit(int argc, const char *argv[], OptionInfo *pOptionInfo)
16301688

16311689
Dbg(dbg_ctl_local,
16321690
"[%s] Plugin started, "
1633-
"packed-node-support: %d, private-response: %d, disable-gzip-output: %d, first-byte-flush: %d, max-doc-size %u ",
1691+
"packed-node-support: %d, private-response: %d, disable-gzip-output: %d, first-byte-flush: %d, max-doc-size %u, "
1692+
"max-inclusion-depth %u ",
16341693
__FUNCTION__, pOptionInfo->packed_node_support, pOptionInfo->private_response, pOptionInfo->disable_gzip_output,
1635-
pOptionInfo->first_byte_flush, pOptionInfo->max_doc_size);
1694+
pOptionInfo->first_byte_flush, pOptionInfo->max_doc_size, pOptionInfo->max_inclusion_depth);
16361695

16371696
return 0;
16381697
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
'''
2+
Test nested include for the ESI plugin.
3+
'''
4+
# Licensed to the Apache Software Foundation (ASF) under one
5+
# or more contributor license agreements. See the NOTICE file
6+
# distributed with this work for additional information
7+
# regarding copyright ownership. The ASF licenses this file
8+
# to you under the Apache License, Version 2.0 (the
9+
# "License"); you may not use this file except in compliance
10+
# with the License. You may obtain a copy of the License at
11+
#
12+
# http://www.apache.org/licenses/LICENSE-2.0
13+
#
14+
# Unless required by applicable law or agreed to in writing, software
15+
# distributed under the License is distributed on an "AS IS" BASIS,
16+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
# See the License for the specific language governing permissions and
18+
# limitations under the License.
19+
20+
import os
21+
22+
Test.Summary = '''
23+
Test nested include for the ESI plugin.
24+
'''
25+
26+
Test.SkipUnless(Condition.PluginExists('esi.so'),)
27+
28+
29+
class EsiTest():
30+
"""
31+
A class that encapsulates the configuration and execution of a set of ESI
32+
test cases.
33+
"""
34+
""" static: The same server Process is used across all tests. """
35+
_server = None
36+
""" static: A counter to keep the ATS process names unique across tests. """
37+
_ts_counter = 0
38+
""" static: A counter to keep any output file names unique across tests. """
39+
_output_counter = 0
40+
""" The ATS process for this set of test cases. """
41+
_ts = None
42+
43+
def __init__(self, plugin_config):
44+
"""
45+
Args:
46+
plugin_config (str): The config line to place in plugin.config for
47+
the ATS process.
48+
"""
49+
if EsiTest._server is None:
50+
EsiTest._server = EsiTest._create_server()
51+
52+
self._ts = EsiTest._create_ats(self, plugin_config)
53+
54+
@staticmethod
55+
def _create_server():
56+
"""
57+
Create and start a server process.
58+
"""
59+
# Configure our server.
60+
server = Test.MakeOriginServer("server", lookup_key="{%uuid}")
61+
62+
# Generate the set of ESI responses.
63+
request_header = {
64+
"headers": "GET /esi-nested-include.php HTTP/1.1\r\n" + "Host: www.example.com\r\n" + "Content-Length: 0\r\n\r\n",
65+
"timestamp": "1469733493.993",
66+
"body": ""
67+
}
68+
esi_body = r'''<p>
69+
<esi:include src="http://www.example.com/esi-nested-include.html"/>
70+
</p>
71+
'''
72+
response_header = {
73+
"headers":
74+
"HTTP/1.1 200 OK\r\n" + "X-Esi: 1\r\n" + "Cache-Control: private\r\n" + "Content-Type: text/html\r\n" +
75+
"Connection: close\r\n" + "Content-Length: {}\r\n".format(len(esi_body)) + "\r\n",
76+
"timestamp": "1469733493.993",
77+
"body": esi_body
78+
}
79+
server.addResponse("sessionfile.log", request_header, response_header)
80+
81+
# Create a run to start the server.
82+
tr = Test.AddTestRun("Start the server.")
83+
tr.Processes.Default.StartBefore(server)
84+
tr.Processes.Default.Command = "echo starting the server"
85+
tr.Processes.Default.ReturnCode = 0
86+
tr.StillRunningAfter = server
87+
88+
return server
89+
90+
@staticmethod
91+
def _create_ats(self, plugin_config):
92+
"""
93+
Create and start an ATS process.
94+
"""
95+
EsiTest._ts_counter += 1
96+
97+
# Configure ATS with a vanilla ESI plugin configuration.
98+
ts = Test.MakeATSProcess("ts{}".format(EsiTest._ts_counter))
99+
ts.Disk.records_config.update({
100+
'proxy.config.diags.debug.enabled': 1,
101+
'proxy.config.diags.debug.tags': 'http|plugin_esi',
102+
})
103+
ts.Disk.remap_config.AddLine(f'map http://www.example.com/ http://127.0.0.1:{EsiTest._server.Variables.Port}')
104+
ts.Disk.plugin_config.AddLine(plugin_config)
105+
106+
ts.Disk.diags_log.Content = Testers.ContainsExpression(
107+
r'The current esi inclusion depth \(3\) is larger than or equal to the max \(3\)',
108+
'Verify the ESI error concerning the max inclusion depth')
109+
110+
# Create a run to start the ATS process.
111+
tr = Test.AddTestRun("Start the ATS process.")
112+
tr.Processes.Default.StartBefore(ts)
113+
tr.Processes.Default.Command = "echo starting ATS"
114+
tr.Processes.Default.ReturnCode = 0
115+
tr.StillRunningAfter = ts
116+
return ts
117+
118+
def run_test(self):
119+
# Test 1: Verify basic ESI functionality without processing internal txn.
120+
tr = Test.AddTestRun("First request")
121+
tr.Processes.Default.Command = \
122+
('curl http://127.0.0.1:{0}/main.php -H"Host: www.example.com" '
123+
'-H"Accept: */*" --verbose'.format(
124+
self._ts.Variables.port))
125+
tr.Processes.Default.ReturnCode = 0
126+
tr.Processes.Default.Streams.stdout = "gold/nested_include_body.gold"
127+
tr.StillRunningAfter = self._server
128+
tr.StillRunningAfter = self._ts
129+
130+
131+
#
132+
# Configure and run the test cases.
133+
#
134+
135+
# Run the tests with ESI configured with private response.
136+
first_test = EsiTest(plugin_config='esi.so')
137+
first_test.run_test()

0 commit comments

Comments
 (0)