From: Nick Porter Date: Thu, 15 Jul 2021 02:12:22 +0000 (+0100) Subject: v4: Define arguments for %(rest: ) xlat and parse / escape URIs (#4127) X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=30e854413af42da25ce3d11064ccde05d6ac2005;p=thirdparty%2Ffreeradius-server.git v4: Define arguments for %(rest: ) xlat and parse / escape URIs (#4127) * Define args for rest xlat * Define xlat_uri_part_t - for defining parts of URIs for parsing * Add xlat_parse_uri() to parse defined URI structures. * Define url_part_escape() to escape part of a URI * Define rest_uri_parts * Update rest_xlat() to parse inbound boxes and escape tainted values * Update syntax for %(rest: ) tests * Add tests using inbound data for other parts of a rest URI * Update documentation for %(rest: ) * Ensure we "exdent" before returning * Add additional test cases for %(rest: ) * Make sure test web server handles different paths correctly --- diff --git a/doc/antora/modules/raddb/pages/mods-available/json.adoc b/doc/antora/modules/raddb/pages/mods-available/json.adoc index ff2d7c4d5da..45ff62297b8 100644 --- a/doc/antora/modules/raddb/pages/mods-available/json.adoc +++ b/doc/antora/modules/raddb/pages/mods-available/json.adoc @@ -66,7 +66,7 @@ fields can be accessed using 'map' as shown in the example below. [source, unlang] ---- -map json "%{rest:GET http://example.org/api/user/%{User-Name}" { +map json "%(rest:GET http://example.org/api/user/%{User-Name})" { &Tmp-Integer-0 := '$.account number' &Group += '$.groups.*' } diff --git a/doc/antora/modules/raddb/pages/mods-available/rest.adoc b/doc/antora/modules/raddb/pages/mods-available/rest.adoc index 69fc6fa34d0..44509794f00 100644 --- a/doc/antora/modules/raddb/pages/mods-available/rest.adoc +++ b/doc/antora/modules/raddb/pages/mods-available/rest.adoc @@ -204,7 +204,7 @@ The values of those attributes should be in the format: : `control.REST-HTTP-Header` attributes will be consumed after each call -to the rest module, and each `%{rest:}` expansion. +to the rest module, and each `%(rest:)` expansion. @@ -426,6 +426,30 @@ or increase lifetime/idle_timeout. ==== +## xlat for REST calls + +An xlat based on the instance name can be used to perform REST calls. + +.Example + +``` +%(rest:http://www.example.com/) +%(rest:GET https://www.example.com/user/%{User-Name}) +%(rest:POST https://www.example.com/auth/%{User-Name} %{Called-Station-Id}) +``` + +The xlat takes up to 3 arguments: + + * Method + * URI + * Body data + +If only one argument is presented, that will be taken as the URI and the +method will default to GET. + +Any attributes in the xlat read from the request packet will be escaped. + + == Default Configuration ``` diff --git a/raddb/mods-available/json b/raddb/mods-available/json index fa15768f371..c6c2fd88486 100644 --- a/raddb/mods-available/json +++ b/raddb/mods-available/json @@ -67,7 +67,7 @@ # # [source, unlang] # ---- -# map json "%{rest:GET http://example.org/api/user/%{User-Name}" { +# map json "%(rest:GET http://example.org/api/user/%{User-Name})" { # &Tmp-Integer-0 := '$.account number' # &Group += '$.groups.*' # } diff --git a/raddb/mods-available/rest b/raddb/mods-available/rest index 9613ec73903..340619f2c87 100644 --- a/raddb/mods-available/rest +++ b/raddb/mods-available/rest @@ -225,7 +225,7 @@ rest { # : # # `control.REST-HTTP-Header` attributes will be consumed after each call - # to the rest module, and each `%{rest:}` expansion. + # to the rest module, and each `%(rest:)` expansion. # # diff --git a/scripts/ci/openresty/json-api.lua b/scripts/ci/openresty/json-api.lua index add2da15a60..96a2429ecad 100644 --- a/scripts/ci/openresty/json-api.lua +++ b/scripts/ci/openresty/json-api.lua @@ -64,6 +64,10 @@ function Api.endpoint(method, path, callback) -- If chunk contains if string.find(k, "<(.-)>") then + if not splitReqPath[i] then + reqPath = origPath + return false + end -- Add to keyData keyData[string.match(k, "%<(%a+)%>")] = splitReqPath[i] -- Replace matches with default for validation @@ -131,3 +135,11 @@ Api.endpoint('GET', '/user//mac/', end ) +-- Simple reflection of a URI argument +Api.endpoint('GET', '/user//reflect/', + function(body, keyData) + local returnData = {} + returnData["station"] = uriArgs.station + return ngx.say(cjson.encode(returnData)) + end +) diff --git a/src/lib/unlang/xlat.h b/src/lib/unlang/xlat.h index b6cdf4c6055..4b3b3948e1d 100644 --- a/src/lib/unlang/xlat.h +++ b/src/lib/unlang/xlat.h @@ -126,6 +126,21 @@ typedef struct { #define XLAT_ARG_PARSER_TERMINATOR { .required = false, .concat = false, .single = false, .variadic = false, \ .type = FR_TYPE_NULL, .func = NULL, .uctx = NULL } +/** Definition for a single part of a URI + * + */ +typedef struct { + char const *name; //!< Name of this part of the URI + fr_sbuff_term_t const *terminals; //!< Characters that mark the end of this part. + uint8_t const part_adv[UINT8_MAX + 1]; //!< How many parts to advance for a specific terminal + size_t extra_skip; //!< How many additional characters to skip after + ///< the terminal + bool tainted_allowed; //!< Do we accept tainted values for this part + xlat_escape_func_t func; //!< Function to use to escape tainted values +} xlat_uri_part_t; + +#define XLAT_URI_PART_TERMINATOR { .name = NULL, .terminals = NULL, .tainted_allowed = false, .func = NULL } + /** A callback when the the timeout occurs * * Used when a xlat needs wait for an event. @@ -328,6 +343,8 @@ int xlat_eval_pair(request_t *request, fr_pair_t *vp); bool xlat_async_required(xlat_exp_t const *xlat); +int xlat_parse_uri(request_t *request, fr_value_box_list_t *uri, xlat_uri_part_t const *uri_parts, void *uctx); + ssize_t xlat_tokenize_ephemeral(TALLOC_CTX *ctx, xlat_exp_t **head, xlat_flags_t *flags, fr_sbuff_t *in, fr_sbuff_parse_rules_t const *p_rules, tmpl_rules_t const *t_rules); diff --git a/src/lib/unlang/xlat_eval.c b/src/lib/unlang/xlat_eval.c index 284cdbd9914..357f76363cb 100644 --- a/src/lib/unlang/xlat_eval.c +++ b/src/lib/unlang/xlat_eval.c @@ -2216,3 +2216,84 @@ bool xlat_async_required(xlat_exp_t const *xlat) return false; } + + +/** Parse a list of value boxes representing a URI + * + * Reads a URI from a list of value boxes and parses it according to the + * definition in uri_parts. Tainted values, where allowed, are escaped + * using the function specified for the uri part. + * + * @param request currently being processed + * @param uri to parse + * @param uri_parts definition of URI structure + * @param uctx to pass to escaping function + * @return + * - 0 on success + * - -1 on failure + */ +int xlat_parse_uri(request_t *request, fr_value_box_list_t *uri, xlat_uri_part_t const *uri_parts, void *uctx) +{ + fr_value_box_t *uri_vb = NULL; + xlat_uri_part_t const *uri_part; + fr_sbuff_t sbuff; + char const *p; + + uri_part = uri_parts; + + while ((uri_vb = fr_dlist_next(uri, uri_vb))){ + if (uri_vb->tainted && !uri_part->tainted_allowed) { + REDEBUG("Tainted value not allowed for %s", uri_part->name); + return -1; + } + + /* + * Tainted boxes can only belong to a single part of the URI + */ + if (uri_vb->tainted) { + if ((uri_part->func) && (uri_part->func(request, uri_vb, uctx) < 0)) { + REDEBUG("Unable to escape tainted input %pV", uri_vb); + return -1; + } + continue; + } + + /* + * This URI part has no term chars - so no need to look for them + */ + if (!uri_part->terminals) continue; + + /* + * Zero length box - no terminators here + */ + if (uri_vb->length == 0) continue; + + /* + * Look for URI part terminator + */ + fr_sbuff_init(&sbuff, uri_vb->vb_strvalue, uri_vb->length); + + do { + fr_sbuff_adv_until(&sbuff, SIZE_MAX, uri_part->terminals, '\0'); + p = fr_sbuff_current(&sbuff); + + /* + * We've not found a terminal in the current box + */ + if (uri_part->part_adv[(uint8_t)*p] == 0) continue; + + /* + * This terminator has trailing characters to skip + */ + if (uri_part->extra_skip) fr_sbuff_advance(&sbuff, uri_part->extra_skip); + + /* + * Move to the next part + */ + uri_part += uri_part->part_adv[(uint8_t)*p]; + if (!uri_part->terminals) break; + } while (fr_sbuff_advance(&sbuff, 1) > 0); + } + + return 0; +} diff --git a/src/modules/rlm_rest/rlm_rest.c b/src/modules/rlm_rest/rlm_rest.c index 215f77d2a50..20790d98d39 100644 --- a/src/modules/rlm_rest/rlm_rest.c +++ b/src/modules/rlm_rest/rlm_rest.c @@ -169,6 +169,7 @@ static int rlm_rest_status_update(request_t *request, void *handle) if (!code) { pair_delete_request(attr_rest_http_status_code); RDEBUG2("&request.REST-HTTP-Status-Code !* ANY"); + REXDENT(); return -1; } @@ -292,16 +293,78 @@ finish: return xa; } +/** URL escape a single box forming part of a URL + * + * @param request being processed + * @param vb to escape + * @param uctx context containing CURL handle + * @return + * - 0 on success + * - -1 on failure + */ +static int uri_part_escape(request_t *request, fr_value_box_t *vb, void *uctx) +{ + char *escaped; + fr_curl_io_request_t *randle = talloc_get_type_abort(uctx, fr_curl_io_request_t); + fr_dlist_t entry; + + escaped = curl_easy_escape(randle->candle, vb->vb_strvalue, vb->length); + if (!escaped) return -1; + + /* + * Returned string the same length - nothing changed + */ + if (strlen(escaped) == vb->length) { + RDEBUG4("Tainted value %pV needed no escaping", vb); + curl_free(escaped); + return 0; + } + + RDEBUG4("Tainted value %pV escaped to %s", vb, escaped); + /* + * Store list pointers to restore later - fr_value_box_clear() clears them + */ + entry = vb->entry; + + fr_value_box_clear(vb); + + fr_value_box_strdup(vb, vb, NULL, escaped, vb->tainted); + vb->entry.next = entry.next; + vb->entry.prev = entry.prev; + + curl_free(escaped); + + return 0; +} + +static xlat_uri_part_t const rest_uri_parts[] = { + { .name = "scheme", .terminals = &FR_SBUFF_TERMS(L(":")), .part_adv = { [':'] = 1 }, + .tainted_allowed = false, .extra_skip = 2 }, + { .name = "host", .terminals = &FR_SBUFF_TERMS(L(":"), L("/")), .part_adv = { [':'] = 1, ['/'] = 2 }, + .tainted_allowed = true, .func = uri_part_escape }, + { .name = "port", .terminals = &FR_SBUFF_TERMS(L("/")), .part_adv = { ['/'] = 1 }, + .tainted_allowed = false }, + { .name = "method", .terminals = &FR_SBUFF_TERMS(L("?")), .part_adv = { ['?'] = 1 }, + .tainted_allowed = true, .func = uri_part_escape }, + { .name = "param", .tainted_allowed = true, .func = uri_part_escape }, + XLAT_URI_PART_TERMINATOR +}; + +static xlat_arg_parser_t const rest_xlat_args[] = { + { .required = true, .variadic = true, .type = FR_TYPE_STRING }, + XLAT_ARG_PARSER_TERMINATOR +}; + /** Simple xlat to read text data from a URL * * Example: @verbatim -%{rest:http://example.com/} +%(rest:http://example.com/) @endverbatim * * @ingroup xlat_functions */ -static xlat_action_t rest_xlat(TALLOC_CTX *ctx, UNUSED fr_dcursor_t *out, +static xlat_action_t rest_xlat(UNUSED TALLOC_CTX *ctx, UNUSED fr_dcursor_t *out, request_t *request, UNUSED void const *xlat_inst, void *xlat_thread_inst, fr_value_box_list_t *in) { @@ -310,28 +373,14 @@ static xlat_action_t rest_xlat(TALLOC_CTX *ctx, UNUSED fr_dcursor_t *out, rlm_rest_thread_t *t = xti->t; fr_curl_io_request_t *randle = NULL; - ssize_t len; int ret; - char *uri = NULL; - char const *p = NULL, *q; http_method_t method; - fr_value_box_t *in_head = fr_dlist_head(in); + fr_value_box_t *in_vb = fr_dlist_pop_head(in), *uri_vb = NULL; /* There are no configurable parameters other than the URI */ rlm_rest_xlat_rctx_t *rctx; rlm_rest_section_t *section; - if (!in_head) { - REDEBUG("Got empty URL string"); - return XLAT_ACTION_FAIL; - } - - if (fr_value_box_list_concat(ctx, in_head, in, FR_TYPE_STRING, true) < 0) { - REDEBUG("Failed concatenating arguments into URL string"); - return XLAT_ACTION_FAIL; - } - p = in_head->vb_strvalue; - MEM(rctx = talloc(request, rlm_rest_xlat_rctx_t)); section = &rctx->section; @@ -340,48 +389,58 @@ static xlat_action_t rest_xlat(TALLOC_CTX *ctx, UNUSED fr_dcursor_t *out, */ memcpy(&rctx->section, &mod_inst->xlat, sizeof(*section)); - RDEBUG2("Expanding URI components"); + fr_assert(in_vb->type == FR_TYPE_GROUP); /* - * Extract the method from the start of the format string (if there is one) + * If we have more than 1 argument, then the first is the method */ - method = fr_table_value_by_substr(http_method_table, p, -1, REST_HTTP_METHOD_UNKNOWN); - if (method != REST_HTTP_METHOD_UNKNOWN) { - section->method = method; - p += http_method_table[method].name.len; - /* - * If the method is unknown, it's either a URL or a verb - */ - } else { - for (q = p; (*q != ' ') && (*q != '\0') && isalpha(*q); q++); + if ((fr_dlist_head(in))) { + uri_vb = fr_dlist_head(&in_vb->vb_group); + if (fr_value_box_list_concat(uri_vb, uri_vb, &in_vb->vb_group, FR_TYPE_STRING, true) < 0) { + REDEBUG("Failed concatenating argument"); + return XLAT_ACTION_FAIL; + } + method = fr_table_value_by_substr(http_method_table, uri_vb->vb_strvalue, -1, REST_HTTP_METHOD_UNKNOWN); + if (method != REST_HTTP_METHOD_UNKNOWN) { + section->method = method; /* - * If the first non-alpha char was a space, - * then assume this is a verb. - */ - if ((*q == ' ') && (q != p)) { - section->method = REST_HTTP_METHOD_CUSTOM; - MEM(section->method_str = talloc_bstrndup(rctx, p, q - p)); - p = q; + * If the method is unknown, it's a custom verb + */ } else { - section->method = REST_HTTP_METHOD_GET; + section->method = REST_HTTP_METHOD_CUSTOM; + MEM(section->method_str = talloc_bstrndup(rctx, in_vb->vb_strvalue, in_vb->vb_length)); } + /* + * Move to next argument + */ + in_vb = fr_dlist_pop_head(in); + uri_vb = NULL; + } else { + section->method = REST_HTTP_METHOD_GET; } /* - * Trim whitespace + * We get a connection from the pool here as the CURL object + * is needed to use curl_easy_escape() for escaping */ - fr_skip_whitespace(p); - randle = rctx->handle = fr_pool_connection_get(t->pool, request); if (!randle) return XLAT_ACTION_FAIL; /* - * Unescape parts of xlat'd URI, this allows REST servers to be specified by - * request attributes. + * Walk the incomming boxes, assessing where each is in the URI, + * escaping tainted ones where needed. Following each space in the + * input a new VB group is started. */ - len = rest_uri_host_unescape(&uri, mod_inst, request, randle, p); - if (len <= 0) { + + fr_assert(in_vb->type == FR_TYPE_GROUP); + + if (xlat_parse_uri(request, &in_vb->vb_group, rest_uri_parts, randle) < 0) return XLAT_ACTION_FAIL; + + uri_vb = fr_dlist_head(&in_vb->vb_group); + + if (fr_value_box_list_concat(uri_vb, uri_vb, &in_vb->vb_group, FR_TYPE_STRING, true) < 0) { + REDEBUG("Failed to concatenate URI"); error: rest_request_cleanup(mod_inst, randle); fr_pool_connection_release(t->pool, request, randle); @@ -391,18 +450,21 @@ static xlat_action_t rest_xlat(TALLOC_CTX *ctx, UNUSED fr_dcursor_t *out, } /* - * Extract freeform body data (url can't contain spaces) + * Any additional arguments are freeform data */ - q = strchr(p, ' '); - if (q && (*++q != '\0')) { + if ((in_vb = fr_dlist_head(in))) { + if (fr_value_box_list_concat(in_vb, in_vb, in, FR_TYPE_STRING, true) < 0) { + REDEBUG("Failed to concatenate freeform data"); + goto error; + } section->body = REST_HTTP_BODY_CUSTOM_LITERAL; - section->data = q; + section->data = in_vb->vb_strvalue; } - RDEBUG2("Sending HTTP %s to \"%s\"", + RDEBUG2("Sending HTTP %s to \"%pV\"", (section->method == REST_HTTP_METHOD_CUSTOM) ? section->method_str : fr_table_str_by_value(http_method_table, section->method, NULL), - uri); + uri_vb); /* * Configure various CURL options, and initialise the read/write @@ -410,9 +472,8 @@ static xlat_action_t rest_xlat(TALLOC_CTX *ctx, UNUSED fr_dcursor_t *out, * * @todo We could extract the User-Name and password from the URL string. */ - ret = rest_request_config(mod_inst, t, section, request, - randle, section->method, section->body, uri, NULL, NULL); - talloc_free(uri); + ret = rest_request_config(mod_inst, t, section, request, randle, section->method, + section->body, uri_vb->vb_strvalue, NULL, NULL); if (ret < 0) goto error; /* @@ -1116,13 +1177,14 @@ static int mod_instantiate(void *instance, CONF_SECTION *conf) static int mod_bootstrap(void *instance, CONF_SECTION *conf) { - rlm_rest_t *inst = instance; - xlat_t const *xlat; + rlm_rest_t *inst = instance; + xlat_t *xlat; inst->xlat_name = cf_section_name2(conf); if (!inst->xlat_name) inst->xlat_name = cf_section_name1(conf); xlat = xlat_register(inst, inst->xlat_name, rest_xlat, true); + xlat_func_args(xlat, rest_xlat_args); xlat_async_thread_instantiate_set(xlat, mod_xlat_thread_instantiate, rest_xlat_thread_inst_t, NULL, inst); return 0; diff --git a/src/tests/modules/rest/rest_xlat.attrs b/src/tests/modules/rest/rest_xlat.attrs index 14c4cc8ec64..ac7da710bdb 100644 --- a/src/tests/modules/rest/rest_xlat.attrs +++ b/src/tests/modules/rest/rest_xlat.attrs @@ -6,6 +6,10 @@ User-Name = 'Bob' User-Password = 'Saget' Called-Station-Id = 'aa:bb:cc:dd:ee:ff' NAS-IP-Address = '192.168.1.1' +Login-IP-Host = 127.0.0.1 +NAS-Port = 8080 +Calling-Station-Id = 'dummy&unsafe=escaped' +Tmp-String-9 = '' # # Expected answer diff --git a/src/tests/modules/rest/rest_xlat.unlang b/src/tests/modules/rest/rest_xlat.unlang index 30a6ff95b84..d455f13f7c9 100644 --- a/src/tests/modules/rest/rest_xlat.unlang +++ b/src/tests/modules/rest/rest_xlat.unlang @@ -11,7 +11,7 @@ update request { # Retrieve a plain text file update control { - &Tmp-String-1 := "%{rest:GET http://%{Tmp-String-0}:%{Tmp-Integer-0}/test.txt}" + &Tmp-String-1 := "%(rest:GET http://%{Tmp-String-0}:%{Tmp-Integer-0}/test.txt)" } if (&REST-HTTP-Status-Code != 200) { @@ -22,9 +22,27 @@ if (&control.Tmp-String-1 != "Sample text response\n") { test_fail } +# Take host from incomming packet +update control { + &Tmp-String-1 := "%(rest:http://%{Login-IP-Host}:%{Tmp-Integer-0}/test.txt)" +} + +if ((&REST-HTTP-Status-Code != 200) || (&control.Tmp-String-1 != "Sample text response\n")) { + test_fail +} + +# Port is not allowed from incomming packet +update control { + &Tmp-String-1 := "%(rest:http://%{Tmp-String-0}:%{NAS-Port}/test.txt)" +} + +if ((&Module-Failure-Message != "Tainted value not allowed for port") || (&control.Tmp-String-1 != "")) { + test_fail +} + # Check a "not found" gives a 404 status code update control { - &Tmp-String-1 := "%{rest:GET http://%{Tmp-String-0}:%{Tmp-Integer-0}/%{Tmp-String-1}}" + &Tmp-String-1 := "%(rest:GET http://%{Tmp-String-0}:%{Tmp-Integer-0}/%{Tmp-String-1})" } if (&REST-HTTP-Status-Code != 404) { @@ -33,7 +51,7 @@ if (&REST-HTTP-Status-Code != 404) { # GET with URL parameters update request { - &Tmp-String-2 := "%{rest:GET http://%{Tmp-String-0}:%{Tmp-Integer-0}/user/%{User-Name}/mac/%{Called-Station-Id}}" + &Tmp-String-2 := "%(rest:GET http://%{Tmp-String-0}:%{Tmp-Integer-0}/user/%{User-Name}/mac/%{Called-Station-Id})" } if (&REST-HTTP-Status-Code != 200) { @@ -58,7 +76,7 @@ update control { } # Directly use json map and prepend the returned value -map json "%{rest:GET http://%{Tmp-String-0}:%{Tmp-Integer-0}/user/%{User-Name}/mac/%{Called-Station-Id}}" { +map json "%(rest:GET http://%{Tmp-String-0}:%{Tmp-Integer-0}/user/%{User-Name}/mac/%{Called-Station-Id})" { &control.Tmp-String-3 ^= '$.control\.User-Name.value' } @@ -72,7 +90,7 @@ update control { # POST to https with JSON body data update request { - &Tmp-String-2 := "%{rest:POST https://%{Tmp-String-0}:%{Tmp-Integer-1}/user/%{User-Name}/mac/%{Called-Station-Id}?section=accounting %{control.Tmp-String-2}}" + &Tmp-String-2 := "%(rest:POST https://%{Tmp-String-0}:%{Tmp-Integer-1}/user/%{User-Name}/mac/%{Called-Station-Id}?section=accounting %{control.Tmp-String-2})" } if (&REST-HTTP-Status-Code != 200) { @@ -103,7 +121,7 @@ update control { # POST to https with POST body data update request { - &Tmp-String-2 := "%{rest:POST https://%{Tmp-String-0}:%{Tmp-Integer-1}/post/test?section=dummy %{control.Tmp-String-2}}" + &Tmp-String-2 := "%(rest:POST https://%{Tmp-String-0}:%{Tmp-Integer-1}/post/test?section=dummy %{control.Tmp-String-2})" } if (&REST-HTTP-Status-Code != 200) { @@ -114,4 +132,36 @@ if (&Tmp-String-2 != "Section: dummy, User: Bob\n") { test_fail } +# URI with tainted values in the arguments - input argument includes URI argument +# separator - make sure this doesn't end up generating extra arguments, but gets escaped. +update request { + &Tmp-String-2 := "%(rest:GET http://%{Tmp-String-0}:%{Tmp-Integer-0}/user/%{User-Name}/reflect/?station=%{Calling-Station-Id})" +} + +if (&Tmp-String-2 != "{\"station\":\"dummy&unsafe=escaped\"}\n" ) { + test_fail +} + +# Zero length untainted value - check parsing doesn't break on zero length string +update request { + &Tmp-String-8 := "" +} + +update request { + &Tmp-String-2 := "%(rest:http://%{Tmp-String-0}:%{Tmp-Integer-0}/user/%{User-Name}/reflect/%{Tmp-String-8}?station=%{User-Name})" +} + +if (&Tmp-String-2 != "{\"station\":\"Bob\"}\n" ) { + test_fail +} + +# Zero length tainted value - check escaping doesn't break on zero length string +update request { + &Tmp-String-2 := "%(rest:http://%{Tmp-String-0}:%{Tmp-Integer-0}/user/%{User-Name}/reflect/%{Tmp-String-9}?station=%{Called-Station-Id})" +} + +if (&Tmp-String-2 != "{\"station\":\"aa:bb:cc:dd:ee:ff\"}\n" ) { + test_fail +} + test_pass