diff --git a/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/Laminas33Tests.groovy b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/Laminas33Tests.groovy index dd365c15c0b..f9e5e443f6e 100644 --- a/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/Laminas33Tests.groovy +++ b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/Laminas33Tests.groovy @@ -81,7 +81,7 @@ class Laminas33Tests { endpoints.size() > 0 }) - assert endpoints.size() == 26 + assert endpoints.size() == 37 assert endpoints.find { it.path == '/' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /' } != null assert endpoints.find { it.path == '/application[/:action]' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /application[/:action]' @@ -124,6 +124,56 @@ class Laminas33Tests { assert endpoints.find { it.path == '/any-verb' && it.method == '*' && it.operationName == 'http.request' && it.resourceName == '* /any-verb' } != null + assert endpoints.find { + it.path == '/normalized-regex/%id%.%format%' && it.method == '*' && + it.operationName == 'http.request' && it.resourceName == '* /normalized-regex/%id%.%format%' + } != null + assert endpoints.find { + it.path == '/normalized-regex-ambiguous/%name%.%ext%' && + it.method == '*' && it.operationName == 'http.request' && + it.resourceName == + '* /normalized-regex-ambiguous/%name%.%ext%' + } != null + assert endpoints.find { + it.path == '/normalized-encoded[/:slug]' && it.method == '*' && + it.operationName == 'http.request' && it.resourceName == '* /normalized-encoded[/:slug]' + } != null + assert endpoints.find { + it.path == '/normalized-static[/draft]' && it.method == '*' && + it.operationName == 'http.request' && it.resourceName == '* /normalized-static[/draft]' + } != null + assert endpoints.find { + it.path == '/normalized-static-prefix[/normalized]' && it.method == '*' && + it.operationName == 'http.request' && + it.resourceName == '* /normalized-static-prefix[/normalized]' + } != null + assert endpoints.find { + it.path == '/normalized-dynamic-prefix[/:value]' && it.method == '*' && + it.operationName == 'http.request' && + it.resourceName == '* /normalized-dynamic-prefix[/:value]' + } != null + assert endpoints.find { + it.path == '/normalized-encoded-cache[/:slug]' && it.method == '*' && + it.operationName == 'http.request' && + it.resourceName == '* /normalized-encoded-cache[/:slug]' + } != null + assert endpoints.find { + it.path == '/normalized-encoded-lowercase[/:slug]' && it.method == '*' && + it.operationName == 'http.request' && + it.resourceName == '* /normalized-encoded-lowercase[/:slug]' + } != null + assert endpoints.find { + it.path == '/normalized-name/:user-id' && it.method == '*' && + it.operationName == 'http.request' && it.resourceName == '* /normalized-name/:user-id' + } != null + assert endpoints.find { + it.path == '/normalized-wildcard/:param1' && it.method == '*' && + it.operationName == 'http.request' && it.resourceName == '* /normalized-wildcard/:param1' + } != null + assert endpoints.find { + it.path == '/normalized-wildcard/:param1/*' && it.method == '*' && + it.operationName == 'http.request' && it.resourceName == '* /normalized-wildcard/:param1/*' + } != null } @Test @@ -231,6 +281,7 @@ class Laminas33Tests { assert span.meta.'_dd.appsec.event_rules.version' != '' assert span.meta.'appsec.blocked' == 'true' assert span.meta.'http.route' == '/dynamic-path[/:param01]' + assert span.meta.'_dd.appsec.normalized_route' == '/dynamic-path/{param01}' } @Test @@ -241,12 +292,14 @@ class Laminas33Tests { assert resp.statusCode() == 200 } assert nestedTrace.first().meta.'http.route' == '/resource/:resourceId/:subId' + assert nestedTrace.first().meta.'_dd.appsec.normalized_route' == '/resource/{resourceId}/{subId}' HttpRequest chainReq = container.buildReq('/chain/abc').GET().build() Trace chainTrace = container.traceFromRequest(chainReq, ofString()) { HttpResponse resp -> assert resp.statusCode() == 200 } assert chainTrace.first().meta.'http.route' == '/chain/:chainId' + assert chainTrace.first().meta.'_dd.appsec.normalized_route' == '/chain/{chainId}' } @Test @@ -271,6 +324,7 @@ class Laminas33Tests { assert resp.statusCode() == 200 } assert regexTrace.first().meta.'http.route' == '/regex-year/%year%' + assert regexTrace.first().meta.'_dd.appsec.normalized_route' == '/regex-year/{year}' Trace schemeTrace = container.traceFromRequest( container.buildReq('/scheme-only-page').GET().build(), @@ -278,6 +332,7 @@ class Laminas33Tests { assert resp.statusCode() == 200 } assert schemeTrace.first().meta.'http.route' == '/scheme-only-page' + assert schemeTrace.first().meta.'_dd.appsec.normalized_route' == '/scheme-only-page' Trace placeholderTrace = container.traceFromRequest( container.buildReq('/placeholder-literal').GET().build(), @@ -285,6 +340,7 @@ class Laminas33Tests { assert resp.statusCode() == 200 } assert placeholderTrace.first().meta.'http.route' == '/placeholder-literal' + assert placeholderTrace.first().meta.'_dd.appsec.normalized_route' == '/placeholder-literal' Trace wildcardTrace = container.traceFromRequest( container.buildReq('/wildcard-keys/foo/bar').GET().build(), @@ -292,5 +348,239 @@ class Laminas33Tests { assert resp.statusCode() == 200 } assert wildcardTrace.first().meta.'http.route' == '/wildcard-keys/*' + assert wildcardTrace.first().meta.'_dd.appsec.normalized_route' == '/wildcard-keys/{param1}' } + + @Test + @Order(11) + void 'optional segment absent produces correct normalized route'() { + // /application[/:action] with no action in URL — optional section dropped + // (default action=index is injected by the router but /index is not in the URL path) + Trace trace = container.traceFromRequest( + container.buildReq('/application').GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + } + assert trace.first().meta.'http.route' == '/application[/:action]' + assert trace.first().meta.'_dd.appsec.normalized_route' == '/application' + } + + @Test + @Order(12) + void 'optional segment present produces correct normalized route'() { + // /application[/:action] with action in URL — optional section expanded + Trace trace = container.traceFromRequest( + container.buildReq('/application/hello').GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + } + assert trace.first().meta.'http.route' == '/application[/:action]' + assert trace.first().meta.'_dd.appsec.normalized_route' == '/application/{action}' + } + + @Test + @Order(13) + void 'optional regex capture absent is omitted from normalized route'() { + Trace trace = container.traceFromRequest( + container.buildReq('/normalized-regex/article').GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + } + + assert trace.first().meta.'http.route' == '/normalized-regex/%id%.%format%' + assert trace.first().meta.'_dd.appsec.normalized_route' == '/normalized-regex/{id}' + } + + @Test + @Order(14) + void 'encoded optional value is recognized as present'() { + Trace trace = container.traceFromRequest( + container.buildReq('/normalized-encoded/a%20b').GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + } + + assert trace.first().meta.'http.route' == '/normalized-encoded[/:slug]' + assert trace.first().meta.'_dd.appsec.normalized_route' == '/normalized-encoded/{slug}' + } + + @Test + @Order(15) + void 'lowercase percent escapes retain an optional matched value'() { + Trace trace = container.traceFromRequest( + container.buildReq('/normalized-encoded-lowercase/%c3%a9').GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + } + + assert trace.first().meta.'http.route' == + '/normalized-encoded-lowercase[/:slug]' + assert trace.first().meta.'_dd.appsec.normalized_route' == + '/normalized-encoded-lowercase/{slug}' + } + + @Test + @Order(16) + void 'static optional text is matched only at its route position'() { + Trace trace = container.traceFromRequest( + container.buildReq('/normalized-static-prefix').GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + } + + assert trace.first().meta.'http.route' == + '/normalized-static-prefix[/normalized]' + // The optional suffix is absent. Its text happens to be a prefix of + // the mandatory segment and must not be detected there. + assert trace.first().meta.'_dd.appsec.normalized_route' == + '/normalized-static-prefix' + } + + @Test + @Order(17) + void 'defaulted optional value is matched only at its route position'() { + Trace trace = container.traceFromRequest( + container.buildReq('/normalized-dynamic-prefix').GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + } + + assert trace.first().meta.'http.route' == + '/normalized-dynamic-prefix[/:value]' + // The framework-injected default equals earlier static route text. It + // does not mean the optional URL segment participated in this request. + // Laminas merges defaults and captures in RouteMatch, so RFC-1103 also + // permits omitting the tag when accurate participation is unavailable. + String normalizedRoute = trace.first().meta.'_dd.appsec.normalized_route' + assert normalizedRoute == null || + normalizedRoute == '/normalized-dynamic-prefix' + } + + @Test + @Order(18) + void 'encoded optional presence is not poisoned by a prior cache shape'() { + // The lowercase request is known to be misclassified as absent. It + // primes the result cache with the absent shape; the next request has + // an uppercase encoding that normalizes correctly when run alone. + container.traceFromRequest( + container.buildReq('/normalized-encoded-cache/%c3%a9').GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + } + + Trace presentTrace = container.traceFromRequest( + container.buildReq('/normalized-encoded-cache/a%20b').GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + } + + assert presentTrace.first().meta.'http.route' == + '/normalized-encoded-cache[/:slug]' + assert presentTrace.first().meta.'_dd.appsec.normalized_route' == + '/normalized-encoded-cache/{slug}' + } + + @Test + @Order(19) + void 'static-only optional shapes do not share a cached result'() { + Trace absentTrace = container.traceFromRequest( + container.buildReq('/normalized-static').GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + } + + assert absentTrace.first().meta.'http.route' == + '/normalized-static[/draft]' + assert absentTrace.first().meta.'_dd.appsec.normalized_route' == + '/normalized-static' + + Trace presentTrace = container.traceFromRequest( + container.buildReq('/normalized-static/draft').GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + } + + assert presentTrace.first().meta.'http.route' == + '/normalized-static[/draft]' + // The cache suffix contains only optional parameter names. This route's + // optional group is purely static, so absent and present both use the + // same key even though they require different normalized results. + assert presentTrace.first().meta.'_dd.appsec.normalized_route' == + '/normalized-static/draft' + } + + @Test + @Order(20) + void 'hyphenated segment parameter name remains intact'() { + Trace trace = container.traceFromRequest( + container.buildReq('/normalized-name/alice').GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + } + + assert trace.first().meta.'http.route' == '/normalized-name/:user-id' + assert trace.first().meta.'_dd.appsec.normalized_route' == '/normalized-name/{user-id}' + } + + @Test + @Order(21) + void 'wildcard placeholder does not collide with an existing parameter name'() { + Trace trace = container.traceFromRequest( + container.buildReq('/normalized-wildcard/value/foo/bar').GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + } + + assert trace.first().meta.'http.route' == '/normalized-wildcard/:param1/*' + assert trace.first().meta.'_dd.appsec.normalized_route' == + '/normalized-wildcard/{param1}/{param2}' + } + + @Test + @Order(22) + void 'Regex constraints distinguish an absent defaulted parameter'() { + Trace trace = container.traceFromRequest( + container.buildReq('/normalized-regex-ambiguous/report.txt') + .GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + assert resp.body() == 'report.txt/html' + } + + assert trace.first().meta.'http.route' == + '/normalized-regex-ambiguous/%name%.%ext%' + // The route regex accepts only pdf or json as ext, so report.txt is + // consumed entirely by name and ext comes only from its html default. + // Generic URL inference ignores that regex and treats txt as matched. + assert trace.first().meta.'_dd.appsec.normalized_route' == + '/normalized-regex-ambiguous/{name}' + } + + @Test + @Order(23) + void 'normalized route is absent when API Security is disabled'() { + try { + def res = CONTAINER.execInContainer( + 'bash', '-c', + '''echo export DD_API_SECURITY_ENABLED=false >> /etc/apache2/envvars; + service apache2 restart''') + assert res.exitCode == 0 + + Trace trace = container.traceFromRequest( + container.buildReq('/application').GET().build(), + ofString()) { HttpResponse resp -> + assert resp.statusCode() == 200 + } + + assert trace.first().meta.'http.route' == '/application[/:action]' + assert trace.first().meta.'_dd.appsec.normalized_route' == null + } finally { + def res = CONTAINER.execInContainer( + 'bash', '-c', + '''sed -i '/export DD_API_SECURITY_ENABLED=/d' /etc/apache2/envvars; + service apache2 restart''') + assert res.exitCode == 0 + } + } + } diff --git a/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/Laravel8xTests.groovy b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/Laravel8xTests.groovy index 16e182d17cd..77bdab25f15 100644 --- a/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/Laravel8xTests.groovy +++ b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/Laravel8xTests.groovy @@ -170,6 +170,10 @@ class Laravel8xTests { assert span.metrics."_dd.appsec.waf.duration" > 0.0d assert span.meta."_dd.appsec.event_rules.version" != '' assert span.meta."appsec.blocked" == "true" + // Laravel uri() returns the route without a leading slash + assert span.meta."http.route" == 'dynamic-path/{param01}' + // Normalizer adds the leading slash and keeps {param01} as-is + assert span.meta."_dd.appsec.normalized_route" == '/dynamic-path/{param01}' } @Test @@ -208,11 +212,109 @@ class Laravel8xTests { endpoints.size() > 0 }) - assert endpoints.size() == 27 + assert endpoints.size() == 30 assert endpoints.find { it.path == '/' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /' } != null assert endpoints.find { it.path == 'login/auth' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET login/auth' } != null assert endpoints.find { it.path == 'login/signup' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET login/signup' } != null assert endpoints.find { it.path == 'dynamic-path/{param01}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET dynamic-path/{param01}' } != null assert endpoints.find { it.path == 'api/user' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET api/user' } != null + assert endpoints.find { it.path == 'normalized-optional/{value?}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET normalized-optional/{value?}' } != null + assert endpoints.find { it.path == 'normalized-default/{format?}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET normalized-default/{format?}' } != null + assert endpoints.find { + it.path == 'normalized-ambiguous/{name}.{ext?}' && it.method == 'GET' && + it.operationName == 'http.request' && + it.resourceName == 'GET normalized-ambiguous/{name}.{ext?}' + } != null + } + + @Test + @Order(10) + void 'optional param present produces correct normalized route'() { + HttpRequest req = container.buildReq('/normalized-optional/hello').GET().build() + Trace trace = container.traceFromRequest(req, ofString()) { HttpResponse re -> + assert re.statusCode() == 200 + assert re.body() == 'hello' + } + + Span span = trace.first() + assert span.meta.'http.route' == 'normalized-optional/{value?}' + assert span.meta.'_dd.appsec.normalized_route' == '/normalized-optional/{value}' + } + + @Test + @Order(11) + void 'optional param absent produces correct normalized route'() { + HttpRequest req = container.buildReq('/normalized-optional').GET().build() + Trace trace = container.traceFromRequest(req, ofString()) { HttpResponse re -> + assert re.statusCode() == 200 + assert re.body() == 'absent' + } + + Span span = trace.first() + assert span.meta.'http.route' == 'normalized-optional/{value?}' + assert span.meta.'_dd.appsec.normalized_route' == '/normalized-optional' + } + + @Test + @Order(12) + void 'defaulted optional absent from URL produces normalized route without the param'() { + // The route uses ->defaults('format', 'html'). When the URL has no {format?} segment, + // Laravel injects 'html' into $route->parameters() — but the param is absent from the URL. + // The normalized route must not include {format} in this case. + HttpRequest req = container.buildReq('/normalized-default').GET().build() + Trace trace = container.traceFromRequest(req, ofString()) { HttpResponse re -> + assert re.statusCode() == 200 + assert re.body() == 'html' + } + + Span span = trace.first() + assert span.meta.'http.route' == 'normalized-default/{format?}' + assert span.meta.'_dd.appsec.normalized_route' == '/normalized-default' + } + + @Test + @Order(13) + void 'route requirements distinguish an absent defaulted mixed parameter'() { + HttpRequest req = container.buildReq('/normalized-ambiguous/report.txt').GET().build() + Trace trace = container.traceFromRequest(req, ofString()) { HttpResponse re -> + assert re.statusCode() == 200 + assert re.body() == 'report.txt/html' + } + + Span span = trace.first() + assert span.meta.'http.route' == + 'normalized-ambiguous/{name}.{ext?}' + // Laravel matched all of "report.txt" as name because ext only accepts + // pdf or json, then supplied the default ext. The integration ignores + // those requirements and infers ext participation from the dot alone. + assert span.meta.'_dd.appsec.normalized_route' == + '/normalized-ambiguous/{name}' + } + + @Test + @Order(14) + void 'normalized route is absent when API Security is disabled'() { + try { + def res = CONTAINER.execInContainer( + 'bash', '-c', + '''echo export DD_API_SECURITY_ENABLED=false >> /etc/apache2/envvars; + service apache2 restart''') + assert res.exitCode == 0 + + HttpRequest req = container.buildReq('/normalized-optional/hello').GET().build() + Trace trace = container.traceFromRequest(req, ofString()) { HttpResponse re -> + assert re.statusCode() == 200 + } + + Span span = trace.first() + assert span.meta.'http.route' == 'normalized-optional/{value?}' + assert span.meta.'_dd.appsec.normalized_route' == null + } finally { + def res = CONTAINER.execInContainer( + 'bash', '-c', + '''sed -i '/export DD_API_SECURITY_ENABLED=/d' /etc/apache2/envvars; + service apache2 restart''') + assert res.exitCode == 0 + } } } diff --git a/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/Symfony62Tests.groovy b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/Symfony62Tests.groovy index f933c5c3c00..93aa09d2e70 100644 --- a/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/Symfony62Tests.groovy +++ b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/Symfony62Tests.groovy @@ -116,6 +116,7 @@ class Symfony62Tests { assert span.meta."_dd.appsec.event_rules.version" != '' assert span.meta."appsec.blocked" == "true" assert span.meta."http.route" == '/dynamic-path/{param01}' + assert span.meta."_dd.appsec.normalized_route" == '/dynamic-path/{param01}' } @Test @@ -129,6 +130,7 @@ class Symfony62Tests { Span span = trace.first() assert span.meta."http.route" == '/caminho-dinamico/{param01}' + assert span.meta."_dd.appsec.normalized_route" == '/caminho-dinamico/{param01}' } @Test @@ -141,6 +143,8 @@ class Symfony62Tests { Span span = trace.first() assert span.meta."http.route" == '/café/{item}' + // Static segment 'café' is percent-encoded per RFC 3986; é (U+00E9) → %C3%A9 + assert span.meta."_dd.appsec.normalized_route" == '/caf%C3%A9/{item}' } @Test @@ -162,6 +166,7 @@ class Symfony62Tests { Span span = trace.first() assert span.meta."http.route" == null + assert span.meta."_dd.appsec.normalized_route" == null assert span.meta."symfony.route.name" != null assert span.resource == 'app_home_dynamic' } finally { @@ -182,6 +187,8 @@ class Symfony62Tests { assert re.body().contains('are_endpoints_collected: false') } } + + @Test @Order(3) void 'Endpoints are collected after the first request to framework'() { HttpRequest req = container.buildReq('/outside_of_framework.php').GET().build() @@ -190,6 +197,8 @@ class Symfony62Tests { assert re.body().contains('are_endpoints_collected: true') } } + + @Test @Order(2) void 'Endpoints are sent'() { def trace = container.traceFromRequest('/') { HttpResponse resp -> @@ -205,7 +214,7 @@ class Symfony62Tests { endpoints.size() > 0 }) - assert endpoints.size() == 14 + assert endpoints.size() == 17 assert endpoints.find { it.path == '/' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /' } != null assert endpoints.find { it.path == '/dynamic-path/{param01}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /dynamic-path/{param01}' } != null assert endpoints.find { it.path == '/caminho-dinamico/{param01}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /caminho-dinamico/{param01}' } != null @@ -220,5 +229,166 @@ class Symfony62Tests { assert endpoints.find { it.path == '/lucky/number' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /lucky/number' } != null assert endpoints.find { it.path == '/lucky/fail' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /lucky/fail' } != null assert endpoints.find { it.path == '/_error/{code}.{_format}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /_error/{code}.{_format}' } != null + assert endpoints.find { it.path == '/article/{slug}.{_format}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /article/{slug}.{_format}' } != null + assert endpoints.find { it.path == '/café/{item}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /café/{item}' } != null + assert endpoints.find { it.path == '/posts/{page}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /posts/{page}' } != null + assert endpoints.find { + it.path == '/normalized/mixed/{id}.{_format}' && it.method == 'GET' && + it.operationName == 'http.request' && + it.resourceName == 'GET /normalized/mixed/{id}.{_format}' + } != null + assert endpoints.find { + it.path == '/normalized/zero/{id}' && it.method == 'GET' && + it.operationName == 'http.request' && + it.resourceName == 'GET /normalized/zero/{id}' + } != null + assert endpoints.find { + it.path == '/normalized/search.{_format}' && it.method == 'GET' && + it.operationName == 'http.request' && + it.resourceName == 'GET /normalized/search.{_format}' + } != null + assert endpoints.find { + it.path == '/normalized/utf8/{föo}' && it.method == 'GET' && + it.operationName == 'http.request' && + it.resourceName == 'GET /normalized/utf8/{föo}' + } != null + assert endpoints.find { + it.path == '/normalized/ambiguous/{slug}.{format}' && it.method == 'GET' && + it.operationName == 'http.request' && + it.resourceName == 'GET /normalized/ambiguous/{slug}.{format}' + } != null + } + + @Test + @Order(11) + void 'normalized route is absent when API Security is disabled'() { + try { + def res = CONTAINER.execInContainer( + 'bash', '-c', + '''echo export DD_API_SECURITY_ENABLED=false >> /etc/apache2/envvars; + service apache2 restart''') + assert res.exitCode == 0 + + Trace trace = container.traceFromRequest('/') { HttpResponse resp -> + assert resp.statusCode() == 200 + } + + Span span = trace.first() + assert span.meta.'http.route' == '/' + assert span.meta.'_dd.appsec.normalized_route' == null + } finally { + def res = CONTAINER.execInContainer( + 'bash', '-c', + '''sed -i '/export DD_API_SECURITY_ENABLED=/d' /etc/apache2/envvars; + service apache2 restart''') + assert res.exitCode == 0 + } + } + + @Test + @Order(12) + void 'mixed dynamic values in one segment are combined'() { + Trace trace = container.traceFromRequest('/normalized/mixed/article.json') { + HttpResponse resp -> + assert resp.statusCode() == 200 + } + + Span span = trace.first() + assert span.meta.'http.route' == '/normalized/mixed/{id}.{_format}' + assert span.meta.'_dd.appsec.normalized_route' == '/normalized/mixed/{id+_format}' + } + + @Test + @Order(13) + void 'zero-valued path parameter is retained'() { + Trace trace = container.traceFromRequest('/normalized/zero/0') { + HttpResponse resp -> + assert resp.statusCode() == 200 + } + + Span span = trace.first() + assert span.meta.'http.route' == '/normalized/zero/{id}' + assert span.meta.'_dd.appsec.normalized_route' == '/normalized/zero/{id}' + } + + @Test + @Order(14) + void 'static part of a segment remains when its optional parameter is absent'() { + Trace trace = container.traceFromRequest('/normalized/search') { + HttpResponse resp -> + assert resp.statusCode() == 200 + } + + Span span = trace.first() + assert span.meta.'http.route' == '/normalized/search.{_format}' + assert span.meta.'_dd.appsec.normalized_route' == '/normalized/search' + } + + @Test + @Order(15) + void 'UTF-8 optional parameter name is omitted when absent'() { + Trace trace = container.traceFromRequest('/normalized/utf8') { + HttpResponse resp -> + assert resp.statusCode() == 200 + } + + Span span = trace.first() + assert span.meta.'http.route' == '/normalized/utf8/{föo}' + assert span.meta.'_dd.appsec.normalized_route' == '/normalized/utf8' + } + + @Test + @Order(16) + void 'optional param absent: cache key does not bleed into present case'() { + // Hit /posts (page absent from URL — uses default=1) first so that if the cache key + // were just the route name, the result '/posts' would be stored and served for /posts/2. + HttpRequest absentReq = container.buildReq('/posts').GET().build() + Trace absentTrace = container.traceFromRequest(absentReq, ofString()) { HttpResponse re -> + assert re.statusCode() == 200 + } + assert absentTrace.first().meta.'http.route' == '/posts/{page}' + assert absentTrace.first().meta.'_dd.appsec.normalized_route' == '/posts' + + // Now hit /posts/2 (page present in URL). With a coarse cache key (route name only) + // this would incorrectly return '/posts' from cache instead of '/posts/{page}'. + HttpRequest presentReq = container.buildReq('/posts/2').GET().build() + Trace presentTrace = container.traceFromRequest(presentReq, ofString()) { HttpResponse re -> + assert re.statusCode() == 200 + } + assert presentTrace.first().meta.'http.route' == '/posts/{page}' + assert presentTrace.first().meta.'_dd.appsec.normalized_route' == '/posts/{page}' + } + + @Test + @Order(17) + void 'mixed segment route normalizes both params into one brace group'() { + HttpRequest req = container.buildReq('/article/my-post.html').GET().build() + Trace trace = container.traceFromRequest(req, ofString()) { HttpResponse re -> + assert re.statusCode() == 200 + assert re.body() == 'my-post.html' + } + + Span span = trace.first() + assert span.meta.'http.route' == '/article/{slug}.{_format}' + assert span.meta.'_dd.appsec.normalized_route' == '/article/{slug+_format}' + } + + @Test + @Order(18) + void 'route requirements distinguish an absent defaulted mixed parameter'() { + HttpRequest req = container.buildReq('/normalized/ambiguous/foo.bar').GET().build() + Trace trace = container.traceFromRequest(req, ofString()) { HttpResponse re -> + assert re.statusCode() == 200 + assert re.body() == 'Ambiguous mixed route: foo.bar/html' + } + + Span span = trace.first() + assert span.meta.'http.route' == + '/normalized/ambiguous/{slug}.{format}' + // Symfony matched the entire "foo.bar" value as slug and supplied + // format from its default. URL-only inference ignores the framework + // requirements and incorrectly treats "bar" as a matched format. + assert span.meta.'_dd.appsec.normalized_route' == + '/normalized/ambiguous/{slug}' } } diff --git a/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/WordPressTests.groovy b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/WordPressTests.groovy index 5ab05fc5d7b..2c4b51dc6ac 100644 --- a/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/WordPressTests.groovy +++ b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/WordPressTests.groovy @@ -3,6 +3,7 @@ package com.datadog.appsec.php.integration import com.datadog.appsec.php.docker.AppSecContainer import com.datadog.appsec.php.docker.FailOnUnmatchedTraces import com.datadog.appsec.php.docker.InspectContainerHelper +import com.datadog.appsec.php.docker.PhpFpm import com.datadog.appsec.php.model.Span import com.datadog.appsec.php.model.Trace import groovy.util.logging.Slf4j @@ -96,9 +97,15 @@ class WordPressTests { res = CONTAINER.execInContainer('bash', '-c', """export DD_TRACE_CLI_ENABLED=false DD_APPSEC_ENABLED=0 wp option update siteurl 'http://localhost:${port}' --path=/var/www/public --allow-root - wp option update home 'http://localhost:${port}' --path=/var/www/public --allow-root""") + wp option update home 'http://localhost:${port}' --path=/var/www/public --allow-root + wp rewrite structure '/%postname%/' --path=/var/www/public --allow-root + wp rewrite flush --hard --path=/var/www/public --allow-root""") assert res.exitCode == 0 : "Failed to update WordPress URLs: ${res.stderr}" + PhpFpm fpm = new PhpFpm(CONTAINER) + fpm.setPoolValue('pm.max_children', '1') + fpm.reload() + CONTAINER.clearTraces() } @@ -194,4 +201,131 @@ class WordPressTests { assert span.meta."_dd.appsec.usr.id" == "1" assert span.meta."_dd.appsec.user.collection_mode" == "identification" } + + @Test + @Order(6) + void 'static prefix remains when optional rewrite capture is absent'() { + Trace trace = CONTAINER.traceFromRequest('/normalized-cache/') { + HttpResponse response -> + assert response.statusCode() == 200 + } + Span span = trace.first() + assert span.meta.'http.route' == + '^normalized-cache(?:/([^/]+))?/?$' + assert span.meta.'_dd.appsec.normalized_route' == '/normalized-cache' + } + + @Test + @Order(7) + void 'optional rewrite capture is normalized for each request'() { + Trace absentTrace = CONTAINER.traceFromRequest('/normalized-cache-shape/') { + HttpResponse response -> + assert response.statusCode() == 200 + } + Span absentSpan = absentTrace.first() + assert absentSpan.meta.'http.route' == + '^normalized-cache-shape/?([^/]*)/?$' + assert absentSpan.meta.'_dd.appsec.normalized_route' == + '/normalized-cache-shape' + + Trace presentTrace = CONTAINER.traceFromRequest( + '/normalized-cache-shape/present/') { + HttpResponse response -> + assert response.statusCode() == 200 + } + Span presentSpan = presentTrace.first() + assert presentSpan.meta.'http.route' == + '^normalized-cache-shape/?([^/]*)/?$' + assert presentSpan.meta.'_dd.appsec.normalized_route' == + '/normalized-cache-shape/{param1}' + } + + @Test + @Order(8) + void 'escaped regex literals remain static route text'() { + Trace trace = CONTAINER.traceFromRequest('/normalized-literal/file.json/') { + HttpResponse response -> + assert response.statusCode() == 200 + } + + Span span = trace.first() + assert span.meta.'http.route' == '^normalized-literal/file\\.json$' + assert span.meta.'_dd.appsec.normalized_route' == + '/normalized-literal/file.json' + } + + @Test + @Order(9) + void 'capture participation holes do not share a cached result shape'() { + Trace absentTrace = CONTAINER.traceFromRequest( + '/normalized-capture-hole/tail/') { + HttpResponse response -> + assert response.statusCode() == 200 + } + + Span absentSpan = absentTrace.first() + assert absentSpan.meta.'http.route' == + '^normalized-capture-hole/(?:([^/]+)-)?([^/]+)$' + assert absentSpan.meta.'_dd.appsec.normalized_route' == + '/normalized-capture-hole/{param2}' + + Trace presentTrace = CONTAINER.traceFromRequest( + '/normalized-capture-hole/head-tail/') { + HttpResponse response -> + assert response.statusCode() == 200 + } + + Span presentSpan = presentTrace.first() + assert presentSpan.meta.'http.route' == + '^normalized-capture-hole/(?:([^/]+)-)?([^/]+)$' + // Both requests have capture 2 as their highest participating index, + // but only this request includes capture 1. A highest-index cache key + // serves the absent shape cached by the preceding request. + assert presentSpan.meta.'_dd.appsec.normalized_route' == + '/normalized-capture-hole/{param1+param2}' + } + + @Test + @Order(10) + void 'named regex captures are counted and retain their framework name'() { + Trace trace = CONTAINER.traceFromRequest( + '/normalized-named-captures/first-second/') { + HttpResponse response -> + assert response.statusCode() == 200 + } + + Span span = trace.first() + assert span.meta.'http.route' == + '^normalized-named-captures/(?P[^/]+)-' + + '(?P[^/]+)/?$' + // Both named captures share one URL segment and must be present in its + // combined element. RFC-1103 does not define whether a route accepting + // both terminal-slash forms should retain '/', so accept either form. + String normalizedRoute = span.meta.'_dd.appsec.normalized_route' + assert normalizedRoute == + '/normalized-named-captures/{first+second}' || + normalizedRoute == + '/normalized-named-captures/{first+second}/' + } + + @Test + @Order(11) + void 'normalized route is absent when API Security is disabled'() { + PhpFpm fpm = new PhpFpm(CONTAINER) + try { + fpm.restart(['DD_API_SECURITY_ENABLED': 'false']) + + Trace trace = CONTAINER.traceFromRequest('/normalized-cache/') { + HttpResponse response -> + assert response.statusCode() == 200 + } + + Span span = trace.first() + assert span.meta.'http.route' == + '^normalized-cache(?:/([^/]+))?/?$' + assert span.meta.'_dd.appsec.normalized_route' == null + } finally { + fpm.restart() + } + } } diff --git a/appsec/tests/integration/src/test/www/laminas33/module/Application/config/module.config.php b/appsec/tests/integration/src/test/www/laminas33/module/Application/config/module.config.php index 88fadd25ae5..f495c34ed2d 100644 --- a/appsec/tests/integration/src/test/www/laminas33/module/Application/config/module.config.php +++ b/appsec/tests/integration/src/test/www/laminas33/module/Application/config/module.config.php @@ -180,6 +180,127 @@ ], ], ], + 'regex_optional_format' => [ + 'type' => Regex::class, + 'options' => [ + 'regex' => '/normalized-regex/(?P[a-z]+)(?:\.(?P[a-z]+))?', + 'spec' => '/normalized-regex/%id%.%format%', + 'defaults' => [ + 'controller' => DynamicPathController::class, + 'action' => 'index', + 'format' => 'html', + ], + ], + ], + 'regex_ambiguous_default' => [ + 'type' => Regex::class, + 'options' => [ + 'regex' => '/normalized-regex-ambiguous/' . + '(?P.+)(?:\.(?Ppdf|json))?', + 'spec' => '/normalized-regex-ambiguous/%name%.%ext%', + 'defaults' => [ + 'controller' => DynamicPathController::class, + 'action' => 'ambiguous', + 'ext' => 'html', + ], + ], + ], + 'normalized_encoded_optional' => [ + 'type' => Segment::class, + 'options' => [ + 'route' => '/normalized-encoded[/:slug]', + 'constraints' => [ + 'slug' => '.+', + ], + 'defaults' => [ + 'controller' => DynamicPathController::class, + 'action' => 'index', + ], + ], + ], + 'normalized_static_optional' => [ + 'type' => Segment::class, + 'options' => [ + 'route' => '/normalized-static[/draft]', + 'defaults' => [ + 'controller' => DynamicPathController::class, + 'action' => 'index', + ], + ], + ], + 'normalized_static_prefix_optional' => [ + 'type' => Segment::class, + 'options' => [ + 'route' => '/normalized-static-prefix[/normalized]', + 'defaults' => [ + 'controller' => DynamicPathController::class, + 'action' => 'index', + ], + ], + ], + 'normalized_dynamic_prefix_optional' => [ + 'type' => Segment::class, + 'options' => [ + 'route' => '/normalized-dynamic-prefix[/:value]', + 'defaults' => [ + 'controller' => DynamicPathController::class, + 'action' => 'index', + 'value' => 'normalized-dynamic-prefix', + ], + ], + ], + 'normalized_encoded_cache_optional' => [ + 'type' => Segment::class, + 'options' => [ + 'route' => '/normalized-encoded-cache[/:slug]', + 'defaults' => [ + 'controller' => DynamicPathController::class, + 'action' => 'index', + ], + ], + ], + 'normalized_encoded_lowercase_optional' => [ + 'type' => Segment::class, + 'options' => [ + 'route' => '/normalized-encoded-lowercase[/:slug]', + 'defaults' => [ + 'controller' => DynamicPathController::class, + 'action' => 'index', + ], + ], + ], + 'normalized_hyphenated_name' => [ + 'type' => Segment::class, + 'options' => [ + 'route' => '/normalized-name/:user-id', + 'constraints' => [ + 'user-id' => '[a-z]+', + ], + 'defaults' => [ + 'controller' => DynamicPathController::class, + 'action' => 'index', + ], + ], + ], + 'normalized_wildcard_collision' => [ + 'type' => Segment::class, + 'options' => [ + 'route' => '/normalized-wildcard/:param1', + 'defaults' => [ + 'controller' => DynamicPathController::class, + 'action' => 'index', + ], + ], + 'may_terminate' => false, + 'child_routes' => [ + 'tail' => [ + 'type' => Wildcard::class, + 'options' => [ + 'defaults' => [], + ], + ], + ], + ], 'scheme_http_gate' => [ 'type' => Scheme::class, 'options' => [ @@ -228,6 +349,7 @@ ], ], ], + 'wildcard_keys' => [ 'type' => Literal::class, 'options' => [ diff --git a/appsec/tests/integration/src/test/www/laminas33/module/Application/src/Controller/DynamicPathController.php b/appsec/tests/integration/src/test/www/laminas33/module/Application/src/Controller/DynamicPathController.php index 347a8287c68..d69186c013d 100644 --- a/appsec/tests/integration/src/test/www/laminas33/module/Application/src/Controller/DynamicPathController.php +++ b/appsec/tests/integration/src/test/www/laminas33/module/Application/src/Controller/DynamicPathController.php @@ -8,6 +8,17 @@ class DynamicPathController extends AbstractActionController { + public function ambiguousAction() + { + $routeMatch = $this->getEvent()->getRouteMatch(); + $name = $routeMatch->getParam('name'); + $ext = $routeMatch->getParam('ext'); + + $response = $this->getResponse(); + $response->setContent("$name/$ext"); + return $response; + } + public function indexAction() { $response = $this->getResponse(); diff --git a/appsec/tests/integration/src/test/www/wordpress/docker-init.sh b/appsec/tests/integration/src/test/www/wordpress/docker-init.sh index 7ba2e9e4991..9ecbd00c962 100755 --- a/appsec/tests/integration/src/test/www/wordpress/docker-init.sh +++ b/appsec/tests/integration/src/test/www/wordpress/docker-init.sh @@ -11,6 +11,9 @@ cp /test-resources/public/wp-config.php wp-config.php cp /test-resources/public/index.php index.php cp /test-resources/public/login_trigger.php login_trigger.php cp /test-resources/public/hello.php hello.php +mkdir -p wp-content/mu-plugins +cp /test-resources/public/wp-content/mu-plugins/normalized-route-test.php \ + wp-content/mu-plugins/normalized-route-test.php # Download WP-CLI curl -sf https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o /usr/local/bin/wp diff --git a/appsec/tests/integration/src/test/www/wordpress/public/wp-content/mu-plugins/normalized-route-test.php b/appsec/tests/integration/src/test/www/wordpress/public/wp-content/mu-plugins/normalized-route-test.php new file mode 100644 index 00000000000..226cdb6dae4 --- /dev/null +++ b/appsec/tests/integration/src/test/www/wordpress/public/wp-content/mu-plugins/normalized-route-test.php @@ -0,0 +1,57 @@ +[^/]+)-(?P[^/]+)/?$', + 'index.php?normalized_route_test=1&normalized_value=$matches[1]-$matches[2]', + 'top' + ); +}); + +add_filter('query_vars', static function (array $queryVars): array { + $queryVars[] = 'normalized_route_test'; + $queryVars[] = 'normalized_value'; + return $queryVars; +}); + +add_filter('pre_handle_404', static function ($preempt, $query) { + if ($query->get('normalized_route_test')) { + return true; + } + return $preempt; +}, 10, 2); + +add_action('template_redirect', static function () { + if (! get_query_var('normalized_route_test')) { + return; + } + + status_header(200); + header('Content-Type: text/plain'); + echo get_query_var('normalized_value') ?: 'absent'; + exit; +}); diff --git a/config.m4 b/config.m4 index de877e18c06..9a8361c3c2f 100644 --- a/config.m4 +++ b/config.m4 @@ -335,6 +335,7 @@ if test "$PHP_DDTRACE" != "no" && test "$PHP_DDTRACE_PROFILING" = "no"; then tracer/priority_sampling/priority_sampling.c \ tracer/profiling.c \ tracer/random.c \ + tracer/routing_cache.c \ tracer/rule_matching.c \ tracer/serializer.c \ tracer/standalone_limiter.c \ diff --git a/config.w32 b/config.w32 index 76b15612f4e..677885f2a1b 100644 --- a/config.w32 +++ b/config.w32 @@ -73,6 +73,7 @@ if (PHP_DDTRACE != 'no') { DDTRACE_TRACER_SOURCES += " tracer_otel_config.c"; DDTRACE_TRACER_SOURCES += " profiling.c"; DDTRACE_TRACER_SOURCES += " random.c"; + DDTRACE_TRACER_SOURCES += " routing_cache.c"; DDTRACE_TRACER_SOURCES += " rule_matching.c"; DDTRACE_TRACER_SOURCES += " serializer.c"; DDTRACE_TRACER_SOURCES += " span.c"; diff --git a/src/DDTrace/Integrations/Laminas/LaminasIntegration.php b/src/DDTrace/Integrations/Laminas/LaminasIntegration.php index 0d55f3d22a9..90c33c869ae 100644 --- a/src/DDTrace/Integrations/Laminas/LaminasIntegration.php +++ b/src/DDTrace/Integrations/Laminas/LaminasIntegration.php @@ -284,6 +284,92 @@ static function (SpanData $span) use ($controller, $action) { $httpRoute = LaminasIntegration::httpRouteTemplateFromNamedRouteStack($this, (string) $routeName); if ($httpRoute !== null && $httpRoute !== '') { $rootSpan->meta[Tag::HTTP_ROUTE] = $httpRoute; + if (function_exists('\datadog\appsec\is_enabled') && \datadog\appsec\is_enabled() + && dd_trace_env_config("DD_API_SECURITY_ENABLED")) { + $allParams = method_exists($routeMatch, 'getParams') ? ($routeMatch->getParams() ?? []) : []; + $urlPath = method_exists($request, 'getUri') ? $request->getUri()->getPath() : null; + // Regex and bracket routes require the framework's compiled + // matcher data. If it cannot be obtained, leave cacheKey null + // and omit the normalized tag rather than infer from the URL. + $urlMatchedFromRegex = null; + $matchedSegmentTemplate = null; + $urlMatchedFromSegment = null; + $cacheKey = null; + if (strpos($httpRoute, '%') !== false) { + // Regex route: use actual route regex for accurate presence. + if ($urlPath !== null) { + try { + $_leafRoute = LaminasIntegration::getLeafRouteFromNamedRouteStack( + $this, + (string) $routeName + ); + $urlMatchedFromRegex = LaminasIntegration::inferLaminasRegexMatch( + $_leafRoute, + $urlPath + ); + unset($_leafRoute); + } catch (\Throwable $_ex) { + unset($_leafRoute, $_ex); + $urlMatchedFromRegex = null; + } + } + if ($urlMatchedFromRegex !== null) { + $_urlMatchedKeys = array_keys($urlMatchedFromRegex); + sort($_urlMatchedKeys); + $cacheKey = $httpRoute . '#' . implode(',', $_urlMatchedKeys); + unset($_urlMatchedKeys); + } + } elseif (strpos($httpRoute, '[') !== false) { + // Bracket-optional route: use Segment::match() to get + // accurate capture participation without URL inference. + try { + $_leafRoute = LaminasIntegration::getLeafRouteFromNamedRouteStack( + $this, + (string) $routeName + ); + if ($_leafRoute instanceof \Laminas\Router\Http\Segment) { + $_segmentMatch = LaminasIntegration::inferLaminasSegmentMatch( + $_leafRoute, + $urlPath + ); + if ($_segmentMatch !== null) { + $matchedSegmentTemplate = $_segmentMatch['template']; + $urlMatchedFromSegment = $_segmentMatch['params']; + } + } + unset($_leafRoute, $_segmentMatch); + } catch (\Throwable $_ex) { + unset($_leafRoute, $_segmentMatch, $_ex); + } + + if ($matchedSegmentTemplate !== null) { + $cacheKey = $httpRoute . '#' . $matchedSegmentTemplate; + } + } else { + $cacheKey = $httpRoute; + } + if ($cacheKey !== null) { + $normalizedRoute = \DDTrace\routing_cache_get($cacheKey); + if ($normalizedRoute === false) { + $normalizationTemplate = $matchedSegmentTemplate ?? $httpRoute; + $normalizationParams = $urlMatchedFromSegment ?? $allParams; + $normalizationUrlPath = $matchedSegmentTemplate === null ? $urlPath : null; + $normalizedRoute = \DDTrace\Util\RouteNormalizer::normalizeFromLaminas( + $normalizationTemplate, + $normalizationParams, + $normalizationUrlPath, + $urlMatchedFromRegex + ); + if ($normalizedRoute !== null) { + \DDTrace\routing_cache_set($cacheKey, $normalizedRoute); + } + unset($normalizationTemplate, $normalizationParams, $normalizationUrlPath); + } + if ($normalizedRoute !== null && $normalizedRoute !== false) { + $rootSpan->meta[Tag::APPSEC_NORMALIZED_ROUTE] = $normalizedRoute; + } + } + } } } @@ -1157,6 +1243,228 @@ private static function walkRouteStackCollectEndpointRows( } } + /** + * Reproduce Regex::match() before Laminas merges route defaults. + */ + public static function inferLaminasRegexMatch($route, string $urlPath) + { + if ($route instanceof \Laminas\Router\Http\Regex) { + $match = self::matchLaminasRegexComponent($route, $urlPath, null); + return $match === null ? null : $match['params']; + } + + if (!($route instanceof \Laminas\Router\Http\Chain)) { + return null; + } + + $offset = 0; + $params = []; + foreach (self::laminasGetChainRoutes($route) as $component) { + if ($component instanceof \Laminas\Router\Http\Regex) { + $match = self::matchLaminasRegexComponent($component, $urlPath, $offset); + } elseif ($component instanceof \Laminas\Router\Http\Literal) { + $match = self::matchLaminasLiteralComponent($component, $urlPath, $offset); + } else { + return null; + } + if ($match === null) { + return null; + } + $params = array_merge($params, $match['params']); + $offset += $match['length']; + } + + return $offset === strlen($urlPath) ? $params : null; + } + + private static function matchLaminasRegexComponent($route, string $urlPath, $offset) + { + $regex = \Closure::bind( + static function ($regexRoute) { return $regexRoute->regex; }, + null, + \Laminas\Router\Http\Regex::class + )($route); + $pattern = $offset === null ? '(^' . $regex . '$)' : '(\G' . $regex . ')'; + $matches = []; + if (@preg_match($pattern, $urlPath, $matches, 0, $offset ?? 0) !== 1) { + return null; + } + + $params = []; + foreach ($matches as $name => $value) { + if (is_string($name) && $value !== '') { + $params[$name] = rawurldecode($value); + } + } + + return ['params' => $params, 'length' => strlen($matches[0])]; + } + + private static function matchLaminasLiteralComponent($route, string $urlPath, int $offset) + { + $literal = \Closure::bind( + static function ($literalRoute) { return $literalRoute->route; }, + null, + \Laminas\Router\Http\Literal::class + )($route); + if ($literal === '' || strpos($urlPath, $literal, $offset) !== $offset) { + return null; + } + + return ['params' => [], 'length' => strlen($literal)]; + } + + /** + * Reproduce Segment::match() before Laminas merges route defaults. + * + * @return array{template: string, params: array}|null + */ + public static function inferLaminasSegmentMatch( + \Laminas\Router\Http\Segment $route, + string $urlPath + ) { + $routeData = \Closure::bind( + static function ($segment) { + return [ + $segment->regex, + $segment->paramMap, + $segment->parts, + $segment->translationKeys, + ]; + }, + null, + \Laminas\Router\Http\Segment::class + )($route); + + if (!is_array($routeData) || count($routeData) !== 4 || !empty($routeData[3])) { + return null; + } + + $matches = []; + if (@preg_match('(^' . $routeData[0] . '$)', $urlPath, $matches, PREG_OFFSET_CAPTURE) !== 1) { + return null; + } + + $captures = []; + $params = []; + foreach ($routeData[1] as $group => $name) { + if (!isset($matches[$group][0]) || $matches[$group][0] === '') { + continue; + } + $captures[$name][] = [ + 'raw' => $matches[$group][0], + 'offset' => $matches[$group][1], + ]; + $params[$name] = rawurldecode($matches[$group][0]); + } + + $states = [[ + 'offset' => 0, + 'template' => '', + 'capture_indexes' => [], + ]]; + $states = self::matchLaminasSegmentParts($routeData[2], $urlPath, $captures, $states); + foreach ($states as $state) { + if ($state['offset'] !== strlen($urlPath)) { + continue; + } + foreach ($captures as $name => $values) { + if (($state['capture_indexes'][$name] ?? 0) !== count($values)) { + continue 2; + } + } + return [ + 'template' => $state['template'], + 'params' => $params, + ]; + } + + return null; + } + + private static function matchLaminasSegmentParts( + array $parts, + string $urlPath, + array $captures, + array $states + ): array { + foreach ($parts as $part) { + $nextStates = []; + foreach ($states as $state) { + if ($part[0] === 'literal') { + $literal = $part[1]; + if (substr($urlPath, $state['offset'], strlen($literal)) !== $literal) { + continue; + } + $state['offset'] += strlen($literal); + $state['template'] .= $literal; + $nextStates[] = $state; + } elseif ($part[0] === 'parameter') { + $name = $part[1]; + $captureIndex = $state['capture_indexes'][$name] ?? 0; + if (!isset($captures[$name][$captureIndex])) { + continue; + } + $capture = $captures[$name][$captureIndex]; + if ($capture['offset'] !== $state['offset']) { + continue; + } + $state['offset'] += strlen($capture['raw']); + $state['template'] .= ':' . $name; + if (isset($part[2]) && $part[2] !== null && $part[2] !== '') { + $state['template'] .= '{' . $part[2] . '}'; + } + $state['capture_indexes'][$name] = $captureIndex + 1; + $nextStates[] = $state; + } elseif ($part[0] === 'optional') { + $optionalStates = self::matchLaminasSegmentParts( + $part[1], + $urlPath, + $captures, + [$state] + ); + foreach ($optionalStates as $optionalState) { + $nextStates[] = $optionalState; + } + $nextStates[] = $state; + } else { + // Translated literals require the router's match options. Fall back + // rather than attempting a potentially inaccurate reconstruction. + return []; + } + } + $states = $nextStates; + if (empty($states)) { + break; + } + } + + return $states; + } + + public static function getLeafRouteFromNamedRouteStack($stack, string $matchedName) + { + $segments = \explode('/', $matchedName, 2); + $route = self::laminasGetNamedRouteFromStack($stack, $segments[0]); + if ($route === null) { + return null; + } + $hasChild = isset($segments[1]); + if ($route instanceof \Laminas\Router\Http\Part) { + if (!$hasChild) { + $rp = new ReflectionProperty($route, 'route'); + $rp->setAccessible(true); + return $rp->getValue($route); + } + self::laminasMaterializePartChildRoutes($route); + return self::getLeafRouteFromNamedRouteStack($route, $segments[1]); + } + if ($hasChild) { + return null; + } + return $route; + } + public static function httpRouteTemplateFromNamedRouteStack($stack, string $matchedName): ?string { $segments = \explode('/', $matchedName, 2); diff --git a/src/DDTrace/Integrations/Laravel/LaravelIntegration.php b/src/DDTrace/Integrations/Laravel/LaravelIntegration.php index 0f00a02b825..4a8ede06092 100644 --- a/src/DDTrace/Integrations/Laravel/LaravelIntegration.php +++ b/src/DDTrace/Integrations/Laravel/LaravelIntegration.php @@ -140,7 +140,40 @@ static function ($This, $scope, $args, $route) { $rootSpan->meta[Tag::HTTP_URL] = \DDTrace\Util\Normalizer::urlSanitize($request->fullUrl()); } if (\method_exists($route, 'uri')) { - $rootSpan->meta[Tag::HTTP_ROUTE] = $route->uri(); + $httpRoute = $route->uri(); + $rootSpan->meta[Tag::HTTP_ROUTE] = $httpRoute; + if (function_exists('\datadog\appsec\is_enabled') && \datadog\appsec\is_enabled() + && dd_trace_env_config("DD_API_SECURITY_ENABLED")) { + $allParams = \method_exists($route, 'parameters') ? ($route->parameters() ?? []) : []; + if (strpos($httpRoute, '?}') !== false) { + // For routes with optional params, filter out default-injected values + // (e.g. ->defaults('format', 'html')) that weren't present in the URL. + $matchedParams = self::laravelUrlMatchedParams($route, $request, $allParams); + // Cache key encodes which optional params are present + preg_match_all('/\{([^}]+)\?\}/', $httpRoute, $_opts); + $_present = []; + foreach ($_opts[1] as $_opt) { + if (array_key_exists($_opt, $matchedParams)) { + $_present[] = $_opt; + } + } + $cacheKey = $httpRoute . '#' . implode(',', $_present); + unset($_opts, $_present, $_opt); + } else { + $matchedParams = $allParams; + $cacheKey = $httpRoute; + } + $normalizedRoute = \DDTrace\routing_cache_get($cacheKey); + if ($normalizedRoute === false) { + $normalizedRoute = \DDTrace\Util\RouteNormalizer::normalizeFromLaravel($httpRoute, $matchedParams); + if ($normalizedRoute !== null) { + \DDTrace\routing_cache_set($cacheKey, $normalizedRoute); + } + } + if ($normalizedRoute !== null && $normalizedRoute !== false) { + $rootSpan->meta[Tag::APPSEC_NORMALIZED_ROUTE] = $normalizedRoute; + } + } } if (\method_exists($route, 'parameters') && function_exists('\datadog\appsec\push_addresses')) { $parameters = $route->parameters(); @@ -753,4 +786,44 @@ public static function normalizeRouteName($routeName) return $routeName; } + + /** + * Determine which Laravel optional params were actually present in the URL path + * (vs. injected as route defaults via ->defaults()). + * + * Laravel applies defaults before exposing Route::parameters(), so use the same + * compiled regex that Laravel used to bind the request to identify URL captures. + * + * @param object $route Matched Laravel route + * @param object $request Laravel request + * @param array $allParams From $route->parameters() + * @return array + */ + private static function laravelUrlMatchedParams($route, $request, array $allParams): array + { + if (!method_exists($route, 'getCompiled') || !method_exists($route, 'parameterNames')) { + return $allParams; + } + + $compiled = $route->getCompiled(); + if ($compiled === null || !method_exists($compiled, 'getRegex')) { + return $allParams; + } + + $path = method_exists($request, 'decodedPath') ? $request->decodedPath() : $request->path(); + $matches = []; + if (@preg_match($compiled->getRegex(), '/' . ltrim($path, '/'), $matches) !== 1) { + return $allParams; + } + + $matched = []; + foreach ($route->parameterNames() as $name) { + if (isset($matches[$name]) && is_string($matches[$name]) + && strlen($matches[$name]) > 0 && array_key_exists($name, $allParams)) { + $matched[$name] = $allParams[$name]; + } + } + + return $matched; + } } diff --git a/src/DDTrace/Integrations/Symfony/SymfonyIntegration.php b/src/DDTrace/Integrations/Symfony/SymfonyIntegration.php index d0540860fa5..05635880d66 100644 --- a/src/DDTrace/Integrations/Symfony/SymfonyIntegration.php +++ b/src/DDTrace/Integrations/Symfony/SymfonyIntegration.php @@ -456,6 +456,50 @@ static function() { if ($path !== null) { $rootSpan->meta[Tag::HTTP_ROUTE] = $path; + if (function_exists('\datadog\appsec\is_enabled') && \datadog\appsec\is_enabled() + && dd_trace_env_config("DD_API_SECURITY_ENABLED")) { + // Use the compiled route regex for accurate param presence detection. + // Without it, omit the tag instead of inferring from the URL and + // potentially treating a route default as a matched parameter. + $matchedParams = null; + if ($container->has('router')) { + $_r = $container->get('router'); + if (method_exists($_r, 'getRouteCollection')) { + $_route = $_r->getRouteCollection()->get($route_name); + if ($_route !== null && method_exists($_route, 'compile')) { + $_compiled = $_route->compile(); + if (method_exists($_compiled, 'getRegex')) { + $_regex = $_compiled->getRegex(); + // Symfony's UrlMatcher matches the decoded path. + $_pathInfo = rawurldecode($request->getPathInfo()); + if (@preg_match($_regex, $_pathInfo, $_rxm) === 1) { + $matchedParams = []; + foreach ($_rxm as $_k => $_v) { + if (is_string($_k) && $_v !== '') { + $matchedParams[$_k] = $_v; + } + } + } + } + } + } + unset($_r, $_route, $_compiled, $_regex, $_pathInfo, $_rxm, $_k, $_v); + } + if ($matchedParams === null) { + return; + } + $cacheKey = $route_name . '|' . implode(',', array_keys($matchedParams)); + $normalizedRoute = \DDTrace\routing_cache_get($cacheKey); + if ($normalizedRoute === false) { + $normalizedRoute = \DDTrace\Util\RouteNormalizer::normalizeFromSymfony($path, $matchedParams); + if ($normalizedRoute !== null) { + \DDTrace\routing_cache_set($cacheKey, $normalizedRoute); + } + } + if ($normalizedRoute !== null && $normalizedRoute !== false) { + $rootSpan->meta[Tag::APPSEC_NORMALIZED_ROUTE] = $normalizedRoute; + } + } } }; } else { @@ -770,4 +814,5 @@ public static function injectActionInfo($event, $eventName, SpanData $requestSpa return true; } + } diff --git a/src/DDTrace/Integrations/WordPress/WordPressIntegrationLoader.php b/src/DDTrace/Integrations/WordPress/WordPressIntegrationLoader.php index 4a9cd2a5178..7db53a281b8 100644 --- a/src/DDTrace/Integrations/WordPress/WordPressIntegrationLoader.php +++ b/src/DDTrace/Integrations/WordPress/WordPressIntegrationLoader.php @@ -732,7 +732,33 @@ static function (HookData $hook) use ( function_exists('is_404') && is_404() === false) { $rootSpan = \DDTrace\root_span(); if (\property_exists($This, 'matched_rule')) { - $rootSpan->meta[Tag::HTTP_ROUTE] = $This->matched_rule; + $matchedRule = $This->matched_rule; + $rootSpan->meta[Tag::HTTP_ROUTE] = $matchedRule; + if (function_exists('\datadog\appsec\is_enabled') && \datadog\appsec\is_enabled() + && dd_trace_env_config("DD_API_SECURITY_ENABLED")) { + $urlPath = \property_exists($This, 'request') ? $This->request : null; + $routeAnalysis = \DDTrace\Util\RouteNormalizer::analyzeWordPressRoute( + $matchedRule, + $urlPath + ); + if ($routeAnalysis !== null) { + $cacheKey = $matchedRule . '#' . $routeAnalysis['cache_signature']; + $normalizedRoute = \DDTrace\routing_cache_get($cacheKey); + if ($normalizedRoute === false) { + $normalizedRoute = \DDTrace\Util\RouteNormalizer::normalizeFromWordPress( + $matchedRule, + $urlPath, + $routeAnalysis + ); + if ($normalizedRoute !== null) { + \DDTrace\routing_cache_set($cacheKey, $normalizedRoute); + } + } + if ($normalizedRoute !== null && $normalizedRoute !== false) { + $rootSpan->meta[Tag::APPSEC_NORMALIZED_ROUTE] = $normalizedRoute; + } + } + } } } }); diff --git a/src/DDTrace/Util/RouteNormalizer.php b/src/DDTrace/Util/RouteNormalizer.php new file mode 100644 index 00000000000..1c80722db4a --- /dev/null +++ b/src/DDTrace/Util/RouteNormalizer.php @@ -0,0 +1,902 @@ +uri(), e.g. "/users/{id}/{format?}" + * @param array $matchedParams Parameters from $route->parameters(); used to resolve optionals. + * Note: includes framework-injected defaults; caller must exclude them. + * @return string|null + */ + public static function normalizeFromLaravel(string $routeUri, array $matchedParams = []) + { + return self::normalizeBraceRoute($routeUri, $matchedParams); + } + + /** + * Normalize a Symfony route path. + * + * @param string $path Path template, e.g. "/users/{id}" + * @param array|null $matchedParams Params actually present in the URL path (not including + * route defaults); required for dynamic routes + * @return string|null + */ + public static function normalizeFromSymfony(string $path, $matchedParams = null) + { + if ($matchedParams === null) { + return self::normalizeBraceRoute($path, []); + } + + // Mark params absent from the URL as optional so normalizeBraceSegment drops them. + // Use [^}?:]+ to match any param name including UTF-8 characters. + $path = preg_replace_callback( + '/\{([^}?:]+)\}/', + static function ($m) use ($matchedParams) { + return array_key_exists($m[1], $matchedParams) ? $m[0] : '{' . $m[1] . '?}'; + }, + $path + ); + return self::normalizeBraceRoute($path, $matchedParams); + } + + /** + * Normalize a Laminas route template. + * + * Laminas uses :param for dynamic parameters and [...] for optional sections. + * The Wildcard route type produces "/*" which is treated as a catch-all. + * + * @param string $template Template from httpRouteTemplateFromMatchedRoute() + * @param array $matchedParams Matched params from $routeMatch->getParams() + * @param string|null $urlPath The raw request URL path; filters out optional sections + * whose params were injected by middleware rather than + * matched from the URL (e.g. Laminas API Tools + * VersionListener sets :version even without a /v1/ prefix) + * @param array|null $urlMatchedParams Parameters captured by the Regex route matcher + * @return string|null + */ + public static function normalizeFromLaminas(string $template, array $matchedParams = [], $urlPath = null, $urlMatchedParams = null) + { + $expanded = self::expandBracketOptionals($template, $matchedParams, ':', $urlPath); + + // Replace wildcard /* with a param name that doesn't collide with existing params + if (preg_match('#/\*$#', $expanded)) { + $wildcardName = self::uniqueParamName($expanded, ':'); + $expanded = preg_replace('#/\*$#', '/{' . $wildcardName . '}', $expanded); + } + + // Segment routes use :param; Regex routes use %param% (spec format) — handle both. + // Detect Regex routes before conversion so matcher capture metadata can be applied. + $hasPercentParams = (bool) preg_match('/%([a-zA-Z_][a-zA-Z0-9_]*)%/', $expanded); + // For Regex routes, defaults inject values into matchedParams even for captures absent + // from the URL (e.g. format='html' when no .html in path). Use $urlMatchedParams when + // provided by the integration; otherwise fall back to URL-value heuristic or treat all + // percent params as required when no URL info is available. + if ($hasPercentParams) { + if ($urlMatchedParams === null) { + if ($urlPath === null) { + // No URL info: treat all percent params as required (present). + $expanded = preg_replace('/%([a-zA-Z_][a-zA-Z0-9_]*)%/', '{$1}', $expanded); + $urlMatchedParams = $matchedParams; + } else { + // Heuristic: params whose values appear in the URL are treated as URL-matched. + $inferred = []; + foreach ($matchedParams as $name => $value) { + if (strpos($expanded, '%' . $name . '%') === false) { + continue; + } + $strValue = (string) $value; + if ($strValue !== '' && ( + strpos($urlPath, $strValue) !== false || + strpos($urlPath, rawurlencode($strValue)) !== false || + strpos(strtolower($urlPath), strtolower(rawurlencode($strValue))) !== false + )) { + $inferred[$name] = $value; + } + } + $expanded = preg_replace_callback( + '/%([a-zA-Z_][a-zA-Z0-9_]*)%/', + static function ($m) use ($inferred) { + return array_key_exists($m[1], $inferred) + ? '{' . $m[1] . '}' + : '{' . $m[1] . '?}'; + }, + $expanded + ); + $urlMatchedParams = $inferred; + } + } else { + $expanded = preg_replace_callback( + '/%([a-zA-Z_][a-zA-Z0-9_]*)%/', + static function ($m) use ($urlMatchedParams) { + return array_key_exists($m[1], $urlMatchedParams) + ? '{' . $m[1] . '}' + : '{' . $m[1] . '?}'; + }, + $expanded + ); + } + } + + $braceFormat = self::colonParamsToBraces($expanded); + $braceFormat = self::percentParamsToBraces($braceFormat); + + return self::normalizeBraceRoute( + $braceFormat, + $hasPercentParams ? $urlMatchedParams : $matchedParams + ); + } + + /** + * Normalize a WordPress matched_rule (regex). + * + * WordPress route matching uses regex rules like "^blog/([^/]+)/?$". + * PCRE supplies declared names; unnamed captures use param1, param2, …. + * + * @param string $matchedRule Value of $wp->matched_rule + * @param string|null $urlPath Value of $wp->request; used to detect which + * optional capture groups actually participated + * in the match, so phantom segments are not emitted. + * @return string|null + */ + public static function normalizeFromWordPress(string $matchedRule, $urlPath = null, $analysis = null) + { + if ($analysis === null) { + $analysis = self::analyzeWordPressRoute($matchedRule, $urlPath); + } + + return $analysis['normalized_route'] ?? null; + } + + /** + * Match a WordPress rule and derive the route from PCRE's capture offsets. + * + * Literal alternatives and optional literals may produce distinct normalized + * routes. Rules that can consume variable text outside a capture are rejected, + * because their uncaptured request text would otherwise become a route constant. + * + * When $urlPath is null, a backward-compatible fallback is used that emits all + * capture groups without filtering by participation. + * + * @return array|null + */ + public static function analyzeWordPressRoute(string $matchedRule, $urlPath = null) + { + if (!self::hasOnlyCapturedWordPressDynamics($matchedRule)) { + return null; + } + + if ($urlPath === null) { + $normalized = self::normalizeWordPressRuleOnly($matchedRule); + if ($normalized === null) { + return null; + } + return [ + 'normalized_route' => $normalized, + 'cache_signature' => $normalized, + ]; + } + + // WordPress uses # delimiters when selecting matched_rule, so an + // unescaped # could not occur in a rule that successfully matched. + $pattern = '#^' . $matchedRule . '#'; + + $subject = trim($urlPath, '/'); + $matches = []; + $flags = PREG_OFFSET_CAPTURE; + if (defined('PREG_UNMATCHED_AS_NULL')) { + $flags |= constant('PREG_UNMATCHED_AS_NULL'); + } + if (@preg_match($pattern, $subject, $matches, $flags) !== 1) { + return null; + } + if ($matches[0][1] !== 0 || strlen($matches[0][0]) !== strlen($subject)) { + return null; + } + + $captures = self::wordPressNumericCaptures($matches); + if ($captures === null || !self::applyWordPressCaptureNames($matches, $captures)) { + return null; + } + + $normalizedRoute = self::normalizeWordPressMatch($subject, $captures); + if ($normalizedRoute === null) { + return null; + } + + return [ + 'normalized_route' => $normalizedRoute, + // This is bounded because uncaptured variable input was rejected above. + 'cache_signature' => $normalizedRoute, + ]; + } + + /** + * Backward-compatible fallback for normalizeFromWordPress when no URL path is available. + * + * Parses the PCRE rule structure to identify capture groups and segment boundaries + * ('/') at capturing-depth 0. All capture groups are treated as present. + */ + /** @return string|null */ + private static function normalizeWordPressRuleOnly(string $rule) + { + // Strip anchors and common trailing patterns + $s = $rule; + if (isset($s[0]) && $s[0] === '^') { + $s = substr($s, 1); + } + if (substr($s, -3) === '/?$') { + $s = substr($s, 0, -3); + } elseif (substr($s, -2) === '/$') { + $s = substr($s, 0, -2); + } elseif (substr($s, -2) === '?$') { + $s = substr($s, 0, -2); + } elseif (substr($s, -1) === '$') { + $s = substr($s, 0, -1); + } + if (substr($s, -2) === '/?') { + $s = substr($s, 0, -2); + } + + if ($s === '') { + return '/'; + } + + $captureNum = 0; + $groups = []; // stack: true = capturing, false = non-capturing + $capturingDepth = 0; + $inClass = false; + $inQuote = false; + $len = strlen($s); + + $segments = []; + $currentSegment = ['static' => '', 'captures' => []]; + + for ($i = 0; $i < $len; $i++) { + $char = $s[$i]; + + if ($inQuote) { + if ($char === '\\' && isset($s[$i + 1]) && $s[$i + 1] === 'E') { + $inQuote = false; + $i++; + } + continue; + } + + if ($inClass) { + if ($char === '\\' && isset($s[$i + 1])) { + $i++; + } elseif ($char === ']') { + $inClass = false; + } + continue; + } + + if ($char === '\\') { + if (!isset($s[$i + 1])) { + break; + } + $next = $s[++$i]; + if ($next === 'Q') { + $inQuote = true; + } + continue; + } + + if ($char === '[' && $capturingDepth > 0) { + $inClass = true; + continue; + } + + if ($char === '(') { + $capturing = true; + if (substr($s, $i + 1, 2) === '?:') { + $capturing = false; + $i += 2; + } elseif (substr($s, $i + 1, 3) === '?P<') { + $end = strpos($s, '>', $i + 4); + if ($end !== false) { + $i = $end; + } + } elseif (substr($s, $i + 1, 2) === '?<' + && isset($s[$i + 3]) && strpos('=!', $s[$i + 3]) === false) { + $end = strpos($s, '>', $i + 3); + if ($end !== false) { + $i = $end; + } + } elseif (isset($s[$i + 1]) && $s[$i + 1] === '?') { + $capturing = false; + $i++; + } + $groups[] = $capturing; + if ($capturing) { + $captureNum++; + if ($capturingDepth === 0) { + $currentSegment['captures'][] = $captureNum; + } + $capturingDepth++; + } + continue; + } + + if ($char === ')') { + $wasCapturing = array_pop($groups); + if ($wasCapturing) { + $capturingDepth--; + } + if (isset($s[$i + 1]) && ($s[$i + 1] === '?' || $s[$i + 1] === '*' || $s[$i + 1] === '+')) { + $i++; + } elseif (isset($s[$i + 1]) && $s[$i + 1] === '{') { + $end = strpos($s, '}', $i + 1); + if ($end !== false) { + $i = $end; + } + } + continue; + } + + if ($capturingDepth > 0) { + continue; + } + + // At capturingDepth === 0 + if ($char === '/') { + $segments[] = $currentSegment; + $currentSegment = ['static' => '', 'captures' => []]; + } elseif ($char === '?' || $char === '*' || $char === '+') { + // quantifier — skip + } elseif ($char === '{') { + $end = strpos($s, '}', $i); + if ($end !== false) { + $i = $end; + } + } elseif ($char === '|') { + break; // take first alternative only + } elseif ($char !== '.') { + $currentSegment['static'] .= $char; + } + } + + $segments[] = $currentSegment; + + $normalized = []; + foreach ($segments as $seg) { + if (!empty($seg['captures'])) { + $params = array_map(static function ($n) { return 'param' . $n; }, $seg['captures']); + $normalized[] = '{' . implode('+', $params) . '}'; + } elseif ($seg['static'] !== '') { + $normalized[] = self::encodeStaticSegment($seg['static']); + } + } + + return '/' . implode('/', $normalized); + } + + /** + * This is a rejection filter, not a PCRE parser. Capture bodies are opaque. + * Outside captures, only literal text, fixed alternatives, optional fixed text, + * and non-capturing wrappers are allowed. + */ + private static function hasOnlyCapturedWordPressDynamics(string $rule): bool + { + $groups = []; + $captureDepth = 0; + $inClass = false; + $inQuote = false; + $length = strlen($rule); + + for ($i = 0; $i < $length; $i++) { + $char = $rule[$i]; + + if ($inQuote) { + if ($char === '\\' && isset($rule[$i + 1]) && $rule[$i + 1] === 'E') { + $inQuote = false; + $i++; + } + continue; + } + if ($inClass) { + if ($char === '\\' && isset($rule[$i + 1])) { + $i++; + } elseif ($char === ']') { + $inClass = false; + } + continue; + } + if ($char === '\\') { + if (!isset($rule[$i + 1])) { + return false; + } + $escaped = $rule[++$i]; + if ($escaped === 'Q') { + $inQuote = true; + } elseif ($captureDepth === 0 && ctype_alnum($escaped)) { + return false; + } + continue; + } + if ($char === '[') { + if ($captureDepth === 0) { + return false; + } + $inClass = true; + continue; + } + if ($char === '(') { + $capturing = true; + if (isset($rule[$i + 1]) && $rule[$i + 1] === '*') { + if ($captureDepth === 0) { + return false; + } + $capturing = false; + } elseif (isset($rule[$i + 1]) && $rule[$i + 1] === '?') { + $namedEnd = self::wordPressNamedCaptureEnd($rule, $i); + if ($namedEnd !== null) { + $i = $namedEnd; + } elseif (substr($rule, $i + 1, 2) === '?:') { + $capturing = false; + $i += 2; + } elseif ($captureDepth > 0) { + // The outer capture covers everything consumed by this group. + $capturing = false; + } else { + return false; + } + } + $groups[] = $capturing; + if ($capturing) { + $captureDepth++; + } + continue; + } + if ($char === ')') { + if (empty($groups)) { + return false; + } + if (array_pop($groups)) { + $captureDepth--; + } + continue; + } + if ($captureDepth === 0 && strpos('.[*+{', $char) !== false) { + return false; + } + } + + return !$inClass && empty($groups); + } + + /** @return int|null */ + private static function wordPressNamedCaptureEnd(string $rule, int $open) + { + if (substr($rule, $open + 1, 3) === '?P<') { + $end = strpos($rule, '>', $open + 4); + } elseif (substr($rule, $open + 1, 2) === '?<' + && isset($rule[$open + 3]) && strpos('=!', $rule[$open + 3]) === false) { + $end = strpos($rule, '>', $open + 3); + } elseif (substr($rule, $open + 1, 2) === "?'") { + $end = strpos($rule, "'", $open + 3); + } else { + return null; + } + + return $end === false ? null : $end; + } + + /** @return array|null */ + private static function wordPressNumericCaptures(array $matches) + { + $captures = []; + foreach ($matches as $key => $match) { + if (!is_int($key) || $key === 0) { + continue; + } + if (!is_array($match) || count($match) !== 2) { + return null; + } + $captures[$key] = [ + 'value' => $match[0], + 'offset' => $match[1], + 'present' => $match[1] >= 0 && $match[0] !== '', + 'name' => null, + ]; + } + ksort($captures); + return $captures; + } + + private static function applyWordPressCaptureNames(array $matches, array &$captures): bool + { + $pendingName = null; + $pendingMatch = null; + foreach ($matches as $key => $match) { + if (is_string($key)) { + if ($pendingName !== null) { + return false; + } + $pendingName = $key; + $pendingMatch = $match; + continue; + } + if ($key === 0 || $pendingName === null) { + continue; + } + if (!isset($captures[$key]) || $pendingMatch !== $match + || $captures[$key]['name'] !== null) { + return false; + } + $captures[$key]['name'] = $pendingName; + $pendingName = null; + $pendingMatch = null; + } + return $pendingName === null; + } + + /** @return string|null */ + private static function normalizeWordPressMatch(string $subject, array $captures) + { + if ($subject === '') { + return '/'; + } + + $values = explode('/', $subject); + $segments = []; + $offset = 0; + foreach ($values as $index => $value) { + $segments[$index] = [ + 'value' => $value, + 'start' => $offset, + 'end' => $offset + strlen($value), + 'captures' => [], + ]; + $offset = $segments[$index]['end'] + 1; + } + + foreach ($captures as $index => &$capture) { + if (!$capture['present']) { + continue; + } + $captureEnd = $capture['offset'] + strlen($capture['value']); + $capture['first_segment'] = null; + $capture['last_segment'] = null; + foreach ($segments as $segmentIndex => &$segment) { + if ($capture['offset'] < $segment['end'] && $captureEnd > $segment['start']) { + $segment['captures'][$index] = true; + if ($capture['first_segment'] === null) { + $capture['first_segment'] = $segmentIndex; + } + $capture['last_segment'] = $segmentIndex; + } + } + unset($segment); + if ($capture['first_segment'] === null) { + return null; + } + } + unset($capture); + + $normalized = []; + for ($segmentIndex = 0; $segmentIndex < count($segments); $segmentIndex++) { + if (empty($segments[$segmentIndex]['captures'])) { + $normalized[] = self::encodeStaticSegment($segments[$segmentIndex]['value']); + continue; + } + + $lastSegment = $segmentIndex; + do { + $previousLast = $lastSegment; + foreach ($captures as $capture) { + if (!$capture['present'] || $capture['first_segment'] > $lastSegment + || $capture['last_segment'] < $segmentIndex) { + continue; + } + $lastSegment = max($lastSegment, $capture['last_segment']); + } + } while ($lastSegment !== $previousLast); + + $params = []; + foreach ($captures as $index => $capture) { + if (!$capture['present'] || $capture['first_segment'] > $lastSegment + || $capture['last_segment'] < $segmentIndex) { + continue; + } + $params[] = $capture['name'] !== null + ? self::encodeParamName($capture['name']) + : 'param' . $index; + } + $normalized[] = '{' . implode('+', $params) . '}'; + $segmentIndex = $lastSegment; + } + + return '/' . implode('/', $normalized); + } + + /** + * Normalize a route that uses {param} notation. + */ + private static function normalizeBraceRoute(string $route, array $matchedParams) + { + $route = trim($route); + if ($route === '' || $route === '/') { + return '/'; + } + + $trailingSlash = (strlen($route) > 1 && substr($route, -1) === '/') ? '/' : ''; + $route = rtrim($route, '/'); + + if ($route[0] !== '/') { + $route = '/' . $route; + } + + // Strip inline constraints (e.g. {name:[^/]+} → {name}) before + // splitting so that a '/' inside a constraint does not break the segment + // split. The optional marker '?' is preserved: {name?:[0-9]+} → {name?}. + $route = preg_replace('/\{([^}?:]+(\?)?):([^}]*)\}/', '{$1}', $route); + + $raw = ltrim($route, '/'); + $parts = explode('/', $raw); + $normalizedSegments = []; + + foreach ($parts as $segment) { + if ($segment === '') { + continue; + } + + $result = self::normalizeBraceSegment($segment, $matchedParams); + if ($result === null) { + continue; + } + + $normalizedSegments[] = $result; + } + + return '/' . implode('/', $normalizedSegments) . $trailingSlash; + } + + /** + * Normalize a single URL segment that may contain {param} placeholders. + * + * @return string|null The normalized element, or null if the segment is optional and absent + * with no remaining static text + */ + private static function normalizeBraceSegment(string $segment, array $matchedParams) + { + preg_match_all('/\{([^}]+)\}/', $segment, $matches, PREG_SET_ORDER); + + if (empty($matches)) { + return self::encodeStaticSegment($segment); + } + + $paramNames = []; + foreach ($matches as $match) { + $raw = $match[1]; + + $isOptional = (substr($raw, -1) === '?'); + if ($isOptional) { + $raw = substr($raw, 0, -1); + } + + $colon = strpos($raw, ':'); + if ($colon !== false) { + $raw = substr($raw, 0, $colon); + } + + $name = trim($raw); + + if ($isOptional && !array_key_exists($name, $matchedParams)) { + continue; + } + + $paramNames[] = self::encodeParamName($name); + } + + if (empty($paramNames)) { + // All params were optional and absent. + // Preserve any static text remaining in the segment (e.g. "search.{_format?}" → "search"). + // rtrim only: a leading special char (e.g. '~foo.{ext?}') must survive. + $staticOnly = preg_replace('/\{[^}]+\}/', '', $segment); + $staticOnly = rtrim($staticOnly, '.-_~'); + if ($staticOnly !== '') { + return self::encodeStaticSegment($staticOnly); + } + return null; + } + + if (count($paramNames) === 1) { + return '{' . $paramNames[0] . '}'; + } + + return '{' . implode('+', $paramNames) . '}'; + } + + /** + * Expand Laminas [...] optional sections based on matched params. + * + * When $urlPath is provided, an optional section is only expanded if the + * section text with param values substituted is a substring of $urlPath. + * This prevents middleware-injected params from incorrectly triggering + * expansion of sections absent from the URL. + * + * For static-only optional sections (no params), the URL path is also checked + * to determine whether the literal text appeared in the request. + */ + private static function expandBracketOptionals( + string $template, + array $matchedParams, + string $paramPrefix = ':', + $urlPath = null + ): string { + $prev = null; + while ($prev !== $template) { + $prev = $template; + $template = preg_replace_callback( + '/\[([^\[\]]*)\]/', + function ($m) use ($matchedParams, $paramPrefix, $urlPath) { + $inner = $m[1]; + $pattern = '/' . preg_quote($paramPrefix, '/') . '([a-zA-Z_][a-zA-Z0-9_-]*)/'; + preg_match_all($pattern, $inner, $pm); + $innerParams = $pm[1]; + + if (empty($innerParams)) { + // Static-only optional section (e.g. [/draft]): + // only expand when the literal text appears in the URL + // at a position > 0 (never at the very start, since optional + // sections always follow mandatory route text). + if ($urlPath !== null) { + return (strpos($urlPath, $inner) > 0) ? $inner : ''; + } + return $inner; + } + + // All params in the section must be present in matched params. + foreach ($innerParams as $param) { + if (!array_key_exists($param, $matchedParams)) { + return ''; + } + } + + if ($urlPath !== null) { + // Substitute every param value before checking the URL so that + // multi-param sections like [/:year/:month] are found correctly. + // Use a word-boundary-aware replacement so :id is not replaced + // inside :id2 (str_replace(':id', ...) would corrupt ':id2'). + // Check position > 0: optional sections always follow mandatory + // route text so a match at position 0 is a false positive (e.g. + // the default value is identical to the mandatory route prefix). + $innerWithValues = $inner; + foreach ($innerParams as $param) { + $value = (string)$matchedParams[$param]; + $innerWithValues = preg_replace( + '/' . preg_quote($paramPrefix . $param, '/') . '(?![a-zA-Z0-9_-])/', + $value, + $innerWithValues + ); + } + if (strpos($urlPath, $innerWithValues) > 0) { + return $inner; + } + // Try percent-encoded values (Laminas URL-decodes param values). + // Also try lowercase hex since browsers may send %c3%a9 for %C3%A9. + $innerEncoded = $inner; + foreach ($innerParams as $param) { + $value = rawurlencode((string)$matchedParams[$param]); + $innerEncoded = preg_replace( + '/' . preg_quote($paramPrefix . $param, '/') . '(?![a-zA-Z0-9_-])/', + $value, + $innerEncoded + ); + } + if (strpos($urlPath, $innerEncoded) > 0 || + strpos(strtolower($urlPath), strtolower($innerEncoded)) > 0) { + return $inner; + } + return ''; + } + + return $inner; + }, + $template + ); + } + return $template; + } + + /** + * Convert ":paramName" colon-prefix notation to "{paramName}" brace notation. + * Laminas segment constraints like ":param{constraint}" are also handled. + * Hyphenated param names like ":user-id" are supported. + */ + private static function colonParamsToBraces(string $template): string + { + return preg_replace_callback( + '/:([a-zA-Z_][a-zA-Z0-9_-]*)(?:\{[^}]*\})?/', + static function ($m) { + return '{' . $m[1] . '}'; + }, + $template + ); + } + + /** + * Convert Laminas Regex route spec %param% notation to {param} brace notation. + * Regex routes store their spec as "/path/%id%/%name%" for URL generation. + */ + private static function percentParamsToBraces(string $template): string + { + return preg_replace('/%([a-zA-Z_][a-zA-Z0-9_]*)%/', '{$1}', $template); + } + + /** + * Find a param name of the form "paramN" that does not already appear in $template + * as either a colon-param (:paramN) or a brace-param ({paramN}). + */ + private static function uniqueParamName(string $template, string $paramPrefix = ':'): string + { + $i = 1; + while ( + // Use regex so ':param1' doesn't falsely match inside ':param10' + preg_match('/' . preg_quote($paramPrefix . 'param' . $i, '/') . '(?![0-9])/', $template) || + strpos($template, '{param' . $i . '}') !== false || + strpos($template, '%param' . $i . '%') !== false + ) { + $i++; + } + return 'param' . $i; + } + + /** + * URL-encode characters in a static segment that are outside [A-Za-z0-9.-~_]. + * Already-encoded percent sequences are left intact (hex digits uppercased). + */ + public static function encodeStaticSegment(string $segment): string + { + $result = ''; + $len = strlen($segment); + for ($i = 0; $i < $len; $i++) { + $c = $segment[$i]; + if ( + ($c >= 'A' && $c <= 'Z') || ($c >= 'a' && $c <= 'z') || + ($c >= '0' && $c <= '9') || + $c === '.' || $c === '-' || $c === '~' || $c === '_' + ) { + $result .= $c; + } elseif ( + $c === '%' && + $i + 2 < $len && + ctype_xdigit($segment[$i + 1]) && + ctype_xdigit($segment[$i + 2]) + ) { + $result .= '%' . strtoupper($segment[$i + 1]) . strtoupper($segment[$i + 2]); + $i += 2; + } else { + $result .= rawurlencode($c); + } + } + return $result; + } + + /** + * URL-encode reserved characters in a parameter name. + * Reserved: /?#+{} — these must not appear literally in a parameter name. + * The '+' combining marker must be encoded if it appears in a framework-supplied name. + */ + public static function encodeParamName(string $name): string + { + $reserved = '/?#+{}'; + $result = ''; + $len = strlen($name); + for ($i = 0; $i < $len; $i++) { + $c = $name[$i]; + if (strpos($reserved, $c) !== false) { + $result .= rawurlencode($c); + } else { + $result .= $c; + } + } + return $result; + } +} diff --git a/src/api/Tag.php b/src/api/Tag.php index f2cb6b7c1e4..ca264c04db6 100644 --- a/src/api/Tag.php +++ b/src/api/Tag.php @@ -26,6 +26,7 @@ class Tag const ERROR_STACK = 'error.stack'; // human readable version of the stack const HTTP_METHOD = 'http.method'; const HTTP_ROUTE = 'http.route'; + const APPSEC_NORMALIZED_ROUTE = '_dd.appsec.normalized_route'; const HTTP_STATUS_CODE = 'http.status_code'; const HTTP_URL = 'http.url'; const HTTP_VERSION = 'http.version'; diff --git a/src/bridge/_files_tracer.php b/src/bridge/_files_tracer.php index 7d924b7fe7c..fccea720f0c 100644 --- a/src/bridge/_files_tracer.php +++ b/src/bridge/_files_tracer.php @@ -41,4 +41,5 @@ __DIR__ . '/../DDTrace/Propagators/TextMap.php', __DIR__ . '/../DDTrace/ScopeManager.php', __DIR__ . '/../DDTrace/Tracer.php', + __DIR__ . '/../DDTrace/Util/RouteNormalizer.php', ]; diff --git a/tests/Frameworks/Laravel/Version_8_x/routes/web.php b/tests/Frameworks/Laravel/Version_8_x/routes/web.php index 3a9ccc2ebfc..9b747666324 100644 --- a/tests/Frameworks/Laravel/Version_8_x/routes/web.php +++ b/tests/Frameworks/Laravel/Version_8_x/routes/web.php @@ -56,3 +56,17 @@ // This route has to remain unnamed so we test both route cached and not cached. Route::get('/unnamed-route', [RouteCachingController::class, 'unnamed']); + +Route::get('/normalized-optional/{value?}', function ($value = null) { + return response($value ?? 'absent'); +}); + +Route::get('/normalized-default/{format?}', function ($format = null) { + return response($format); +})->defaults('format', 'html'); + +Route::get('/normalized-ambiguous/{name}.{ext?}', function ($name, $ext = null) { + return response($name . '/' . ($ext ?? 'absent')); +})->where('name', '.+') + ->where('ext', 'pdf|json') + ->defaults('ext', 'html'); diff --git a/tests/Frameworks/Symfony/Version_6_2/src/Controller/HomeController.php b/tests/Frameworks/Symfony/Version_6_2/src/Controller/HomeController.php index 38cbcaa27eb..dd1da99a1c5 100644 --- a/tests/Frameworks/Symfony/Version_6_2/src/Controller/HomeController.php +++ b/tests/Frameworks/Symfony/Version_6_2/src/Controller/HomeController.php @@ -37,4 +37,65 @@ public function utf8Action(Request $request, string $item) "Café: $item" ); } + + #[Route("/article/{slug}.{_format}", name: "article_mixed", requirements: ["_format" => "html|json|xml"])] + public function normalizedMixedAction(Request $request, string $slug, string $_format) + { + return new Response( + "$slug.$_format" + ); + } + + #[Route("/posts/{page}", name: "posts_optional_page", defaults: ["page" => 1])] + public function postsAction(Request $request, int $page) + { + return new Response("posts page: $page"); + } + + #[Route("/normalized/mixed/{id}.{_format}", name: "normalized_mixed")] + public function normalizedMixedIdAction(Request $request) + { + return new Response('Mixed route'); + } + + #[Route("/normalized/zero/{id}", name: "normalized_zero")] + public function normalizedZeroAction(Request $request) + { + return new Response('Zero route'); + } + + #[Route( + "/normalized/search.{_format}", + name: "normalized_static_optional", + defaults: ["_format" => null] + )] + public function normalizedStaticOptionalAction(Request $request) + { + return new Response('Optional format route'); + } + + #[Route( + "/normalized/utf8/{föo}", + name: "normalized_utf8_optional", + defaults: ["föo" => null] + )] + public function normalizedUtf8OptionalAction(Request $request) + { + return new Response('UTF-8 parameter route'); + } + + #[Route( + "/normalized/ambiguous/{slug}.{format}", + name: "normalized_ambiguous_mixed", + defaults: ["format" => "html"], + requirements: ["slug" => ".+", "format" => "html|json"] + )] + public function normalizedAmbiguousMixedAction( + Request $request, + string $slug, + string $format + ) + { + return new Response("Ambiguous mixed route: $slug/$format"); + } } diff --git a/tests/Unit/Util/Normalizer/RouteNormalizerTest.php b/tests/Unit/Util/Normalizer/RouteNormalizerTest.php new file mode 100644 index 00000000000..71f2c3d6945 --- /dev/null +++ b/tests/Unit/Util/Normalizer/RouteNormalizerTest.php @@ -0,0 +1,410 @@ +assertSame('hello', RouteNormalizer::encodeStaticSegment('hello')); + $this->assertSame('Hello-World_v1.0~test', RouteNormalizer::encodeStaticSegment('Hello-World_v1.0~test')); + } + + public function testEncodeStaticSegmentEncodesReserved() + { + $this->assertSame('dump-request', RouteNormalizer::encodeStaticSegment('dump-request')); + $this->assertSame('foo%40bar', RouteNormalizer::encodeStaticSegment('foo@bar')); + $this->assertSame('foo%20bar', RouteNormalizer::encodeStaticSegment('foo bar')); + } + + public function testEncodeStaticSegmentPreservesExistingPercentEncoding() + { + $this->assertSame('%2F', RouteNormalizer::encodeStaticSegment('%2F')); + $this->assertSame('%2F', RouteNormalizer::encodeStaticSegment('%2f')); + } + + // encodeParamName + + public function testEncodeParamNamePreservesNormal() + { + $this->assertSame('id', RouteNormalizer::encodeParamName('id')); + $this->assertSame('user_id', RouteNormalizer::encodeParamName('user_id')); + } + + public function testEncodeParamNameEncodesPlusSign() + { + $this->assertSame('foo%2Bbar', RouteNormalizer::encodeParamName('foo+bar')); + } + + public function testEncodeParamNameEncodesReserved() + { + $this->assertSame('foo%23bar', RouteNormalizer::encodeParamName('foo#bar')); + } + + // normalizeFromLaravel + + public function testLaravelSimpleRoute() + { + $this->assertSame('/users', RouteNormalizer::normalizeFromLaravel('/users')); + $this->assertSame('/users/{id}', RouteNormalizer::normalizeFromLaravel('/users/{id}')); + } + + public function testLaravelOptionalParamPresent() + { + $result = RouteNormalizer::normalizeFromLaravel('/users/{id}/{format?}', ['id' => '1', 'format' => 'json']); + $this->assertSame('/users/{id}/{format}', $result); + } + + public function testLaravelOptionalParamAbsent() + { + $result = RouteNormalizer::normalizeFromLaravel('/users/{id}/{format?}', ['id' => '1']); + $this->assertSame('/users/{id}', $result); + } + + public function testLaravelMixedSegmentTwoParams() + { + // /photos/{id}.{format} → both in same URL segment → combined + $result = RouteNormalizer::normalizeFromLaravel('/photos/{id}.{format}', ['id' => '1', 'format' => 'jpg']); + $this->assertSame('/photos/{id+format}', $result); + } + + public function testLaravelMixedSegmentOptionalFormat() + { + // /posts/:id(.:format) style — optional format present + $result = RouteNormalizer::normalizeFromLaravel('/posts/{id}/{format?}', ['id' => '1', 'format' => 'json']); + $this->assertSame('/posts/{id}/{format}', $result); + + // optional format absent + $result = RouteNormalizer::normalizeFromLaravel('/posts/{id}/{format?}', ['id' => '1']); + $this->assertSame('/posts/{id}', $result); + } + + public function testLaravelRequiredParamBesideAbsentOptional() + { + // {name} is required; {ext?} is absent — must keep {name}, not drop the whole segment + $result = RouteNormalizer::normalizeFromLaravel('/files/{name}.{ext?}', ['name' => 'foo']); + $this->assertSame('/files/{name}', $result); + } + + public function testLaravelRequiredParamBesideAbsentOptionalBothPresent() + { + $result = RouteNormalizer::normalizeFromLaravel('/files/{name}.{ext?}', ['name' => 'foo', 'ext' => 'txt']); + $this->assertSame('/files/{name+ext}', $result); + } + + public function testLaravelDeeperRoute() + { + $result = RouteNormalizer::normalizeFromLaravel('/dashboard/shared_widget_update/{id}/{widget_id}'); + $this->assertSame('/dashboard/shared_widget_update/{id}/{widget_id}', $result); + } + + public function testLaravelTrailingSlash() + { + $result = RouteNormalizer::normalizeFromLaravel('/users/{id}/'); + $this->assertSame('/users/{id}/', $result); + } + + public function testLaravelRoot() + { + $this->assertSame('/', RouteNormalizer::normalizeFromLaravel('/')); + } + + // normalizeFromSymfony + + public function testSymfonySimpleRoute() + { + $this->assertSame('/sleep/{seconds}', RouteNormalizer::normalizeFromSymfony('/sleep/{seconds}')); + } + + public function testSymfonyMixedSegment() + { + // Symfony may produce routes like /posts/{id}.{_format} + $result = RouteNormalizer::normalizeFromSymfony('/posts/{id}.{_format}'); + $this->assertSame('/posts/{id+_format}', $result); + } + + public function testSymfonyStaticOnlyRoute() + { + $this->assertSame('/dump-request', RouteNormalizer::normalizeFromSymfony('/dump-request')); + } + + public function testSymfonyOptionalParamAbsent() + { + // /blog/{page} requested as /blog — page has a default and was not in the URL + $result = RouteNormalizer::normalizeFromSymfony('/blog/{page}', []); + $this->assertSame('/blog', $result); + } + + public function testSymfonyOptionalParamPresent() + { + // /blog/{page} requested as /blog/2 — page was in the URL + $result = RouteNormalizer::normalizeFromSymfony('/blog/{page}', ['page' => '2']); + $this->assertSame('/blog/{page}', $result); + } + + public function testSymfonyRequiredParamsAlwaysKept() + { + // All params present — nothing dropped + $result = RouteNormalizer::normalizeFromSymfony('/users/{id}/posts/{post_id}', ['id' => '1', 'post_id' => '5']); + $this->assertSame('/users/{id}/posts/{post_id}', $result); + } + + public function testSymfonyTrailingOptionalAbsent() + { + // /users/{id}/posts/{post_id} with only id in URL — post_id absent + $result = RouteNormalizer::normalizeFromSymfony('/users/{id}/posts/{post_id}', ['id' => '1']); + $this->assertSame('/users/{id}/posts', $result); + } + + public function testSymfonyNoMatchedParamsArgKeepsAll() + { + // null matchedParams → old behaviour, no params dropped + $result = RouteNormalizer::normalizeFromSymfony('/blog/{page}'); + $this->assertSame('/blog/{page}', $result); + } + + // normalizeFromLaminas + + public function testLaminasSimpleColon() + { + $this->assertSame('/users/{id}', RouteNormalizer::normalizeFromLaminas('/users/:id')); + } + + public function testLaminasOptionalPresent() + { + $result = RouteNormalizer::normalizeFromLaminas('/users/:id[.:format]', ['id' => '1', 'format' => 'json']); + $this->assertSame('/users/{id+format}', $result); + } + + public function testLaminasOptionalAbsent() + { + $result = RouteNormalizer::normalizeFromLaminas('/users/:id[.:format]', ['id' => '1']); + $this->assertSame('/users/{id}', $result); + } + + public function testLaminasMultiParamOptionalPresent() + { + // Both params in the section present and appear in the URL → expand + $result = RouteNormalizer::normalizeFromLaminas( + '/archive[/:year/:month]', + ['year' => '2024', 'month' => '08'], + '/archive/2024/08' + ); + $this->assertSame('/archive/{year}/{month}', $result); + } + + public function testLaminasMultiParamOptionalAbsent() + { + // Both params injected by middleware but absent from URL → do not expand + $result = RouteNormalizer::normalizeFromLaminas( + '/archive[/:year/:month]', + ['year' => '2024', 'month' => '08'], + '/archive' + ); + $this->assertSame('/archive', $result); + } + + public function testLaminasNestedOptionalBothPresent() + { + $result = RouteNormalizer::normalizeFromLaminas( + '/foo[/:bar[/:baz]]', + ['bar' => 'a', 'baz' => 'b'], + '/foo/a/b' + ); + $this->assertSame('/foo/{bar}/{baz}', $result); + } + + public function testLaminasNestedOptionalOnlyOuterPresent() + { + $result = RouteNormalizer::normalizeFromLaminas( + '/foo[/:bar[/:baz]]', + ['bar' => 'a'], + '/foo/a' + ); + $this->assertSame('/foo/{bar}', $result); + } + + public function testLaminasRegexRouteSpec() + { + // Laminas\Router\Http\Regex uses %param% spec format for URL generation + $this->assertSame('/blog/{id}', RouteNormalizer::normalizeFromLaminas('/blog/%id%')); + $this->assertSame('/user/{id}/{name}', RouteNormalizer::normalizeFromLaminas('/user/%id%/%name%')); + } + + public function testLaminasRegexRouteOptionalFormatAbsent() + { + // Route defaults inject format='html' even when the URL has no .html extension. + // Only params actually present in the URL path should appear in the normalized route. + $result = RouteNormalizer::normalizeFromLaminas( + '/normalized-regex/%id%.%format%', + ['id' => 'article', 'format' => 'html', 'controller' => 'C', 'action' => 'index'], + '/normalized-regex/article' + ); + $this->assertSame('/normalized-regex/{id}', $result); + } + + public function testLaminasRegexRouteOptionalFormatPresent() + { + $result = RouteNormalizer::normalizeFromLaminas( + '/normalized-regex/%id%.%format%', + ['id' => 'article', 'format' => 'html', 'controller' => 'C', 'action' => 'index'], + '/normalized-regex/article.html' + ); + $this->assertSame('/normalized-regex/{id+format}', $result); + } + + public function testLaminasLiteralRoute() + { + $this->assertSame('/dump-request', RouteNormalizer::normalizeFromLaminas('/dump-request')); + } + + public function testLaminasWildcard() + { + // Wildcard routes produce '/*' from laminasSegmentPartsToRouteTemplate + $result = RouteNormalizer::normalizeFromLaminas('/*'); + $this->assertSame('/{param1}', $result); + } + + // normalizeFromWordPress + + public function testWordPressSimpleRegex() + { + $result = RouteNormalizer::normalizeFromWordPress('^blog/([^/]+)/?$'); + $this->assertSame('/blog/{param1}', $result); + } + + public function testWordPressStaticRule() + { + $result = RouteNormalizer::normalizeFromWordPress('^about/?$'); + $this->assertSame('/about', $result); + } + + public function testWordPressMultipleGroups() + { + $result = RouteNormalizer::normalizeFromWordPress('^([^/]+)/([^/]+)/?$'); + $this->assertSame('/{param1}/{param2}', $result); + } + + public function testWordPressOptionalGroupAbsent() + { + // Optional second segment not present in URL — must not emit phantom {param2} + $result = RouteNormalizer::normalizeFromWordPress('^([^/]+)(?:/([0-9]+))?/?$', 'simple'); + $this->assertSame('/{param1}', $result); + } + + public function testWordPressOptionalGroupPresent() + { + $result = RouteNormalizer::normalizeFromWordPress('^([^/]+)(?:/([0-9]+))?/?$', 'simple/123'); + $this->assertSame('/{param1}/{param2}', $result); + } + + public function testWordPressOptionalGroupNoUrlPath() + { + // Without URL path, fall back to emitting all groups (backward-compatible) + $result = RouteNormalizer::normalizeFromWordPress('^([^/]+)(?:/([0-9]+))?/?$'); + $this->assertSame('/{param1}/{param2}', $result); + } + + public function testWordPressRootRule() + { + $result = RouteNormalizer::normalizeFromWordPress('^/?$'); + $this->assertSame('/', $result); + } + + public function testWordPressMultipleCaptureGroupsInOneSegment() + { + // Two capture groups in the same slash-separated segment → combined with + + // The static prefix "post-" is dropped as the whole mixed segment is treated as dynamic + $result = RouteNormalizer::normalizeFromWordPress('^post-([^/]+)-([0-9]+)/?$'); + $this->assertSame('/{param1+param2}', $result); + } + + public function testWordPressStaticPrefixNotSeparateElement() + { + // F-07: static prefix before a capture must NOT become a separate segment element. + // "post-([^/]+)" is a single URL segment → one RFC element. + $result = RouteNormalizer::normalizeFromWordPress('^post-([^/]+)$', 'post-hello'); + $this->assertSame('/{param1}', $result); + } + + public function testWordPressAbsentOptionalCaptureSkipped() + { + // F-11: when an inner optional capture did not participate (empty string in captures), + // it must not produce a phantom {paramN}. + $result = RouteNormalizer::normalizeFromWordPress('^(?:([^/]+)-)?([^/]+)$', 'x'); + $this->assertSame('/{param2}', $result); + } + + public function testWordPressBothCapturesPresentInOptionalGroup() + { + $result = RouteNormalizer::normalizeFromWordPress('^(?:([^/]+)-)?([^/]+)$', 'foo-x'); + $this->assertSame('/{param1+param2}', $result); + } + + public function testStaticPrefixLeadingTildePreservedWhenOptionalAbsent() + { + // F-04: rtrim — a leading special char like '~' must survive when the optional + // param is absent. Old behaviour: trim('~foo.', '.-_~') = 'foo'. Fixed: rtrim. + $result = RouteNormalizer::normalizeFromLaravel('~foo.{ext?}', []); + $this->assertSame('/~foo', $result); + } + + public function testLaminasWildcardAfterPercentParam() + { + // F-10: uniqueParamName must skip %param1% when choosing a name for the wildcard. + $result = RouteNormalizer::normalizeFromLaminas('/foo/%param1%/*'); + $this->assertSame('/foo/{param1}/{param2}', $result); + } + + // RFC examples + + public function testRfcExampleFastApi() + { + // http.route: /dashboard/shared_widget_update/{id}/{widget_id} + $result = RouteNormalizer::normalizeFromLaravel('/dashboard/shared_widget_update/{id}/{widget_id}'); + $this->assertSame('/dashboard/shared_widget_update/{id}/{widget_id}', $result); + } + + public function testRfcExampleDjangoDumpRequest() + { + // http.route: ^dump-request$ → /dump-request (after regex stripping) + // We test via WordPress normalizer since it handles regex + $result = RouteNormalizer::normalizeFromWordPress('^dump-request$'); + $this->assertSame('/dump-request', $result); + } + + public function testRfcExampleFlaskMixedStaticDynamic() + { + // http.route: /users/user- → /users/{id} + // Flask wraps static+dynamic in same segment; normalizer drops static prefix + $result = RouteNormalizer::normalizeFromLaravel('/users/{id}'); + $this->assertSame('/users/{id}', $result); + } + + public function testRfcExampleRailsMandatoryFormat() + { + // http.route: /photos/:id.:format → /photos/{id+format} + // Laminas uses the same :param syntax as CakePHP/Rails for this pattern. + $result = RouteNormalizer::normalizeFromLaminas('/photos/:id.:format'); + $this->assertSame('/photos/{id+format}', $result); + } + + public function testRfcExampleRailsOptionalFormatPresent() + { + // /posts/:id(.:format) with format present → /posts/{id+format} + $result = RouteNormalizer::normalizeFromLaminas('/posts/:id[.:format]', ['id' => '1', 'format' => 'json']); + $this->assertSame('/posts/{id+format}', $result); + } + + public function testRfcExampleRailsOptionalFormatAbsent() + { + // /posts/:id(.:format) without format → /posts/{id} + $result = RouteNormalizer::normalizeFromLaminas('/posts/:id[.:format]', ['id' => '1']); + $this->assertSame('/posts/{id}', $result); + } +} diff --git a/tests/api/Unit/UserAvailableConstantsTest.php b/tests/api/Unit/UserAvailableConstantsTest.php index 0df908057d9..d2174d9810d 100644 --- a/tests/api/Unit/UserAvailableConstantsTest.php +++ b/tests/api/Unit/UserAvailableConstantsTest.php @@ -110,6 +110,7 @@ public function tags() [Tag::ERROR_STACK, 'error.stack'], [Tag::HTTP_METHOD, 'http.method'], [Tag::HTTP_ROUTE, 'http.route'], + [Tag::APPSEC_NORMALIZED_ROUTE, '_dd.appsec.normalized_route'], [Tag::HTTP_STATUS_CODE, 'http.status_code'], [Tag::HTTP_URL, 'http.url'], [Tag::HTTP_VERSION, 'http.version'], diff --git a/tests/ext/routing_cache/cache_capacity_eviction.phpt b/tests/ext/routing_cache/cache_capacity_eviction.phpt new file mode 100644 index 00000000000..7122ac864c7 --- /dev/null +++ b/tests/ext/routing_cache/cache_capacity_eviction.phpt @@ -0,0 +1,26 @@ +--TEST-- +DDTrace\routing_cache evicts the oldest inserted entry when capacity (500) is exceeded +--FILE-- + +--EXPECT-- +string(6) "value0" +bool(false) +string(6) "value1" +string(8) "value500" diff --git a/tests/ext/routing_cache/cache_miss_returns_false.phpt b/tests/ext/routing_cache/cache_miss_returns_false.phpt new file mode 100644 index 00000000000..1138b0ad30e --- /dev/null +++ b/tests/ext/routing_cache/cache_miss_returns_false.phpt @@ -0,0 +1,14 @@ +--TEST-- +DDTrace\routing_cache_get returns false on cache miss +--FILE-- + +--EXPECT-- +bool(false) +bool(false) +bool(false) diff --git a/tests/ext/routing_cache/cache_set_and_get.phpt b/tests/ext/routing_cache/cache_set_and_get.phpt new file mode 100644 index 00000000000..0f75a2f4aba --- /dev/null +++ b/tests/ext/routing_cache/cache_set_and_get.phpt @@ -0,0 +1,21 @@ +--TEST-- +DDTrace\routing_cache_set stores and DDTrace\routing_cache_get retrieves values +--FILE-- + +--EXPECT-- +string(15) "/api/users/{id}" +string(12) "/blog/{slug}" +string(15) "/api/users/{id}" +bool(false) diff --git a/tests/ext/routing_cache/cache_update_existing_key.phpt b/tests/ext/routing_cache/cache_update_existing_key.phpt new file mode 100644 index 00000000000..c7feb2ec8a6 --- /dev/null +++ b/tests/ext/routing_cache/cache_update_existing_key.phpt @@ -0,0 +1,15 @@ +--TEST-- +DDTrace\routing_cache_set updates value for existing key +--FILE-- + +--EXPECT-- +string(5) "first" +string(7) "updated" diff --git a/tracer/configuration.h b/tracer/configuration.h index 892cdce0715..127d3227f5f 100644 --- a/tracer/configuration.h +++ b/tracer/configuration.h @@ -164,6 +164,7 @@ CONFIG(BOOL, DD_TRACE_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT, "false") \ CONFIG(BOOL, DD_TRACE_STATS_COMPUTATION_ENABLED, "false") \ CONFIG(BOOL, DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, "false") \ + CONFIG(BOOL, DD_API_SECURITY_ENABLED, "true", .ini_change = zai_config_system_ini_change) \ DD_INTEGRATIONS #ifndef DDTRACE_CONFIGURATION diff --git a/tracer/ddtrace.c b/tracer/ddtrace.c index df9b73ef12c..6b49697402e 100644 --- a/tracer/ddtrace.c +++ b/tracer/ddtrace.c @@ -1,3 +1,4 @@ +#include "routing_cache.h" #include "components-rs/common.h" #include "components-rs/sidecar.h" #include "zend_API.h" @@ -244,6 +245,7 @@ void ddtrace_ginit(zend_datadog_globals *ddtrace_globals) { UNUSED(ddtrace_globals); #endif zai_hook_ginit(); + ddtrace_routing_cache_ginit(&ddtrace_globals->ddtrace.rcache); } void ddtrace_gshutdown(zend_datadog_globals *datadog_globals) { @@ -252,6 +254,7 @@ void ddtrace_gshutdown(zend_datadog_globals *datadog_globals) { if (datadog_globals->ddtrace.agent_config_reader) { ddog_agent_remote_config_reader_drop(datadog_globals->ddtrace.agent_config_reader); } + ddtrace_routing_cache_gshutdown(&datadog_globals->ddtrace.rcache); } diff --git a/tracer/ddtrace_arginfo.h b/tracer/ddtrace_arginfo.h index afa0f62d9f7..db27f813c8d 100644 --- a/tracer/ddtrace_arginfo.h +++ b/tracer/ddtrace_arginfo.h @@ -38,6 +38,15 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_set_user, 0, 1, IS_VOID, ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, propagate, _IS_BOOL, 1, "null") ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_DDTrace_routing_cache_get, 0, 1, MAY_BE_STRING|MAY_BE_FALSE) + ZEND_ARG_TYPE_INFO(0, key, IS_STRING, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_routing_cache_set, 0, 2, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, key, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, value, IS_STRING, 0) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_DDTrace_close_spans_until, 0, 1, MAY_BE_FALSE|MAY_BE_LONG) ZEND_ARG_OBJ_INFO(0, span, DDTrace\\SpanData, 1) ZEND_END_ARG_INFO() @@ -473,6 +482,8 @@ ZEND_FUNCTION(DDTrace_trace_function); ZEND_FUNCTION(DDTrace_trace_method); ZEND_FUNCTION(dd_untrace); ZEND_FUNCTION(dd_trace_synchronous_flush); +ZEND_FUNCTION(DDTrace_routing_cache_get); +ZEND_FUNCTION(DDTrace_routing_cache_set); ZEND_METHOD(DDTrace_SpanEvent, __construct); ZEND_METHOD(DDTrace_SpanEvent, jsonSerialize); ZEND_METHOD(DDTrace_ExceptionSpanEvent, __construct); @@ -549,6 +560,8 @@ static const zend_function_entry ext_functions[] = { ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Internal", "flush_ffe_evaluation_metrics"), zif_DDTrace_Internal_flush_ffe_evaluation_metrics, arginfo_DDTrace_Internal_flush_ffe_evaluation_metrics, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("datadog\\appsec\\v2", "track_user_login_success"), zif_datadog_appsec_v2_track_user_login_success, arginfo_datadog_appsec_v2_track_user_login_success, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("datadog\\appsec\\v2", "track_user_login_failure"), zif_datadog_appsec_v2_track_user_login_failure, arginfo_datadog_appsec_v2_track_user_login_failure, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "routing_cache_get"), zif_DDTrace_routing_cache_get, arginfo_DDTrace_routing_cache_get, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "routing_cache_set"), zif_DDTrace_routing_cache_set, arginfo_DDTrace_routing_cache_set, 0, NULL, NULL) ZEND_FE(dd_trace_env_config, arginfo_dd_trace_env_config) ZEND_FE(dd_trace_disable_in_request, arginfo_dd_trace_disable_in_request) ZEND_FE(dd_trace_reset, arginfo_dd_trace_reset) diff --git a/tracer/ddtrace_globals.h b/tracer/ddtrace_globals.h index 062a02ca797..554f7b6cf76 100644 --- a/tracer/ddtrace_globals.h +++ b/tracer/ddtrace_globals.h @@ -113,6 +113,8 @@ typedef struct { HashTable resource_weak_storage; dtor_func_t resource_dtor_func; + HashTable rcache; + void *ffe_exposure_buffer; size_t ffe_exposure_buffer_len; size_t ffe_exposure_buffer_cap; diff --git a/tracer/routing_cache.c b/tracer/routing_cache.c new file mode 100644 index 00000000000..a69f7cd1cbe --- /dev/null +++ b/tracer/routing_cache.c @@ -0,0 +1,59 @@ +#include "routing_cache.h" +#include "ddtrace.h" + +ZEND_EXTERN_MODULE_GLOBALS(datadog); + +static void ddtrace_routing_cache_dtor(zval *pz) { + zend_string_release_ex((zend_string *)Z_PTR_P(pz), 1); +} + +static void ddtrace_routing_cache_evict_oldest(void) { + HashPosition pos; + zend_string *key; + zend_ulong num_idx; + + zend_hash_internal_pointer_reset_ex(&DDTRACE_G(rcache), &pos); + if (zend_hash_get_current_key_type_ex(&DDTRACE_G(rcache), &pos) == HASH_KEY_IS_STRING) { + zend_hash_get_current_key_ex(&DDTRACE_G(rcache), &key, &num_idx, &pos); + zend_hash_del(&DDTRACE_G(rcache), key); + } +} + +void ddtrace_routing_cache_ginit(HashTable *rcache) { + zend_hash_init(rcache, DDTRACE_ROUTING_CACHE_CAPACITY, NULL, ddtrace_routing_cache_dtor, 1); +} + +void ddtrace_routing_cache_gshutdown(HashTable *rcache) { + zend_hash_destroy(rcache); +} + +/* DDTrace\routing_cache_get(string $key): string|false */ +PHP_FUNCTION(DDTrace_routing_cache_get) { + zend_string *key; + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_STR(key) + ZEND_PARSE_PARAMETERS_END(); + + zend_string *value = zend_hash_find_ptr(&DDTRACE_G(rcache), key); + if (!value) { + RETURN_FALSE; + } + RETURN_STRINGL(ZSTR_VAL(value), ZSTR_LEN(value)); +} + +/* DDTrace\routing_cache_set(string $key, string $value): void */ +PHP_FUNCTION(DDTrace_routing_cache_set) { + zend_string *key, *value; + ZEND_PARSE_PARAMETERS_START(2, 2) + Z_PARAM_STR(key) + Z_PARAM_STR(value) + ZEND_PARSE_PARAMETERS_END(); + + if (zend_hash_num_elements(&DDTRACE_G(rcache)) >= DDTRACE_ROUTING_CACHE_CAPACITY + && !zend_hash_find_ptr(&DDTRACE_G(rcache), key)) { + ddtrace_routing_cache_evict_oldest(); + } + + zend_string *persistent_value = zend_string_init(ZSTR_VAL(value), ZSTR_LEN(value), 1); + zend_hash_str_update_ptr(&DDTRACE_G(rcache), ZSTR_VAL(key), ZSTR_LEN(key), persistent_value); +} diff --git a/tracer/routing_cache.h b/tracer/routing_cache.h new file mode 100644 index 00000000000..37961f5ba84 --- /dev/null +++ b/tracer/routing_cache.h @@ -0,0 +1,14 @@ +#ifndef DDTRACE_ROUTING_CACHE_H +#define DDTRACE_ROUTING_CACHE_H + +#include + +#define DDTRACE_ROUTING_CACHE_CAPACITY 500 + +void ddtrace_routing_cache_ginit(HashTable *rcache); +void ddtrace_routing_cache_gshutdown(HashTable *rcache); + +PHP_FUNCTION(DDTrace_routing_cache_get); +PHP_FUNCTION(DDTrace_routing_cache_set); + +#endif /* DDTRACE_ROUTING_CACHE_H */