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 08d0642e4e0..4ccea96c7fc 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 @@ -171,6 +171,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 @@ -209,12 +213,110 @@ class Laravel8xTests { endpoints.size() > 0 }) - assert endpoints.size() == 6 + assert endpoints.size() == 9 assert endpoints.find { it.path == '/' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /' } != null assert endpoints.find { it.path == 'authenticate' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET authenticate' } != null assert endpoints.find { it.path == 'register' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET register' } != 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 == 'sanctum/csrf-cookie' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET sanctum/csrf-cookie' } != 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 ccc149ba143..d03e089ae8e 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,12 +214,173 @@ class Symfony62Tests { endpoints.size() > 0 }) - assert endpoints.size() == 6 + assert endpoints.size() == 14 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 == '/login' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /login' } != 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 == '/register' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /register' } != null assert endpoints.find { it.path == '/caminho-dinamico/{param01}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /caminho-dinamico/{param01}' } != 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/laravel8x/routes/web.php b/appsec/tests/integration/src/test/www/laravel8x/routes/web.php index 916325368c4..7d601355627 100644 --- a/appsec/tests/integration/src/test/www/laravel8x/routes/web.php +++ b/appsec/tests/integration/src/test/www/laravel8x/routes/web.php @@ -21,3 +21,17 @@ Route::get('/authenticate', '\App\Http\Controllers\LoginController@authenticate'); Route::get('/register', '\App\Http\Controllers\LoginController@register'); Route::get('/dynamic-path/{param01}', '\App\Http\Controllers\MiscController@dynamicPath'); + +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/appsec/tests/integration/src/test/www/symfony62/src/Controller/HomeController.php b/appsec/tests/integration/src/test/www/symfony62/src/Controller/HomeController.php index 38cbcaa27eb..dd1da99a1c5 100644 --- a/appsec/tests/integration/src/test/www/symfony62/src/Controller/HomeController.php +++ b/appsec/tests/integration/src/test/www/symfony62/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/appsec/tests/integration/src/test/www/wordpress/initialize.sh b/appsec/tests/integration/src/test/www/wordpress/initialize.sh index 7ba2e9e4991..9ecbd00c962 100755 --- a/appsec/tests/integration/src/test/www/wordpress/initialize.sh +++ b/appsec/tests/integration/src/test/www/wordpress/initialize.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 f89e3461e36..8f6fd16a38f 100644 --- a/config.m4 +++ b/config.m4 @@ -247,6 +247,7 @@ if test "$PHP_DDTRACE" != "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 716aed1c91a..fe15c9499e6 100644 --- a/config.w32 +++ b/config.w32 @@ -69,6 +69,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..f87728ded4f 100644 --- a/src/DDTrace/Integrations/Laminas/LaminasIntegration.php +++ b/src/DDTrace/Integrations/Laminas/LaminasIntegration.php @@ -284,6 +284,86 @@ 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; + // Build a stable cache key that encodes only which optional + // components participated, not the raw URL (which would cause + // one cache entry per distinct request value — 500-entry churn). + // + // Regex routes (%param% spec): use the route's actual regex to + // determine which named captures matched the URL. + // Bracket routes ([/:param]): collect optional colon-params whose + // values appear in the URL as a presence key; also include + // static-only optional sections. + // Fully-required routes: template alone is sufficient. + $urlMatchedFromRegex = null; + if ($urlPath !== null && strpos($httpRoute, '%') !== false) { + // Regex route: use actual route regex for accurate presence + $_leafRoute = self::getLeafRouteFromNamedRouteStack($this, (string) $routeName); + if ($_leafRoute instanceof \Laminas\Router\Http\Regex) { + $_rp = new ReflectionProperty($_leafRoute, 'regex'); + $_rp->setAccessible(true); + $_routeRegex = $_rp->getValue($_leafRoute); + if ($_routeRegex !== null && + @preg_match('(^' . $_routeRegex . '$)', $urlPath, $_rxm) === 1) { + $urlMatchedFromRegex = []; + foreach ($_rxm as $_k => $_v) { + if (!is_string($_k) || $_v === '') { + continue; + } + $urlMatchedFromRegex[$_k] = rawurldecode($_v); + } + } + unset($_rp, $_routeRegex, $_rxm, $_k, $_v); + } + unset($_leafRoute); + $_braceTemp = preg_replace('/%([a-zA-Z_][a-zA-Z0-9_]*)%/', '{$1}', $httpRoute); + $_urlMatchedKeys = array_keys( + $urlMatchedFromRegex ?? \DDTrace\Util\RouteNormalizer::inferSymfonyRouteParams($_braceTemp, $urlPath) + ); + sort($_urlMatchedKeys); + $cacheKey = $httpRoute . '#' . implode(',', $_urlMatchedKeys); + unset($_braceTemp, $_urlMatchedKeys); + } elseif (strpos($httpRoute, '[') !== false) { + // Bracket-optional route: encode which optional colon-params + // have values that appear in the URL path (position > 0 to + // avoid matching the mandatory route prefix with a default). + // Also include static-only optional sections ([/draft] etc.) + // so absent/present shapes get distinct cache keys. + preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_-]*)/', $httpRoute, $_pm); + $_present = []; + foreach ($_pm[1] as $_p) { + if (isset($allParams[$_p]) && $urlPath !== null && + strpos($urlPath, '/' . (string)$allParams[$_p]) > 0) { + $_present[] = $_p; + } + } + preg_match_all('/\[([^\[\]]*)\]/', $httpRoute, $_sm); + foreach ($_sm[1] as $_s) { + if ($urlPath !== null && !preg_match('/:/', $_s) && + strpos($urlPath, $_s) > 0) { + $_present[] = 'static:' . $_s; + } + } + sort($_present); + $cacheKey = $httpRoute . '#' . implode(',', $_present); + unset($_pm, $_sm, $_present, $_p, $_s); + } else { + $cacheKey = $httpRoute; + } + $normalizedRoute = \DDTrace\routing_cache_get($cacheKey); + if ($normalizedRoute === false) { + $normalizedRoute = \DDTrace\Util\RouteNormalizer::normalizeFromLaminas($httpRoute, $allParams, $urlPath, $urlMatchedFromRegex); + if ($normalizedRoute !== null) { + \DDTrace\routing_cache_set($cacheKey, $normalizedRoute); + } + } + if ($normalizedRoute !== null && $normalizedRoute !== false) { + $rootSpan->meta[Tag::APPSEC_NORMALIZED_ROUTE] = $normalizedRoute; + } + } } } @@ -1157,6 +1237,29 @@ private static function walkRouteStackCollectEndpointRows( } } + private 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..d7ab3e63e90 100644 --- a/src/DDTrace/Integrations/Laravel/LaravelIntegration.php +++ b/src/DDTrace/Integrations/Laravel/LaravelIntegration.php @@ -140,7 +140,42 @@ 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( + $httpRoute, $request->path(), $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 +788,75 @@ 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()). + * + * Walks the route URI template and URL path in parallel; an optional param is only + * included in the result when the URL has a non-empty segment at that position. + * + * @param string $routeUri From $route->uri(), e.g. "normalized-default/{format?}" + * @param string $urlPath From $request->path(), e.g. "normalized-default" + * @param array $allParams From $route->parameters() + * @return array + */ + private static function laravelUrlMatchedParams(string $routeUri, string $urlPath, array $allParams): array + { + $routeSegs = explode('/', trim($routeUri, '/')); + $urlSegs = explode('/', trim($urlPath, '/')); + $matched = []; + $urlIdx = 0; + + foreach ($routeSegs as $seg) { + if (preg_match('/^\{([^}?:]+)\?\}$/', $seg, $m)) { + // Whole-segment optional param + if ($urlIdx < count($urlSegs) && $urlSegs[$urlIdx] !== '') { + if (array_key_exists($m[1], $allParams)) { + $matched[$m[1]] = $allParams[$m[1]]; + } + $urlIdx++; + } + } elseif (preg_match('/^\{([^}?:]+)\}$/', $seg, $m)) { + // Whole-segment required param — always present + if (array_key_exists($m[1], $allParams)) { + $matched[$m[1]] = $allParams[$m[1]]; + } + $urlIdx++; + } elseif (strpos($seg, '{') !== false) { + // Mixed segment (e.g. "{name}.{ext?}"): use progressive regex matching + // to determine which params (including optional ones) appear in the URL. + if ($urlIdx < count($urlSegs) && $urlSegs[$urlIdx] !== '') { + preg_match_all('/\{([^}?:]+)(\?)?\}/', $seg, $pm, PREG_SET_ORDER); + $paramNames = array_map(static function($m) { return $m[1]; }, $pm); + $staticParts = preg_split('/\{[^}]+\}/', $seg); + $n = count($paramNames); + $urlSeg = $urlSegs[$urlIdx]; + + for ($k = $n; $k >= 1; $k--) { + $regexBody = ''; + for ($ri = 0; $ri < $k; $ri++) { + $regexBody .= preg_quote($staticParts[$ri], '/') . '(.+)'; + } + if ($k === $n) { + $regexBody .= preg_quote($staticParts[$n], '/'); + } + if (@preg_match('/^' . $regexBody . '$/', $urlSeg)) { + for ($ri = 0; $ri < $k; $ri++) { + if (array_key_exists($paramNames[$ri], $allParams)) { + $matched[$paramNames[$ri]] = $allParams[$paramNames[$ri]]; + } + } + break; + } + } + } + $urlIdx++; + } else { + $urlIdx++; + } + } + + return $matched; + } } diff --git a/src/DDTrace/Integrations/Symfony/SymfonyIntegration.php b/src/DDTrace/Integrations/Symfony/SymfonyIntegration.php index d0540860fa5..d90cb674ee8 100644 --- a/src/DDTrace/Integrations/Symfony/SymfonyIntegration.php +++ b/src/DDTrace/Integrations/Symfony/SymfonyIntegration.php @@ -456,6 +456,51 @@ 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. + // Generic URL inference (inferSymfonyRouteParams) ignores route + // requirements and can misidentify defaulted params as URL-matched + // (e.g. {slug}.{format} with format=html|json requirement and URL + // "foo.bar" — generic inference treats "bar" as format). + // Fall back to generic inference when the route is unavailable. + $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(); + if (@preg_match($_regex, $request->getPathInfo(), $_rxm) === 1) { + $matchedParams = []; + foreach ($_rxm as $_k => $_v) { + if (is_string($_k) && $_v !== '') { + $matchedParams[$_k] = $_v; + } + } + } + } + } + } + unset($_r, $_route, $_compiled, $_regex, $_rxm, $_k, $_v); + } + if ($matchedParams === null) { + $matchedParams = \DDTrace\Util\RouteNormalizer::inferSymfonyRouteParams($path, $request->getPathInfo()); + } + $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 +815,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..74948a3e16d 100644 --- a/src/DDTrace/Integrations/WordPress/WordPressIntegrationLoader.php +++ b/src/DDTrace/Integrations/WordPress/WordPressIntegrationLoader.php @@ -732,7 +732,37 @@ 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; + // Key on per-capture participation bits, not the full URL or the + // highest-index group, so routes with optional-group holes (e.g. + // (?:([^/]+)-)? absent vs present) get distinct cache entries. + $wpParticipation = null; + if ($urlPath !== null) { + if (@preg_match('#^' . $matchedRule . '#', ltrim($urlPath, '/'), $_wpc)) { + $_bits = []; + for ($_wi = 1; $_wi < count($_wpc); $_wi++) { + $_bits[] = (isset($_wpc[$_wi]) && $_wpc[$_wi] !== '') ? '1' : '0'; + } + $wpParticipation = implode('', $_bits); + unset($_wpc, $_wi, $_bits); + } + } + $cacheKey = $matchedRule . '#' . ($wpParticipation ?? 'n'); + $normalizedRoute = \DDTrace\routing_cache_get($cacheKey); + if ($normalizedRoute === false) { + $normalizedRoute = \DDTrace\Util\RouteNormalizer::normalizeFromWordPress($matchedRule, $urlPath); + 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..989b937e5cd --- /dev/null +++ b/src/DDTrace/Util/RouteNormalizer.php @@ -0,0 +1,678 @@ +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); when provided, absent params are dropped + * @return string|null + */ + public static function normalizeFromSymfony(string $path, $matchedParams = null) + { + if ($matchedParams !== null) { + // 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); + } + return self::normalizeBraceRoute($path, []); + } + + /** + * 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) + * @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 we can apply URL-based param filtering. + $hasPercentParams = (bool) preg_match('/%([a-zA-Z_][a-zA-Z0-9_]*)%/', $expanded); + $braceFormat = self::colonParamsToBraces($expanded); + $braceFormat = self::percentParamsToBraces($braceFormat); + + // For Regex routes the defaults array injects values into matchedParams even for + // optional captures absent from the URL (e.g. format='html' when no .html in path). + // Use the URL path to determine which params were actually URL-matched. + if ($hasPercentParams && $urlPath !== null) { + $effectiveUrlMatchedParams = $urlMatchedParams ?? self::inferSymfonyRouteParams($braceFormat, $urlPath); + $braceFormat = preg_replace_callback( + '/\{([^}?:]+)\}/', + static function ($m) use ($effectiveUrlMatchedParams) { + return array_key_exists($m[1], $effectiveUrlMatchedParams) ? $m[0] : '{' . $m[1] . '?}'; + }, + $braceFormat + ); + return self::normalizeBraceRoute($braceFormat, $effectiveUrlMatchedParams); + } + + return self::normalizeBraceRoute($braceFormat, $matchedParams); + } + + /** + * Normalize a WordPress matched_rule (regex). + * + * WordPress route matching uses regex rules like "^blog/([^/]+)/?$". + * Named parameters are not available; placeholders param1, param2, … are used. + * + * @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) + { + // Re-run the regex against the actual URL to find which capture groups matched. + // Tracks each group individually so gaps from optional groups (e.g. (?:(...))?) + // that didn't participate are skipped instead of emitting phantom params. + $matchedGroups = null; + if ($urlPath !== null) { + if (@preg_match('#^' . $matchedRule . '#', ltrim($urlPath, '/'), $captures)) { + $matchedGroups = []; + for ($i = 1; $i < count($captures); $i++) { + if (isset($captures[$i]) && $captures[$i] !== '') { + $matchedGroups[$i] = true; + } + } + } + } + + $rule = ltrim($matchedRule, '^'); + $rule = rtrim($rule, '$'); + + if (preg_match('#\\\\?/\?$#', $rule, $m)) { + $rule = substr($rule, 0, -strlen($m[0])); + } + + $rule = trim($rule, '/'); + if ($rule === '') { + return '/'; + } + + // Pull the path separator out of (?:/...) non-capturing groups so that + // splitRegexBySlash treats the embedded '/' as a real segment boundary. + // Pattern: (?:/foo) means "optional /foo segment" — the '/' belongs at top level. + $rule = str_replace('(?:/', '/(?:', $rule); + + $segments = self::splitRegexBySlash($rule); + $normalizedSegments = []; + $paramIndex = 1; + + foreach ($segments as $segment) { + if ($segment === '') { + continue; + } + + if (preg_match('/[()[\].*+?|^${}\\\\]/', $segment)) { + // If the segment is entirely escaped static text (e.g. file\.json), + // decode the backslash escapes and emit it as a plain static segment. + if (self::isStaticEscapedRegex($segment)) { + $decoded = preg_replace('/\\\\(.)/', '$1', $segment); + if ($decoded !== '') { + $normalizedSegments[] = self::encodeStaticSegment($decoded); + } + continue; + } + + $prefixLen = strcspn($segment, '([{?*+|^$\\'); + $dynamicPart = substr($segment, $prefixLen); + $groupNames = self::extractCaptureGroupNames($dynamicPart); + $groupCount = count($groupNames); + + // Only emit a static prefix for purely-regex segments with no capture + // groups. When captures exist the whole segment (prefix + captures) maps + // to one RFC element, so the prefix must not become a separate element. + if ($prefixLen > 0 && $groupCount === 0) { + $staticPart = rtrim(substr($segment, 0, $prefixLen), '/-._'); + if ($staticPart !== '') { + $normalizedSegments[] = self::encodeStaticSegment($staticPart); + } + } + + if ($groupCount === 0) { + if ($matchedGroups !== null && !isset($matchedGroups[$paramIndex])) { + $paramIndex++; + continue; + } + $normalizedSegments[] = '{param' . $paramIndex++ . '}'; + } else { + $params = []; + for ($j = 0; $j < $groupCount; $j++) { + if ($matchedGroups !== null && !isset($matchedGroups[$paramIndex])) { + $paramIndex++; + continue; + } + $name = $groupNames[$j] ?? null; + $params[] = $name !== null ? self::encodeParamName($name) : 'param' . $paramIndex; + $paramIndex++; + } + if (!empty($params)) { + $normalizedSegments[] = '{' . implode('+', $params) . '}'; + } + } + } else { + $normalizedSegments[] = self::encodeStaticSegment($segment); + } + } + + return '/' . implode('/', $normalizedSegments); + } + + /** + * Split a regex string by '/' but not inside character classes [...] or groups (...). + * Prevents [^/] and (?:/...) from being split into multiple segments. + */ + private static function splitRegexBySlash(string $str): array + { + $segments = []; + $current = ''; + $len = strlen($str); + $bracketDepth = 0; + $parenDepth = 0; + + for ($i = 0; $i < $len; $i++) { + $c = $str[$i]; + + if ($c === '\\' && $i + 1 < $len) { + $current .= $c . $str[$i + 1]; + $i++; + continue; + } + + if ($c === '[' && $parenDepth === 0) { + $bracketDepth++; + $current .= $c; + } elseif ($c === ']' && $bracketDepth > 0) { + $bracketDepth--; + $current .= $c; + } elseif ($c === '(' && $bracketDepth === 0) { + $parenDepth++; + $current .= $c; + } elseif ($c === ')' && $parenDepth > 0 && $bracketDepth === 0) { + $parenDepth--; + $current .= $c; + } elseif ($c === '/' && $bracketDepth === 0 && $parenDepth === 0) { + $segments[] = $current; + $current = ''; + } else { + $current .= $c; + } + } + + $segments[] = $current; + return $segments; + } + + /** + * Returns true if $s is entirely made up of backslash-escaped characters and + * plain literal text, with no real regex metacharacters (captures, classes, etc.). + */ + private static function isStaticEscapedRegex(string $s): bool + { + $len = strlen($s); + for ($i = 0; $i < $len; $i++) { + if ($s[$i] === '\\') { + if ($i + 1 >= $len) { + return false; + } + $i++; + } elseif (strpos('([{?*+|^$', $s[$i]) !== false) { + return false; + } + } + return true; + } + + /** + * Extract capture group names from a regex segment. + * Named groups ((?P...) or (?...)) return their name; unnamed groups return null. + * Non-capturing groups (?:...) and lookarounds are not included. + */ + private static function extractCaptureGroupNames(string $segment): array + { + $names = []; + $len = strlen($segment); + $inClass = false; + + for ($i = 0; $i < $len; $i++) { + $c = $segment[$i]; + + if ($c === '\\' && $i + 1 < $len) { + $i++; + continue; + } + + if ($c === '[' && !$inClass) { + $inClass = true; + } elseif ($c === ']' && $inClass) { + $inClass = false; + } elseif ($c === '(' && !$inClass) { + if ($i + 1 < $len && $segment[$i + 1] === '?') { + // Named group (?P...) — Python/PCRE syntax + if ($i + 3 < $len && $segment[$i + 2] === 'P' && $segment[$i + 3] === '<') { + $closePos = strpos($segment, '>', $i + 4); + $names[] = $closePos !== false + ? substr($segment, $i + 4, $closePos - ($i + 4)) + : null; + // Named group (?...) but not lookbehind (?<=...) / (?', $i + 3); + $names[] = $closePos !== false + ? substr($segment, $i + 3, $closePos - ($i + 3)) + : null; + } + // (?:...), (?=...), etc. — not a capturing group, skip + } else { + $names[] = null; + } + } + } + + return $names; + } + + /** + * 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. Slim's {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; + } + + /** + * Infer which Symfony route parameters were actually present in the URL path + * (vs. injected as route defaults). + * + * Handles three kinds of template segments: + * - Whole-segment param: {id} → matched if URL has a segment at that position + * - Mixed segment: {id}.{_format} → matched if URL segment matches template regex + * - Static segment: users → no params extracted + * + * UTF-8 parameter names are supported. + */ + public static function inferSymfonyRouteParams(string $template, string $urlPath): array + { + $templateSegments = array_values(array_filter(explode('/', $template), 'strlen')); + // Decode percent-encoded URL path so template literals (e.g. café) compare + // correctly against encoded URL segments (e.g. caf%C3%A9). + $urlSegments = array_values(array_filter( + array_map('rawurldecode', explode('/', $urlPath)), + 'strlen' + )); + + $matched = []; + $urlIdx = 0; + + foreach ($templateSegments as $seg) { + if (preg_match('/^\{([^}?:]+)\}$/', $seg, $m)) { + // Whole-segment param — present if there is a URL segment at this position + if ($urlIdx < count($urlSegments)) { + $matched[$m[1]] = $urlSegments[$urlIdx]; + } + $urlIdx++; + } elseif (preg_match('/\{/', $seg)) { + // Mixed segment (static text + one or more params): determine presence by + // trying to match the URL segment, dropping trailing optional params as needed. + if ($urlIdx < count($urlSegments)) { + preg_match_all('/\{([^}?:]+)\}/', $seg, $pm); + $paramNames = $pm[1]; + $n = count($paramNames); + if ($n > 0) { + $urlSeg = $urlSegments[$urlIdx]; + $staticParts = preg_split('/\{[^}]+\}/', $seg); + // Try with k params (k = n, n-1, ..., 1). Drop params from the right + // until the URL segment matches. This handles optional trailing captures + // that were injected as route defaults but absent from the URL. + for ($k = $n; $k >= 1; $k--) { + $regexBody = ''; + for ($i = 0; $i < $k; $i++) { + $regexBody .= preg_quote($staticParts[$i], '/') . '(.+)'; + } + // Only include the trailing static part for a full match + if ($k === $n) { + $regexBody .= preg_quote($staticParts[$n], '/'); + } + if (@preg_match('/^' . $regexBody . '$/', $urlSeg)) { + for ($i = 0; $i < $k; $i++) { + $matched[$paramNames[$i]] = true; + } + break; + } + } + } + } + $urlIdx++; + } else { + // Pure static segment — advance URL position + $urlIdx++; + } + } + + return $matched; + } +} 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/Unit/Util/Normalizer/RouteNormalizerTest.php b/tests/Unit/Util/Normalizer/RouteNormalizerTest.php new file mode 100644 index 00000000000..c9459206555 --- /dev/null +++ b/tests/Unit/Util/Normalizer/RouteNormalizerTest.php @@ -0,0 +1,453 @@ +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); + } + + // inferSymfonyRouteParams — used to build the cache key in SymfonyIntegration + + public function testInferSymfonyRouteParamsRequiredParamsAlwaysPresent() + { + $params = RouteNormalizer::inferSymfonyRouteParams('/users/{id}', '/users/42'); + $this->assertArrayHasKey('id', $params); + } + + public function testInferSymfonyRouteParamsOptionalParamAbsent() + { + // Route /posts/{page} where page has a Symfony default — URL /posts does not include page. + // The integration code uses array_keys($params) as cache key suffix; this must be [] + // so that the 'absent' cache entry is distinct from the 'present' one. + $params = RouteNormalizer::inferSymfonyRouteParams('/posts/{page}', '/posts'); + $this->assertSame([], $params); + } + + public function testInferSymfonyRouteParamsOptionalParamPresent() + { + // URL /posts/2 provides page explicitly — must be in the returned params. + $params = RouteNormalizer::inferSymfonyRouteParams('/posts/{page}', '/posts/2'); + $this->assertArrayHasKey('page', $params); + } + + public function testInferSymfonyRouteParamsCacheKeysDiffer() + { + // Core invariant for correct cache behaviour: the two URL patterns for the same route + // produce different param key sets, so the cache key suffix encodes presence/absence. + $absent = array_keys(RouteNormalizer::inferSymfonyRouteParams('/posts/{page}', '/posts')); + $present = array_keys(RouteNormalizer::inferSymfonyRouteParams('/posts/{page}', '/posts/2')); + $this->assertNotSame($absent, $present); + + // And normalizeFromSymfony produces the correct result for each case. + $this->assertSame('/posts', RouteNormalizer::normalizeFromSymfony( + '/posts/{page}', + RouteNormalizer::inferSymfonyRouteParams('/posts/{page}', '/posts') + )); + $this->assertSame('/posts/{page}', RouteNormalizer::normalizeFromSymfony( + '/posts/{page}', + RouteNormalizer::inferSymfonyRouteParams('/posts/{page}', '/posts/2') + )); + } + + // 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 c6fb9dfe3fc..ba4799faf54 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 _WIN32 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 */