From 8c079f0056589636b29742ba11cfcda2c37aa054 Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Sat, 4 Apr 2026 10:01:00 -0300 Subject: [PATCH 1/8] feat: add experimental xmlquery body processor with lazy XPath evaluation Add a new "xmlquery" body processor backed by antchfx/xmlquery that parses XML request bodies into a DOM and evaluates XPath expressions lazily on demand. This enables real XPath support (e.g. XML:/soap:Envelope) that the existing XML processor cannot provide. Key changes: - New XPathMap collection type implementing collection.Map with lazy XPath evaluation against a parsed XML document - New xmlquery body processor in experimental/plugins registered as "xmlquery", activated via ctl:requestBodyProcessor=XMLQUERY - Widen requestXML/responseXML/xml transaction fields from concrete *collections.Map to collection.Map interface, enabling polymorphic collection implementations - Add SetRequestXML/SetResponseXML setters on TransactionVariables (concrete struct only, no interface change) so body processors can install custom collections via type assertion - Fallback path for TransactionVariables implementations that don't support the setter interface Relates to #1441 --- experimental/plugins/xmlquery.go | 88 ++++++++ experimental/plugins/xmlquery_test.go | 274 +++++++++++++++++++++++++ go.mod | 3 + go.sum | 28 +++ internal/collections/xpath_map.go | 145 +++++++++++++ internal/collections/xpath_map_test.go | 188 +++++++++++++++++ internal/corazawaf/transaction.go | 20 +- 7 files changed, 743 insertions(+), 3 deletions(-) create mode 100644 experimental/plugins/xmlquery.go create mode 100644 experimental/plugins/xmlquery_test.go create mode 100644 internal/collections/xpath_map.go create mode 100644 internal/collections/xpath_map_test.go diff --git a/experimental/plugins/xmlquery.go b/experimental/plugins/xmlquery.go new file mode 100644 index 000000000..8b82ca07d --- /dev/null +++ b/experimental/plugins/xmlquery.go @@ -0,0 +1,88 @@ +// Copyright 2026 Juan Pablo Tosso and the OWASP Coraza contributors +// SPDX-License-Identifier: Apache-2.0 + +package plugins + +import ( + "io" + + "github.com/antchfx/xmlquery" + + "github.com/corazawaf/coraza/v3/collection" + "github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes" + "github.com/corazawaf/coraza/v3/internal/bodyprocessors" + "github.com/corazawaf/coraza/v3/internal/collections" + "github.com/corazawaf/coraza/v3/types/variables" +) + +// xmlSettableVariables is an optional interface that TransactionVariables may +// implement to allow body processors to replace the XML collection with a +// custom implementation. This decouples the body processor from the concrete +// transaction type. +type xmlSettableVariables interface { + SetRequestXML(collection.Map) +} + +// xmlQueryBodyProcessor parses XML request bodies into an in-memory DOM and +// installs a lazy XPath collection (collections.XPathMap) as the RequestXML +// variable. XPath expressions in rules (e.g. XML:/soap:Envelope/soap:Body) +// are evaluated on demand against the parsed document, rather than requiring +// pre-computed keys. +type xmlQueryBodyProcessor struct{} + +var _ plugintypes.BodyProcessor = (*xmlQueryBodyProcessor)(nil) + +// ProcessRequest parses the request body as XML and installs a lazy XPath +// collection on the transaction's RequestXML variable. If the transaction +// does not support SetRequestXML, it falls back to populating the standard +// //@* and /* keys on the existing collection. +func (*xmlQueryBodyProcessor) ProcessRequest(reader io.Reader, v plugintypes.TransactionVariables, options plugintypes.BodyProcessorOptions) error { + doc, err := xmlquery.Parse(reader) + if err != nil { + return err + } + + // If the transaction supports swapping the XML collection, install the + // lazy XPath-backed collection. Otherwise, fall back to populating the + // existing map with the two standard keys. + if setter, ok := v.(xmlSettableVariables); ok { + xpathMap := collections.NewXPathMap(variables.RequestXML, doc) + setter.SetRequestXML(xpathMap) + } else { + col := v.RequestXML() + attrs, contents := extractFromDoc(doc) + col.Set("//@*", attrs) + col.Set("/*", contents) + } + + return nil +} + +// ProcessResponse is not yet implemented for XML response bodies. +func (*xmlQueryBodyProcessor) ProcessResponse(reader io.Reader, v plugintypes.TransactionVariables, options plugintypes.BodyProcessorOptions) error { + return nil +} + +// extractFromDoc walks the parsed DOM and extracts all attribute values and +// text content, mirroring the behavior of the original xml body processor. +func extractFromDoc(doc *xmlquery.Node) (attrs []string, content []string) { + nodes, _ := xmlquery.QueryAll(doc, "//@*") + for _, n := range nodes { + if v := n.InnerText(); v != "" { + attrs = append(attrs, v) + } + } + textNodes, _ := xmlquery.QueryAll(doc, "//*[text()]") + for _, n := range textNodes { + if v := n.InnerText(); v != "" { + content = append(content, v) + } + } + return attrs, content +} + +func init() { + bodyprocessors.RegisterBodyProcessor("xmlquery", func() plugintypes.BodyProcessor { + return &xmlQueryBodyProcessor{} + }) +} diff --git a/experimental/plugins/xmlquery_test.go b/experimental/plugins/xmlquery_test.go new file mode 100644 index 000000000..ce5e1731f --- /dev/null +++ b/experimental/plugins/xmlquery_test.go @@ -0,0 +1,274 @@ +// Copyright 2026 Juan Pablo Tosso and the OWASP Coraza contributors +// SPDX-License-Identifier: Apache-2.0 + +package plugins_test + +import ( + "bytes" + "testing" + + "github.com/corazawaf/coraza/v3/collection" + "github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes" + "github.com/corazawaf/coraza/v3/internal/bodyprocessors" + "github.com/corazawaf/coraza/v3/internal/collections" + "github.com/corazawaf/coraza/v3/internal/corazawaf" + "github.com/corazawaf/coraza/v3/types/variables" + + // Blank import to trigger init() registration of the xmlquery body processor. + _ "github.com/corazawaf/coraza/v3/experimental/plugins" +) + +const bookstoreXML = ` + + + Harry Potter + 29.99 + + + Learning XML + 39.95 + +` + +const soapXML = ` + + + + GOOG + + +` + +const xmlRPC = ` + + wp.getUsersBlogs + + admin + password123 + +` + +func xmlQueryProcessor(t *testing.T) plugintypes.BodyProcessor { + t.Helper() + bp, err := bodyprocessors.GetBodyProcessor("xmlquery") + if err != nil { + t.Fatal(err) + } + return bp +} + +// TestXMLQueryBodyProcessorXPath verifies that the xmlquery body processor +// installs a lazy XPath collection that can evaluate arbitrary XPath +// expressions against the parsed document. +func TestXMLQueryBodyProcessorXPath(t *testing.T) { + v := corazawaf.NewTransactionVariables() + bp := xmlQueryProcessor(t) + err := bp.ProcessRequest( + bytes.NewReader([]byte(bookstoreXML)), + v, + plugintypes.BodyProcessorOptions{}, + ) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + xpath string + expect []string + }{ + { + name: "all titles", + xpath: "//title", + expect: []string{"Harry Potter", "Learning XML"}, + }, + { + name: "all prices", + xpath: "//price", + expect: []string{"29.99", "39.95"}, + }, + { + name: "first book title", + xpath: "//book[1]/title", + expect: []string{"Harry Potter"}, + }, + { + name: "attribute selection", + xpath: "//title/@lang", + expect: []string{"en", "es"}, + }, + { + name: "backward compat //@*", + xpath: "//@*", + expect: []string{"en", "value", "es"}, + }, + { + name: "text nodes via //text()", + xpath: "//text()", + expect: []string{"Harry Potter", "29.99", "Learning XML", "39.95"}, + }, + { + name: "no match", + xpath: "//nonexistent", + expect: nil, + }, + } + + col := v.RequestXML() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := col.Get(tt.xpath) + if tt.expect == nil { + if len(got) != 0 { + t.Errorf("expected no results, got %v", got) + } + return + } + if len(got) != len(tt.expect) { + t.Fatalf("expected %d results, got %d: %v", len(tt.expect), len(got), got) + } + for i := range tt.expect { + if got[i] != tt.expect[i] { + t.Errorf("result[%d]: expected %q, got %q", i, tt.expect[i], got[i]) + } + } + }) + } +} + +// TestXMLQueryBodyProcessorSOAP verifies namespace-aware XPath evaluation +// on a SOAP envelope. +func TestXMLQueryBodyProcessorSOAP(t *testing.T) { + v := corazawaf.NewTransactionVariables() + bp := xmlQueryProcessor(t) + err := bp.ProcessRequest( + bytes.NewReader([]byte(soapXML)), + v, + plugintypes.BodyProcessorOptions{}, + ) + if err != nil { + t.Fatal(err) + } + + col := v.RequestXML() + + // Namespace-aware query using local-name() + got := col.Get("//*[local-name()='StockName']") + if len(got) != 1 || got[0] != "GOOG" { + t.Errorf("expected [GOOG], got %v", got) + } +} + +// TestXMLQueryBodyProcessorXMLRPC verifies parsing of WordPress XML-RPC +// requests, the original scenario from issue #1441. +func TestXMLQueryBodyProcessorXMLRPC(t *testing.T) { + v := corazawaf.NewTransactionVariables() + bp := xmlQueryProcessor(t) + err := bp.ProcessRequest( + bytes.NewReader([]byte(xmlRPC)), + v, + plugintypes.BodyProcessorOptions{}, + ) + if err != nil { + t.Fatal(err) + } + + col := v.RequestXML() + + got := col.Get("//methodName") + if len(got) != 1 || got[0] != "wp.getUsersBlogs" { + t.Errorf("expected [wp.getUsersBlogs], got %v", got) + } + + got = col.Get("//params/param/value/string") + if len(got) != 2 { + t.Fatalf("expected 2 params, got %d: %v", len(got), got) + } + if got[0] != "admin" || got[1] != "password123" { + t.Errorf("expected [admin, password123], got %v", got) + } +} + +// TestXMLQueryBodyProcessorFindString verifies that FindString returns +// proper MatchData for rule evaluation. +func TestXMLQueryBodyProcessorFindString(t *testing.T) { + v := corazawaf.NewTransactionVariables() + bp := xmlQueryProcessor(t) + err := bp.ProcessRequest( + bytes.NewReader([]byte(bookstoreXML)), + v, + plugintypes.BodyProcessorOptions{}, + ) + if err != nil { + t.Fatal(err) + } + + col := v.RequestXML() + matches := col.FindString("//title") + if len(matches) != 2 { + t.Fatalf("expected 2 matches, got %d", len(matches)) + } + if matches[0].Value() != "Harry Potter" { + t.Errorf("expected 'Harry Potter', got %q", matches[0].Value()) + } + if matches[0].Key() != "//title" { + t.Errorf("expected key '//title', got %q", matches[0].Key()) + } +} + +// TestXMLQueryBodyProcessorFallback verifies that the body processor falls +// back to populating the standard map keys when SetRequestXML is not available. +func TestXMLQueryBodyProcessorFallback(t *testing.T) { + xmlMap := collections.NewMap(variables.RequestXML) + + // Use a TransactionVariables mock that does not implement xmlSettableVariables. + vars := &fallbackTestVars{requestXML: xmlMap} + + bp := xmlQueryProcessor(t) + err := bp.ProcessRequest( + bytes.NewReader([]byte(bookstoreXML)), + vars, + plugintypes.BodyProcessorOptions{}, + ) + if err != nil { + t.Fatal(err) + } + + attrs := xmlMap.Get("//@*") + if len(attrs) != 3 { + t.Errorf("expected 3 attributes, got %d: %v", len(attrs), attrs) + } + + contents := xmlMap.Get("/*") + if len(contents) == 0 { + t.Error("expected content in /*, got none") + } +} + +// TestXMLQueryBodyProcessorInvalidXML verifies that malformed XML returns +// an error instead of silently succeeding. +func TestXMLQueryBodyProcessorInvalidXML(t *testing.T) { + v := corazawaf.NewTransactionVariables() + bp := xmlQueryProcessor(t) + err := bp.ProcessRequest( + bytes.NewReader([]byte("")), + v, + plugintypes.BodyProcessorOptions{}, + ) + if err == nil { + t.Error("expected error for malformed XML, got nil") + } +} + +// fallbackTestVars is a minimal TransactionVariables implementation that does +// NOT implement xmlSettableVariables, forcing the body processor to use the +// fallback path of populating the existing collection.Map directly. +type fallbackTestVars struct { + plugintypes.TransactionVariables + requestXML collection.Map +} + +// RequestXML returns the test collection without supporting SetRequestXML. +func (v *fallbackTestVars) RequestXML() collection.Map { + return v.requestXML +} diff --git a/go.mod b/go.mod index 3f396be4d..1ff62ec62 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ go 1.25.0 // - ocsf-schema-golang require ( + github.com/antchfx/xmlquery v1.5.1 github.com/anuraaga/go-modsecurity v0.0.0-20220824035035-b9a4099778df github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc github.com/corazawaf/libinjection-go v0.3.2 @@ -34,9 +35,11 @@ require ( ) require ( + github.com/antchfx/xpath v1.3.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect github.com/kaptinlin/go-i18n v0.1.4 // indirect diff --git a/go.sum b/go.sum index a42acef57..d9ba2a8bb 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/antchfx/xmlquery v1.5.1 h1:T9I4Ns1EXiWHy0IqKupGhnfTQtJwlGrpXtauYOoNv78= +github.com/antchfx/xmlquery v1.5.1/go.mod h1:bVqnl7TaDXSReKINrhZz+2E/PbCu2tUahb+wZ7WZNT8= +github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI= +github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/anuraaga/go-modsecurity v0.0.0-20220824035035-b9a4099778df h1:YWiVl53v0R8Knj/k+4slO0SXPL67Y4dXWiOIWNzrkew= github.com/anuraaga/go-modsecurity v0.0.0-20220824035035-b9a4099778df/go.mod h1:7jguE759ADzy2EkxGRXigiC0ER1Yq2IFk2qNtwgzc7U= github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc= @@ -12,6 +16,9 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 h1:b70jEaX2iaJSPZULSUxKtm73LBfsCrMsIlYCUgNGSIs= @@ -53,10 +60,15 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -67,6 +79,9 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -75,6 +90,9 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -87,8 +105,12 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -96,6 +118,9 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -103,6 +128,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -111,6 +138,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/collections/xpath_map.go b/internal/collections/xpath_map.go new file mode 100644 index 000000000..527ba3a36 --- /dev/null +++ b/internal/collections/xpath_map.go @@ -0,0 +1,145 @@ +// Copyright 2026 Juan Pablo Tosso and the OWASP Coraza contributors +// SPDX-License-Identifier: Apache-2.0 + +package collections + +import ( + "regexp" + "strings" + + "github.com/antchfx/xmlquery" + + "github.com/corazawaf/coraza/v3/collection" + "github.com/corazawaf/coraza/v3/internal/corazarules" + "github.com/corazawaf/coraza/v3/types" + "github.com/corazawaf/coraza/v3/types/variables" +) + +// XPathMap implements collection.Map with lazy XPath evaluation against +// a parsed XML document. XPath expressions are evaluated on demand when +// FindString, FindRegex, or Get are called. +type XPathMap struct { + doc *xmlquery.Node + variable variables.RuleVariable +} + +var _ collection.Map = &XPathMap{} + +// NewXPathMap creates a new XPathMap backed by the given XML document. +func NewXPathMap(variable variables.RuleVariable, doc *xmlquery.Node) *XPathMap { + return &XPathMap{ + doc: doc, + variable: variable, + } +} + +// evalXPath evaluates an XPath expression against the document and returns +// the string values of matching nodes. +func (c *XPathMap) evalXPath(expr string) []string { + if c.doc == nil { + return nil + } + nodes, err := xmlquery.QueryAll(c.doc, expr) + if err != nil { + return nil + } + if len(nodes) == 0 { + return nil + } + results := make([]string, 0, len(nodes)) + for _, n := range nodes { + text := n.InnerText() + if trimmed := strings.TrimSpace(text); trimmed != "" { + results = append(results, trimmed) + } + } + return results +} + +// Get evaluates the given XPath expression against the parsed XML document +// and returns the string values of all matching nodes. +func (c *XPathMap) Get(key string) []string { + return c.evalXPath(key) +} + +// FindString evaluates the given XPath expression against the parsed XML +// document and returns MatchData for each matching node. If key is empty, +// it delegates to FindAll. +func (c *XPathMap) FindString(key string) []types.MatchData { + if key == "" { + return c.FindAll() + } + values := c.evalXPath(key) + if len(values) == 0 { + return nil + } + buf := make([]corazarules.MatchData, len(values)) + result := make([]types.MatchData, len(values)) + for i, v := range values { + buf[i] = corazarules.MatchData{ + Variable_: c.variable, + Key_: key, + Value_: v, + } + result[i] = &buf[i] + } + return result +} + +// FindRegex returns all document nodes whose XPath key matches the given +// regular expression. Since regex is not meaningful over XPath expressions, +// this falls back to FindAll and filters results by key. +func (c *XPathMap) FindRegex(key *regexp.Regexp) []types.MatchData { + all := c.FindAll() + var result []types.MatchData + for _, m := range all { + if key.MatchString(m.Key()) { + result = append(result, m) + } + } + return result +} + +// FindAll returns all text content and attribute values from the document, +// equivalent to evaluating //@* and //* text nodes. +func (c *XPathMap) FindAll() []types.MatchData { + var result []types.MatchData + // All attribute values + for _, m := range c.FindString("//@*") { + result = append(result, m) + } + // All text content + for _, m := range c.FindString("//*[text()]") { + result = append(result, m) + } + return result +} + +// Set is a no-op for XPathMap. The underlying data comes from the parsed +// XML DOM and is not mutable through this interface. +func (c *XPathMap) Set(key string, values []string) {} + +// Add is a no-op for XPathMap. See Set for rationale. +func (c *XPathMap) Add(key string, value string) {} + +// SetIndex is a no-op for XPathMap. See Set for rationale. +func (c *XPathMap) SetIndex(key string, index int, value string) {} + +// Remove is a no-op for XPathMap. See Set for rationale. +func (c *XPathMap) Remove(key string) {} + +// Name returns the name of the variable this collection is bound to. +func (c *XPathMap) Name() string { + return c.variable.Name() +} + +// Reset clears the document reference. +func (c *XPathMap) Reset() { + c.doc = nil +} + +// Format writes a string representation of the collection. +func (c *XPathMap) Format(res *strings.Builder) { + res.WriteString(c.variable.Name()) + res.WriteString(": (xmlquery xpath-backed collection)\n") +} diff --git a/internal/collections/xpath_map_test.go b/internal/collections/xpath_map_test.go new file mode 100644 index 000000000..d2d9ee8f3 --- /dev/null +++ b/internal/collections/xpath_map_test.go @@ -0,0 +1,188 @@ +// Copyright 2026 Juan Pablo Tosso and the OWASP Coraza contributors +// SPDX-License-Identifier: Apache-2.0 + +package collections + +import ( + "regexp" + "strings" + "testing" + + "github.com/antchfx/xmlquery" + + "github.com/corazawaf/coraza/v3/types/variables" +) + +func parseTestDoc(t *testing.T, xml string) *xmlquery.Node { + t.Helper() + doc, err := xmlquery.Parse(strings.NewReader(xml)) + if err != nil { + t.Fatal(err) + } + return doc +} + +const testXML = ` + + alpha + beta +` + +// TestXPathMapGet verifies that Get evaluates an XPath expression and +// returns the string values of matching nodes. +func TestXPathMapGet(t *testing.T) { + doc := parseTestDoc(t, testXML) + m := NewXPathMap(variables.RequestXML, doc) + + got := m.Get("//item") + if len(got) != 2 { + t.Fatalf("expected 2 results, got %d", len(got)) + } + if got[0] != "alpha" || got[1] != "beta" { + t.Errorf("unexpected values: %v", got) + } +} + +// TestXPathMapGetAttributes verifies attribute XPath selection. +func TestXPathMapGetAttributes(t *testing.T) { + doc := parseTestDoc(t, testXML) + m := NewXPathMap(variables.RequestXML, doc) + + got := m.Get("//item/@key") + if len(got) != 2 { + t.Fatalf("expected 2 attributes, got %d", len(got)) + } + if got[0] != "a" || got[1] != "b" { + t.Errorf("unexpected attribute values: %v", got) + } +} + +// TestXPathMapGetNoMatch verifies that a non-matching XPath returns nil. +func TestXPathMapGetNoMatch(t *testing.T) { + doc := parseTestDoc(t, testXML) + m := NewXPathMap(variables.RequestXML, doc) + + got := m.Get("//missing") + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +// TestXPathMapGetInvalidXPath verifies that an invalid XPath expression +// returns nil instead of panicking. +func TestXPathMapGetInvalidXPath(t *testing.T) { + doc := parseTestDoc(t, testXML) + m := NewXPathMap(variables.RequestXML, doc) + + got := m.Get("[invalid xpath") + if got != nil { + t.Errorf("expected nil for invalid xpath, got %v", got) + } +} + +// TestXPathMapNilDoc verifies that a nil document returns nil for all queries. +func TestXPathMapNilDoc(t *testing.T) { + m := NewXPathMap(variables.RequestXML, nil) + + if got := m.Get("//item"); got != nil { + t.Errorf("expected nil, got %v", got) + } + if got := m.FindString("//item"); got != nil { + t.Errorf("expected nil, got %v", got) + } + if got := m.FindAll(); got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +// TestXPathMapFindString verifies that FindString returns proper MatchData +// with the correct variable, key, and value fields. +func TestXPathMapFindString(t *testing.T) { + doc := parseTestDoc(t, testXML) + m := NewXPathMap(variables.RequestXML, doc) + + matches := m.FindString("//item") + if len(matches) != 2 { + t.Fatalf("expected 2 matches, got %d", len(matches)) + } + if matches[0].Variable().Name() != "REQUEST_XML" { + t.Errorf("expected variable REQUEST_XML, got %q", matches[0].Variable().Name()) + } + if matches[0].Key() != "//item" { + t.Errorf("expected key '//item', got %q", matches[0].Key()) + } + if matches[0].Value() != "alpha" { + t.Errorf("expected value 'alpha', got %q", matches[0].Value()) + } +} + +// TestXPathMapFindStringEmpty verifies that FindString with an empty key +// delegates to FindAll. +func TestXPathMapFindStringEmpty(t *testing.T) { + doc := parseTestDoc(t, testXML) + m := NewXPathMap(variables.RequestXML, doc) + + all := m.FindString("") + if len(all) == 0 { + t.Error("expected results from FindString(\"\"), got none") + } +} + +// TestXPathMapFindRegex verifies that FindRegex filters results by key match. +func TestXPathMapFindRegex(t *testing.T) { + doc := parseTestDoc(t, testXML) + m := NewXPathMap(variables.RequestXML, doc) + + re := regexp.MustCompile(`//@\*`) + matches := m.FindRegex(re) + // Should match results from FindAll whose key is "//@*" + for _, match := range matches { + if !re.MatchString(match.Key()) { + t.Errorf("FindRegex returned match with non-matching key: %q", match.Key()) + } + } +} + +// TestXPathMapName verifies the collection name matches the variable. +func TestXPathMapName(t *testing.T) { + m := NewXPathMap(variables.RequestXML, nil) + if m.Name() != "REQUEST_XML" { + t.Errorf("expected name 'REQUEST_XML', got %q", m.Name()) + } +} + +// TestXPathMapReset verifies that Reset clears the document reference, +// causing subsequent queries to return nil. +func TestXPathMapReset(t *testing.T) { + doc := parseTestDoc(t, testXML) + m := NewXPathMap(variables.RequestXML, doc) + + if got := m.Get("//item"); len(got) == 0 { + t.Fatal("expected results before reset") + } + + m.Reset() + + if got := m.Get("//item"); got != nil { + t.Errorf("expected nil after reset, got %v", got) + } +} + +// TestXPathMapMutationNoOps verifies that Set, Add, SetIndex, and Remove +// are safe no-ops that do not panic. +func TestXPathMapMutationNoOps(t *testing.T) { + doc := parseTestDoc(t, testXML) + m := NewXPathMap(variables.RequestXML, doc) + + // These should not panic or change behavior + m.Set("//item", []string{"new"}) + m.Add("//item", "new") + m.SetIndex("//item", 0, "new") + m.Remove("//item") + + // Original data should still be accessible + got := m.Get("//item") + if len(got) != 2 { + t.Errorf("mutation methods should be no-ops, but data changed: %v", got) + } +} diff --git a/internal/corazawaf/transaction.go b/internal/corazawaf/transaction.go index c0d2af84a..d36428a9e 100644 --- a/internal/corazawaf/transaction.go +++ b/internal/corazawaf/transaction.go @@ -1820,7 +1820,7 @@ type TransactionVariables struct { requestProtocol *collections.Single requestURI *collections.Single requestURIRaw *collections.Single - requestXML *collections.Map + requestXML collection.Map responseBody *collections.Single responseContentLength *collections.Single responseContentType *collections.Single @@ -1828,7 +1828,7 @@ type TransactionVariables struct { responseHeadersNames collection.Keyed responseProtocol *collections.Single responseStatus *collections.Single - responseXML *collections.Map + responseXML collection.Map responseArgs *collections.Map resBodyProcessor *collections.Single rule *collections.Map @@ -1839,7 +1839,7 @@ type TransactionVariables struct { tx *collections.Map uniqueID *collections.Single urlencodedError *collections.Single - xml *collections.Map + xml collection.Map resBodyError *collections.Single resBodyErrorMsg *collections.Single resBodyProcessorError *collections.Single @@ -2235,6 +2235,20 @@ func (v *TransactionVariables) ResponseXML() collection.Map { return v.responseXML } +// SetRequestXML replaces the RequestXML collection with the given implementation. +// This allows body processors to install custom collection types (e.g., lazy +// XPath evaluation) without the transaction needing to know about the specifics. +// It also updates the XML alias to point to the new collection. +func (v *TransactionVariables) SetRequestXML(m collection.Map) { + v.requestXML = m + v.xml = m +} + +// SetResponseXML replaces the ResponseXML collection with the given implementation. +func (v *TransactionVariables) SetResponseXML(m collection.Map) { + v.responseXML = m +} + func (v *TransactionVariables) ResponseBodyProcessor() collection.Single { return v.resBodyProcessor } From 533e7ced00c6fcfb95ab47b1a2c5afb30a71735a Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Sat, 4 Apr 2026 10:12:37 -0300 Subject: [PATCH 2/8] refactor: move XPathMap collection to experimental/plugins/collections Move the XPathMap collection from internal/collections to experimental/plugins/collections, keeping it alongside the other experimental plugin code. Update xmlquery body processor to import from the new location. --- {internal => experimental/plugins}/collections/xpath_map.go | 0 .../plugins}/collections/xpath_map_test.go | 0 experimental/plugins/xmlquery.go | 2 +- experimental/plugins/xmlquery_test.go | 4 ++-- 4 files changed, 3 insertions(+), 3 deletions(-) rename {internal => experimental/plugins}/collections/xpath_map.go (100%) rename {internal => experimental/plugins}/collections/xpath_map_test.go (100%) diff --git a/internal/collections/xpath_map.go b/experimental/plugins/collections/xpath_map.go similarity index 100% rename from internal/collections/xpath_map.go rename to experimental/plugins/collections/xpath_map.go diff --git a/internal/collections/xpath_map_test.go b/experimental/plugins/collections/xpath_map_test.go similarity index 100% rename from internal/collections/xpath_map_test.go rename to experimental/plugins/collections/xpath_map_test.go diff --git a/experimental/plugins/xmlquery.go b/experimental/plugins/xmlquery.go index 8b82ca07d..974059efb 100644 --- a/experimental/plugins/xmlquery.go +++ b/experimental/plugins/xmlquery.go @@ -9,9 +9,9 @@ import ( "github.com/antchfx/xmlquery" "github.com/corazawaf/coraza/v3/collection" + "github.com/corazawaf/coraza/v3/experimental/plugins/collections" "github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes" "github.com/corazawaf/coraza/v3/internal/bodyprocessors" - "github.com/corazawaf/coraza/v3/internal/collections" "github.com/corazawaf/coraza/v3/types/variables" ) diff --git a/experimental/plugins/xmlquery_test.go b/experimental/plugins/xmlquery_test.go index ce5e1731f..bc548378e 100644 --- a/experimental/plugins/xmlquery_test.go +++ b/experimental/plugins/xmlquery_test.go @@ -10,7 +10,7 @@ import ( "github.com/corazawaf/coraza/v3/collection" "github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes" "github.com/corazawaf/coraza/v3/internal/bodyprocessors" - "github.com/corazawaf/coraza/v3/internal/collections" + internalcollections "github.com/corazawaf/coraza/v3/internal/collections" "github.com/corazawaf/coraza/v3/internal/corazawaf" "github.com/corazawaf/coraza/v3/types/variables" @@ -219,7 +219,7 @@ func TestXMLQueryBodyProcessorFindString(t *testing.T) { // TestXMLQueryBodyProcessorFallback verifies that the body processor falls // back to populating the standard map keys when SetRequestXML is not available. func TestXMLQueryBodyProcessorFallback(t *testing.T) { - xmlMap := collections.NewMap(variables.RequestXML) + xmlMap := internalcollections.NewMap(variables.RequestXML) // Use a TransactionVariables mock that does not implement xmlSettableVariables. vars := &fallbackTestVars{requestXML: xmlMap} From 3c88499b7bb2e035e141647c7a7dbdb02503dc8d Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Sat, 4 Apr 2026 10:33:13 -0300 Subject: [PATCH 3/8] test: full coverage and benchmarks for XPathMap collection Achieve 100% statement coverage on xpath_map.go with tests covering: - All query methods (Get, FindString, FindRegex, FindAll) - Edge cases (nil doc, invalid XPath, whitespace-only nodes, no match) - No-op mutation methods and Format - Diverse XML structures: SOAP namespaces, CDATA, XML-RPC, deep nesting Add benchmarks for small (~1KB), medium (~22KB), and large (~298KB) documents measuring parse time, element/attribute queries, predicate filters, namespace-aware queries, and FindAll/FindString/FindRegex. Add testdata with generated XML fixtures of varying complexity: - small_simple.xml, small_soap.xml, small_cdata.xml, small_xmlrpc.xml - medium_mixed.xml (50 books, namespaces, reviews, CDATA, metadata) - medium_deep.xml (30-level nesting with siblings) - large_catalog.xml (500 employees, 4 namespaces, access logs, CDATA) --- .../collections/testdata/gen_test_xml.go | 177 + .../collections/testdata/large_catalog.xml | 7316 +++++++++++++++++ .../collections/testdata/medium_deep.xml | 136 + .../collections/testdata/medium_mixed.xml | 520 ++ .../collections/testdata/small_cdata.xml | 11 + .../collections/testdata/small_simple.xml | 5 + .../collections/testdata/small_soap.xml | 12 + .../collections/testdata/small_xmlrpc.xml | 10 + .../plugins/collections/xpath_map_test.go | 494 +- 9 files changed, 8673 insertions(+), 8 deletions(-) create mode 100644 experimental/plugins/collections/testdata/gen_test_xml.go create mode 100644 experimental/plugins/collections/testdata/large_catalog.xml create mode 100644 experimental/plugins/collections/testdata/medium_deep.xml create mode 100644 experimental/plugins/collections/testdata/medium_mixed.xml create mode 100644 experimental/plugins/collections/testdata/small_cdata.xml create mode 100644 experimental/plugins/collections/testdata/small_simple.xml create mode 100644 experimental/plugins/collections/testdata/small_soap.xml create mode 100644 experimental/plugins/collections/testdata/small_xmlrpc.xml diff --git a/experimental/plugins/collections/testdata/gen_test_xml.go b/experimental/plugins/collections/testdata/gen_test_xml.go new file mode 100644 index 000000000..52c2d27ca --- /dev/null +++ b/experimental/plugins/collections/testdata/gen_test_xml.go @@ -0,0 +1,177 @@ +// Copyright 2026 Juan Pablo Tosso and the OWASP Coraza contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build ignore +// +build ignore + +// gen_test_xml generates medium and large XML test fixtures with diverse +// structure: namespaces, attributes, CDATA, mixed content, deep nesting, +// processing instructions, and comments. +package main + +import ( + "fmt" + "os" + "strings" +) + +func main() { + generateMedium("medium_mixed.xml", 50) + generateLarge("large_catalog.xml", 500) + generateDeepNesting("medium_deep.xml", 30) +} + +func generateMedium(filename string, count int) { + var b strings.Builder + b.WriteString(``) + b.WriteString("\n") + b.WriteString(``) + b.WriteString("\n") + b.WriteString(``) + b.WriteString("\n") + b.WriteString(``) + b.WriteString("\n") + + categories := []string{"fiction", "science", "history", "tech", "art"} + formats := []string{"hardcover", "paperback", "ebook", "audiobook"} + + for i := 1; i <= count; i++ { + cat := categories[i%len(categories)] + format := formats[i%len(formats)] + b.WriteString(fmt.Sprintf(` `+"\n", + i, cat, format, i%3 != 0)) + b.WriteString(fmt.Sprintf(` Book Title Number %d`+"\n", i)) + b.WriteString(fmt.Sprintf(` Author %d`+"\n", i%20+1)) + b.WriteString(fmt.Sprintf(` 978-0-%07d-%d`+"\n", i*1000+i, i%10)) + b.WriteString(fmt.Sprintf(` %.2f`+"\n", float64(10+i%90)+0.99)) + if i%5 == 0 { + b.WriteString(` characters & "quotes" that need CDATA.]]>` + "\n") + } else if i%3 == 0 { + b.WriteString(fmt.Sprintf(` A fascinating exploration of %s topics in volume %d.`+"\n", cat, i)) + } + if i%4 == 0 { + b.WriteString(` ` + "\n") + for j := 1; j <= 3; j++ { + b.WriteString(fmt.Sprintf(` `+"\n", j+2, j%2 == 0)) + b.WriteString(fmt.Sprintf(` User%d`+"\n", i*10+j)) + b.WriteString(fmt.Sprintf(` Review comment %d for book %d`+"\n", j, i)) + b.WriteString(` ` + "\n") + } + b.WriteString(` ` + "\n") + } + if i%7 == 0 { + b.WriteString(` ` + "\n") + b.WriteString(fmt.Sprintf(` `+"\n", i%5+1)) + b.WriteString(fmt.Sprintf(` `+"\n", 100+i*3)) + } + b.WriteString(` ` + "\n") + } + + b.WriteString(`` + "\n") + writeFile(filename, b.String()) +} + +func generateLarge(filename string, count int) { + var b strings.Builder + b.WriteString(`` + "\n") + b.WriteString(`` + "\n") + + departments := []string{"Engineering", "Marketing", "Sales", "Support", "Research", "Legal", "Finance", "HR"} + roles := []string{"developer", "manager", "analyst", "director", "intern", "architect", "lead", "specialist"} + locations := []string{"NYC", "SFO", "LON", "TKY", "BER", "SYD"} + + for i := 1; i <= count; i++ { + dept := departments[i%len(departments)] + role := roles[i%len(roles)] + loc := locations[i%len(locations)] + + b.WriteString(fmt.Sprintf(` `+"\n", + i, dept, i%5+1)) + b.WriteString(fmt.Sprintf(` ` + "\n")) + b.WriteString(fmt.Sprintf(` FirstName%d`+"\n", i)) + b.WriteString(fmt.Sprintf(` LastName%d`+"\n", i)) + b.WriteString(` ` + "\n") + b.WriteString(fmt.Sprintf(` %s`+"\n", role)) + b.WriteString(fmt.Sprintf(` %s Office`+"\n", + loc, i%3 == 0, loc)) + + b.WriteString(fmt.Sprintf(` ` + "\n")) + b.WriteString(fmt.Sprintf(` %d`+"\n", 50000+i*100)) + b.WriteString(fmt.Sprintf(` `+"\n", float64(i%20)+5.0)) + if i%3 == 0 { + b.WriteString(` ` + "\n") + b.WriteString(fmt.Sprintf(` `+"\n", i*100, float64(50+i%200)+0.50)) + b.WriteString(` ` + "\n") + } + b.WriteString(` ` + "\n") + + if i%5 == 0 { + b.WriteString(` ` + "\n") + for j := 1; j <= 5; j++ { + b.WriteString(fmt.Sprintf(` `+"\n", + j, j+8, []string{"read", "write", "admin"}[j%3], j%3+1)) + } + b.WriteString(` ` + "\n") + } + + if i%10 == 0 { + b.WriteString(` data & "sensitive" info.]]>` + "\n") + } + + // Mixed content element + if i%8 == 0 { + b.WriteString(fmt.Sprintf(` Employee %d joined in %d and works on Project-%s.`+"\n", + i, 2015+i%10, dept[:3])) + } + + b.WriteString(` ` + "\n") + } + + b.WriteString(`` + "\n") + writeFile(filename, b.String()) +} + +func generateDeepNesting(filename string, depth int) { + var b strings.Builder + b.WriteString(`` + "\n") + b.WriteString(`` + "\n") + b.WriteString(`` + "\n") + + // Generate a deeply nested structure with siblings at each level + for i := 1; i <= depth; i++ { + indent := strings.Repeat(" ", i) + b.WriteString(fmt.Sprintf(`%s`+"\n", indent, i, i)) + b.WriteString(fmt.Sprintf(`%s Level %d Node`+"\n", indent, i)) + b.WriteString(fmt.Sprintf(`%s Value at depth %d`+"\n", indent, i)) + if i%5 == 0 { + b.WriteString(fmt.Sprintf(`%s Sibling A at depth %d`+"\n", indent, i)) + b.WriteString(fmt.Sprintf(`%s Sibling B at depth %d`+"\n", indent, i)) + } + } + + // Close all levels + for i := depth; i >= 1; i-- { + indent := strings.Repeat(" ", i) + b.WriteString(fmt.Sprintf(`%s`+"\n", indent)) + } + + b.WriteString(`` + "\n") + writeFile(filename, b.String()) +} + +func writeFile(name, content string) { + if err := os.WriteFile(name, []byte(content), 0644); err != nil { + fmt.Fprintf(os.Stderr, "error writing %s: %v\n", name, err) + os.Exit(1) + } + fmt.Printf("Generated %s (%d bytes)\n", name, len(content)) +} diff --git a/experimental/plugins/collections/testdata/large_catalog.xml b/experimental/plugins/collections/testdata/large_catalog.xml new file mode 100644 index 000000000..2fe137379 --- /dev/null +++ b/experimental/plugins/collections/testdata/large_catalog.xml @@ -0,0 +1,7316 @@ + + + + + FirstName1 + LastName1 + + manager + SFO Office + + 50100 + + + + + + FirstName2 + LastName2 + + analyst + LON Office + + 50200 + + + + + + FirstName3 + LastName3 + + director + TKY Office + + 50300 + + + + + + + + + FirstName4 + LastName4 + + intern + BER Office + + 50400 + + + + + + FirstName5 + LastName5 + + architect + SYD Office + + 50500 + + + + + + + + + + + + + FirstName6 + LastName6 + + lead + NYC Office + + 50600 + + + + + + + + + FirstName7 + LastName7 + + specialist + SFO Office + + 50700 + + + + + + FirstName8 + LastName8 + + developer + LON Office + + 50800 + + + Employee 8 joined in 2023 and works on Project-Eng. + + + + FirstName9 + LastName9 + + manager + TKY Office + + 50900 + + + + + + + + + FirstName10 + LastName10 + + analyst + BER Office + + 51000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName11 + LastName11 + + director + SYD Office + + 51100 + + + + + + FirstName12 + LastName12 + + intern + NYC Office + + 51200 + + + + + + + + + FirstName13 + LastName13 + + architect + SFO Office + + 51300 + + + + + + FirstName14 + LastName14 + + lead + LON Office + + 51400 + + + + + + FirstName15 + LastName15 + + specialist + TKY Office + + 51500 + + + + + + + + + + + + + + + + FirstName16 + LastName16 + + developer + BER Office + + 51600 + + + Employee 16 joined in 2021 and works on Project-Eng. + + + + FirstName17 + LastName17 + + manager + SYD Office + + 51700 + + + + + + FirstName18 + LastName18 + + analyst + NYC Office + + 51800 + + + + + + + + + FirstName19 + LastName19 + + director + SFO Office + + 51900 + + + + + + FirstName20 + LastName20 + + intern + LON Office + + 52000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName21 + LastName21 + + architect + TKY Office + + 52100 + + + + + + + + + FirstName22 + LastName22 + + lead + BER Office + + 52200 + + + + + + FirstName23 + LastName23 + + specialist + SYD Office + + 52300 + + + + + + FirstName24 + LastName24 + + developer + NYC Office + + 52400 + + + + + + Employee 24 joined in 2019 and works on Project-Eng. + + + + FirstName25 + LastName25 + + manager + SFO Office + + 52500 + + + + + + + + + + + + + FirstName26 + LastName26 + + analyst + LON Office + + 52600 + + + + + + FirstName27 + LastName27 + + director + TKY Office + + 52700 + + + + + + + + + FirstName28 + LastName28 + + intern + BER Office + + 52800 + + + + + + FirstName29 + LastName29 + + architect + SYD Office + + 52900 + + + + + + FirstName30 + LastName30 + + lead + NYC Office + + 53000 + + + + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName31 + LastName31 + + specialist + SFO Office + + 53100 + + + + + + FirstName32 + LastName32 + + developer + LON Office + + 53200 + + + Employee 32 joined in 2017 and works on Project-Eng. + + + + FirstName33 + LastName33 + + manager + TKY Office + + 53300 + + + + + + + + + FirstName34 + LastName34 + + analyst + BER Office + + 53400 + + + + + + FirstName35 + LastName35 + + director + SYD Office + + 53500 + + + + + + + + + + + + + FirstName36 + LastName36 + + intern + NYC Office + + 53600 + + + + + + + + + FirstName37 + LastName37 + + architect + SFO Office + + 53700 + + + + + + FirstName38 + LastName38 + + lead + LON Office + + 53800 + + + + + + FirstName39 + LastName39 + + specialist + TKY Office + + 53900 + + + + + + + + + FirstName40 + LastName40 + + developer + BER Office + + 54000 + + + + + + + + + + data & "sensitive" info.]]> + Employee 40 joined in 2015 and works on Project-Eng. + + + + FirstName41 + LastName41 + + manager + SYD Office + + 54100 + + + + + + FirstName42 + LastName42 + + analyst + NYC Office + + 54200 + + + + + + + + + FirstName43 + LastName43 + + director + SFO Office + + 54300 + + + + + + FirstName44 + LastName44 + + intern + LON Office + + 54400 + + + + + + FirstName45 + LastName45 + + architect + TKY Office + + 54500 + + + + + + + + + + + + + + + + FirstName46 + LastName46 + + lead + BER Office + + 54600 + + + + + + FirstName47 + LastName47 + + specialist + SYD Office + + 54700 + + + + + + FirstName48 + LastName48 + + developer + NYC Office + + 54800 + + + + + + Employee 48 joined in 2023 and works on Project-Eng. + + + + FirstName49 + LastName49 + + manager + SFO Office + + 54900 + + + + + + FirstName50 + LastName50 + + analyst + LON Office + + 55000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName51 + LastName51 + + director + TKY Office + + 55100 + + + + + + + + + FirstName52 + LastName52 + + intern + BER Office + + 55200 + + + + + + FirstName53 + LastName53 + + architect + SYD Office + + 55300 + + + + + + FirstName54 + LastName54 + + lead + NYC Office + + 55400 + + + + + + + + + FirstName55 + LastName55 + + specialist + SFO Office + + 55500 + + + + + + + + + + + + + FirstName56 + LastName56 + + developer + LON Office + + 55600 + + + Employee 56 joined in 2021 and works on Project-Eng. + + + + FirstName57 + LastName57 + + manager + TKY Office + + 55700 + + + + + + + + + FirstName58 + LastName58 + + analyst + BER Office + + 55800 + + + + + + FirstName59 + LastName59 + + director + SYD Office + + 55900 + + + + + + FirstName60 + LastName60 + + intern + NYC Office + + 56000 + + + + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName61 + LastName61 + + architect + SFO Office + + 56100 + + + + + + FirstName62 + LastName62 + + lead + LON Office + + 56200 + + + + + + FirstName63 + LastName63 + + specialist + TKY Office + + 56300 + + + + + + + + + FirstName64 + LastName64 + + developer + BER Office + + 56400 + + + Employee 64 joined in 2019 and works on Project-Eng. + + + + FirstName65 + LastName65 + + manager + SYD Office + + 56500 + + + + + + + + + + + + + FirstName66 + LastName66 + + analyst + NYC Office + + 56600 + + + + + + + + + FirstName67 + LastName67 + + director + SFO Office + + 56700 + + + + + + FirstName68 + LastName68 + + intern + LON Office + + 56800 + + + + + + FirstName69 + LastName69 + + architect + TKY Office + + 56900 + + + + + + + + + FirstName70 + LastName70 + + lead + BER Office + + 57000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName71 + LastName71 + + specialist + SYD Office + + 57100 + + + + + + FirstName72 + LastName72 + + developer + NYC Office + + 57200 + + + + + + Employee 72 joined in 2017 and works on Project-Eng. + + + + FirstName73 + LastName73 + + manager + SFO Office + + 57300 + + + + + + FirstName74 + LastName74 + + analyst + LON Office + + 57400 + + + + + + FirstName75 + LastName75 + + director + TKY Office + + 57500 + + + + + + + + + + + + + + + + FirstName76 + LastName76 + + intern + BER Office + + 57600 + + + + + + FirstName77 + LastName77 + + architect + SYD Office + + 57700 + + + + + + FirstName78 + LastName78 + + lead + NYC Office + + 57800 + + + + + + + + + FirstName79 + LastName79 + + specialist + SFO Office + + 57900 + + + + + + FirstName80 + LastName80 + + developer + LON Office + + 58000 + + + + + + + + + + data & "sensitive" info.]]> + Employee 80 joined in 2015 and works on Project-Eng. + + + + FirstName81 + LastName81 + + manager + TKY Office + + 58100 + + + + + + + + + FirstName82 + LastName82 + + analyst + BER Office + + 58200 + + + + + + FirstName83 + LastName83 + + director + SYD Office + + 58300 + + + + + + FirstName84 + LastName84 + + intern + NYC Office + + 58400 + + + + + + + + + FirstName85 + LastName85 + + architect + SFO Office + + 58500 + + + + + + + + + + + + + FirstName86 + LastName86 + + lead + LON Office + + 58600 + + + + + + FirstName87 + LastName87 + + specialist + TKY Office + + 58700 + + + + + + + + + FirstName88 + LastName88 + + developer + BER Office + + 58800 + + + Employee 88 joined in 2023 and works on Project-Eng. + + + + FirstName89 + LastName89 + + manager + SYD Office + + 58900 + + + + + + FirstName90 + LastName90 + + analyst + NYC Office + + 59000 + + + + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName91 + LastName91 + + director + SFO Office + + 59100 + + + + + + FirstName92 + LastName92 + + intern + LON Office + + 59200 + + + + + + FirstName93 + LastName93 + + architect + TKY Office + + 59300 + + + + + + + + + FirstName94 + LastName94 + + lead + BER Office + + 59400 + + + + + + FirstName95 + LastName95 + + specialist + SYD Office + + 59500 + + + + + + + + + + + + + FirstName96 + LastName96 + + developer + NYC Office + + 59600 + + + + + + Employee 96 joined in 2021 and works on Project-Eng. + + + + FirstName97 + LastName97 + + manager + SFO Office + + 59700 + + + + + + FirstName98 + LastName98 + + analyst + LON Office + + 59800 + + + + + + FirstName99 + LastName99 + + director + TKY Office + + 59900 + + + + + + + + + FirstName100 + LastName100 + + intern + BER Office + + 60000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName101 + LastName101 + + architect + SYD Office + + 60100 + + + + + + FirstName102 + LastName102 + + lead + NYC Office + + 60200 + + + + + + + + + FirstName103 + LastName103 + + specialist + SFO Office + + 60300 + + + + + + FirstName104 + LastName104 + + developer + LON Office + + 60400 + + + Employee 104 joined in 2019 and works on Project-Eng. + + + + FirstName105 + LastName105 + + manager + TKY Office + + 60500 + + + + + + + + + + + + + + + + FirstName106 + LastName106 + + analyst + BER Office + + 60600 + + + + + + FirstName107 + LastName107 + + director + SYD Office + + 60700 + + + + + + FirstName108 + LastName108 + + intern + NYC Office + + 60800 + + + + + + + + + FirstName109 + LastName109 + + architect + SFO Office + + 60900 + + + + + + FirstName110 + LastName110 + + lead + LON Office + + 61000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName111 + LastName111 + + specialist + TKY Office + + 61100 + + + + + + + + + FirstName112 + LastName112 + + developer + BER Office + + 61200 + + + Employee 112 joined in 2017 and works on Project-Eng. + + + + FirstName113 + LastName113 + + manager + SYD Office + + 61300 + + + + + + FirstName114 + LastName114 + + analyst + NYC Office + + 61400 + + + + + + + + + FirstName115 + LastName115 + + director + SFO Office + + 61500 + + + + + + + + + + + + + FirstName116 + LastName116 + + intern + LON Office + + 61600 + + + + + + FirstName117 + LastName117 + + architect + TKY Office + + 61700 + + + + + + + + + FirstName118 + LastName118 + + lead + BER Office + + 61800 + + + + + + FirstName119 + LastName119 + + specialist + SYD Office + + 61900 + + + + + + FirstName120 + LastName120 + + developer + NYC Office + + 62000 + + + + + + + + + + + + + data & "sensitive" info.]]> + Employee 120 joined in 2015 and works on Project-Eng. + + + + FirstName121 + LastName121 + + manager + SFO Office + + 62100 + + + + + + FirstName122 + LastName122 + + analyst + LON Office + + 62200 + + + + + + FirstName123 + LastName123 + + director + TKY Office + + 62300 + + + + + + + + + FirstName124 + LastName124 + + intern + BER Office + + 62400 + + + + + + FirstName125 + LastName125 + + architect + SYD Office + + 62500 + + + + + + + + + + + + + FirstName126 + LastName126 + + lead + NYC Office + + 62600 + + + + + + + + + FirstName127 + LastName127 + + specialist + SFO Office + + 62700 + + + + + + FirstName128 + LastName128 + + developer + LON Office + + 62800 + + + Employee 128 joined in 2023 and works on Project-Eng. + + + + FirstName129 + LastName129 + + manager + TKY Office + + 62900 + + + + + + + + + FirstName130 + LastName130 + + analyst + BER Office + + 63000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName131 + LastName131 + + director + SYD Office + + 63100 + + + + + + FirstName132 + LastName132 + + intern + NYC Office + + 63200 + + + + + + + + + FirstName133 + LastName133 + + architect + SFO Office + + 63300 + + + + + + FirstName134 + LastName134 + + lead + LON Office + + 63400 + + + + + + FirstName135 + LastName135 + + specialist + TKY Office + + 63500 + + + + + + + + + + + + + + + + FirstName136 + LastName136 + + developer + BER Office + + 63600 + + + Employee 136 joined in 2021 and works on Project-Eng. + + + + FirstName137 + LastName137 + + manager + SYD Office + + 63700 + + + + + + FirstName138 + LastName138 + + analyst + NYC Office + + 63800 + + + + + + + + + FirstName139 + LastName139 + + director + SFO Office + + 63900 + + + + + + FirstName140 + LastName140 + + intern + LON Office + + 64000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName141 + LastName141 + + architect + TKY Office + + 64100 + + + + + + + + + FirstName142 + LastName142 + + lead + BER Office + + 64200 + + + + + + FirstName143 + LastName143 + + specialist + SYD Office + + 64300 + + + + + + FirstName144 + LastName144 + + developer + NYC Office + + 64400 + + + + + + Employee 144 joined in 2019 and works on Project-Eng. + + + + FirstName145 + LastName145 + + manager + SFO Office + + 64500 + + + + + + + + + + + + + FirstName146 + LastName146 + + analyst + LON Office + + 64600 + + + + + + FirstName147 + LastName147 + + director + TKY Office + + 64700 + + + + + + + + + FirstName148 + LastName148 + + intern + BER Office + + 64800 + + + + + + FirstName149 + LastName149 + + architect + SYD Office + + 64900 + + + + + + FirstName150 + LastName150 + + lead + NYC Office + + 65000 + + + + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName151 + LastName151 + + specialist + SFO Office + + 65100 + + + + + + FirstName152 + LastName152 + + developer + LON Office + + 65200 + + + Employee 152 joined in 2017 and works on Project-Eng. + + + + FirstName153 + LastName153 + + manager + TKY Office + + 65300 + + + + + + + + + FirstName154 + LastName154 + + analyst + BER Office + + 65400 + + + + + + FirstName155 + LastName155 + + director + SYD Office + + 65500 + + + + + + + + + + + + + FirstName156 + LastName156 + + intern + NYC Office + + 65600 + + + + + + + + + FirstName157 + LastName157 + + architect + SFO Office + + 65700 + + + + + + FirstName158 + LastName158 + + lead + LON Office + + 65800 + + + + + + FirstName159 + LastName159 + + specialist + TKY Office + + 65900 + + + + + + + + + FirstName160 + LastName160 + + developer + BER Office + + 66000 + + + + + + + + + + data & "sensitive" info.]]> + Employee 160 joined in 2015 and works on Project-Eng. + + + + FirstName161 + LastName161 + + manager + SYD Office + + 66100 + + + + + + FirstName162 + LastName162 + + analyst + NYC Office + + 66200 + + + + + + + + + FirstName163 + LastName163 + + director + SFO Office + + 66300 + + + + + + FirstName164 + LastName164 + + intern + LON Office + + 66400 + + + + + + FirstName165 + LastName165 + + architect + TKY Office + + 66500 + + + + + + + + + + + + + + + + FirstName166 + LastName166 + + lead + BER Office + + 66600 + + + + + + FirstName167 + LastName167 + + specialist + SYD Office + + 66700 + + + + + + FirstName168 + LastName168 + + developer + NYC Office + + 66800 + + + + + + Employee 168 joined in 2023 and works on Project-Eng. + + + + FirstName169 + LastName169 + + manager + SFO Office + + 66900 + + + + + + FirstName170 + LastName170 + + analyst + LON Office + + 67000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName171 + LastName171 + + director + TKY Office + + 67100 + + + + + + + + + FirstName172 + LastName172 + + intern + BER Office + + 67200 + + + + + + FirstName173 + LastName173 + + architect + SYD Office + + 67300 + + + + + + FirstName174 + LastName174 + + lead + NYC Office + + 67400 + + + + + + + + + FirstName175 + LastName175 + + specialist + SFO Office + + 67500 + + + + + + + + + + + + + FirstName176 + LastName176 + + developer + LON Office + + 67600 + + + Employee 176 joined in 2021 and works on Project-Eng. + + + + FirstName177 + LastName177 + + manager + TKY Office + + 67700 + + + + + + + + + FirstName178 + LastName178 + + analyst + BER Office + + 67800 + + + + + + FirstName179 + LastName179 + + director + SYD Office + + 67900 + + + + + + FirstName180 + LastName180 + + intern + NYC Office + + 68000 + + + + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName181 + LastName181 + + architect + SFO Office + + 68100 + + + + + + FirstName182 + LastName182 + + lead + LON Office + + 68200 + + + + + + FirstName183 + LastName183 + + specialist + TKY Office + + 68300 + + + + + + + + + FirstName184 + LastName184 + + developer + BER Office + + 68400 + + + Employee 184 joined in 2019 and works on Project-Eng. + + + + FirstName185 + LastName185 + + manager + SYD Office + + 68500 + + + + + + + + + + + + + FirstName186 + LastName186 + + analyst + NYC Office + + 68600 + + + + + + + + + FirstName187 + LastName187 + + director + SFO Office + + 68700 + + + + + + FirstName188 + LastName188 + + intern + LON Office + + 68800 + + + + + + FirstName189 + LastName189 + + architect + TKY Office + + 68900 + + + + + + + + + FirstName190 + LastName190 + + lead + BER Office + + 69000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName191 + LastName191 + + specialist + SYD Office + + 69100 + + + + + + FirstName192 + LastName192 + + developer + NYC Office + + 69200 + + + + + + Employee 192 joined in 2017 and works on Project-Eng. + + + + FirstName193 + LastName193 + + manager + SFO Office + + 69300 + + + + + + FirstName194 + LastName194 + + analyst + LON Office + + 69400 + + + + + + FirstName195 + LastName195 + + director + TKY Office + + 69500 + + + + + + + + + + + + + + + + FirstName196 + LastName196 + + intern + BER Office + + 69600 + + + + + + FirstName197 + LastName197 + + architect + SYD Office + + 69700 + + + + + + FirstName198 + LastName198 + + lead + NYC Office + + 69800 + + + + + + + + + FirstName199 + LastName199 + + specialist + SFO Office + + 69900 + + + + + + FirstName200 + LastName200 + + developer + LON Office + + 70000 + + + + + + + + + + data & "sensitive" info.]]> + Employee 200 joined in 2015 and works on Project-Eng. + + + + FirstName201 + LastName201 + + manager + TKY Office + + 70100 + + + + + + + + + FirstName202 + LastName202 + + analyst + BER Office + + 70200 + + + + + + FirstName203 + LastName203 + + director + SYD Office + + 70300 + + + + + + FirstName204 + LastName204 + + intern + NYC Office + + 70400 + + + + + + + + + FirstName205 + LastName205 + + architect + SFO Office + + 70500 + + + + + + + + + + + + + FirstName206 + LastName206 + + lead + LON Office + + 70600 + + + + + + FirstName207 + LastName207 + + specialist + TKY Office + + 70700 + + + + + + + + + FirstName208 + LastName208 + + developer + BER Office + + 70800 + + + Employee 208 joined in 2023 and works on Project-Eng. + + + + FirstName209 + LastName209 + + manager + SYD Office + + 70900 + + + + + + FirstName210 + LastName210 + + analyst + NYC Office + + 71000 + + + + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName211 + LastName211 + + director + SFO Office + + 71100 + + + + + + FirstName212 + LastName212 + + intern + LON Office + + 71200 + + + + + + FirstName213 + LastName213 + + architect + TKY Office + + 71300 + + + + + + + + + FirstName214 + LastName214 + + lead + BER Office + + 71400 + + + + + + FirstName215 + LastName215 + + specialist + SYD Office + + 71500 + + + + + + + + + + + + + FirstName216 + LastName216 + + developer + NYC Office + + 71600 + + + + + + Employee 216 joined in 2021 and works on Project-Eng. + + + + FirstName217 + LastName217 + + manager + SFO Office + + 71700 + + + + + + FirstName218 + LastName218 + + analyst + LON Office + + 71800 + + + + + + FirstName219 + LastName219 + + director + TKY Office + + 71900 + + + + + + + + + FirstName220 + LastName220 + + intern + BER Office + + 72000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName221 + LastName221 + + architect + SYD Office + + 72100 + + + + + + FirstName222 + LastName222 + + lead + NYC Office + + 72200 + + + + + + + + + FirstName223 + LastName223 + + specialist + SFO Office + + 72300 + + + + + + FirstName224 + LastName224 + + developer + LON Office + + 72400 + + + Employee 224 joined in 2019 and works on Project-Eng. + + + + FirstName225 + LastName225 + + manager + TKY Office + + 72500 + + + + + + + + + + + + + + + + FirstName226 + LastName226 + + analyst + BER Office + + 72600 + + + + + + FirstName227 + LastName227 + + director + SYD Office + + 72700 + + + + + + FirstName228 + LastName228 + + intern + NYC Office + + 72800 + + + + + + + + + FirstName229 + LastName229 + + architect + SFO Office + + 72900 + + + + + + FirstName230 + LastName230 + + lead + LON Office + + 73000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName231 + LastName231 + + specialist + TKY Office + + 73100 + + + + + + + + + FirstName232 + LastName232 + + developer + BER Office + + 73200 + + + Employee 232 joined in 2017 and works on Project-Eng. + + + + FirstName233 + LastName233 + + manager + SYD Office + + 73300 + + + + + + FirstName234 + LastName234 + + analyst + NYC Office + + 73400 + + + + + + + + + FirstName235 + LastName235 + + director + SFO Office + + 73500 + + + + + + + + + + + + + FirstName236 + LastName236 + + intern + LON Office + + 73600 + + + + + + FirstName237 + LastName237 + + architect + TKY Office + + 73700 + + + + + + + + + FirstName238 + LastName238 + + lead + BER Office + + 73800 + + + + + + FirstName239 + LastName239 + + specialist + SYD Office + + 73900 + + + + + + FirstName240 + LastName240 + + developer + NYC Office + + 74000 + + + + + + + + + + + + + data & "sensitive" info.]]> + Employee 240 joined in 2015 and works on Project-Eng. + + + + FirstName241 + LastName241 + + manager + SFO Office + + 74100 + + + + + + FirstName242 + LastName242 + + analyst + LON Office + + 74200 + + + + + + FirstName243 + LastName243 + + director + TKY Office + + 74300 + + + + + + + + + FirstName244 + LastName244 + + intern + BER Office + + 74400 + + + + + + FirstName245 + LastName245 + + architect + SYD Office + + 74500 + + + + + + + + + + + + + FirstName246 + LastName246 + + lead + NYC Office + + 74600 + + + + + + + + + FirstName247 + LastName247 + + specialist + SFO Office + + 74700 + + + + + + FirstName248 + LastName248 + + developer + LON Office + + 74800 + + + Employee 248 joined in 2023 and works on Project-Eng. + + + + FirstName249 + LastName249 + + manager + TKY Office + + 74900 + + + + + + + + + FirstName250 + LastName250 + + analyst + BER Office + + 75000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName251 + LastName251 + + director + SYD Office + + 75100 + + + + + + FirstName252 + LastName252 + + intern + NYC Office + + 75200 + + + + + + + + + FirstName253 + LastName253 + + architect + SFO Office + + 75300 + + + + + + FirstName254 + LastName254 + + lead + LON Office + + 75400 + + + + + + FirstName255 + LastName255 + + specialist + TKY Office + + 75500 + + + + + + + + + + + + + + + + FirstName256 + LastName256 + + developer + BER Office + + 75600 + + + Employee 256 joined in 2021 and works on Project-Eng. + + + + FirstName257 + LastName257 + + manager + SYD Office + + 75700 + + + + + + FirstName258 + LastName258 + + analyst + NYC Office + + 75800 + + + + + + + + + FirstName259 + LastName259 + + director + SFO Office + + 75900 + + + + + + FirstName260 + LastName260 + + intern + LON Office + + 76000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName261 + LastName261 + + architect + TKY Office + + 76100 + + + + + + + + + FirstName262 + LastName262 + + lead + BER Office + + 76200 + + + + + + FirstName263 + LastName263 + + specialist + SYD Office + + 76300 + + + + + + FirstName264 + LastName264 + + developer + NYC Office + + 76400 + + + + + + Employee 264 joined in 2019 and works on Project-Eng. + + + + FirstName265 + LastName265 + + manager + SFO Office + + 76500 + + + + + + + + + + + + + FirstName266 + LastName266 + + analyst + LON Office + + 76600 + + + + + + FirstName267 + LastName267 + + director + TKY Office + + 76700 + + + + + + + + + FirstName268 + LastName268 + + intern + BER Office + + 76800 + + + + + + FirstName269 + LastName269 + + architect + SYD Office + + 76900 + + + + + + FirstName270 + LastName270 + + lead + NYC Office + + 77000 + + + + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName271 + LastName271 + + specialist + SFO Office + + 77100 + + + + + + FirstName272 + LastName272 + + developer + LON Office + + 77200 + + + Employee 272 joined in 2017 and works on Project-Eng. + + + + FirstName273 + LastName273 + + manager + TKY Office + + 77300 + + + + + + + + + FirstName274 + LastName274 + + analyst + BER Office + + 77400 + + + + + + FirstName275 + LastName275 + + director + SYD Office + + 77500 + + + + + + + + + + + + + FirstName276 + LastName276 + + intern + NYC Office + + 77600 + + + + + + + + + FirstName277 + LastName277 + + architect + SFO Office + + 77700 + + + + + + FirstName278 + LastName278 + + lead + LON Office + + 77800 + + + + + + FirstName279 + LastName279 + + specialist + TKY Office + + 77900 + + + + + + + + + FirstName280 + LastName280 + + developer + BER Office + + 78000 + + + + + + + + + + data & "sensitive" info.]]> + Employee 280 joined in 2015 and works on Project-Eng. + + + + FirstName281 + LastName281 + + manager + SYD Office + + 78100 + + + + + + FirstName282 + LastName282 + + analyst + NYC Office + + 78200 + + + + + + + + + FirstName283 + LastName283 + + director + SFO Office + + 78300 + + + + + + FirstName284 + LastName284 + + intern + LON Office + + 78400 + + + + + + FirstName285 + LastName285 + + architect + TKY Office + + 78500 + + + + + + + + + + + + + + + + FirstName286 + LastName286 + + lead + BER Office + + 78600 + + + + + + FirstName287 + LastName287 + + specialist + SYD Office + + 78700 + + + + + + FirstName288 + LastName288 + + developer + NYC Office + + 78800 + + + + + + Employee 288 joined in 2023 and works on Project-Eng. + + + + FirstName289 + LastName289 + + manager + SFO Office + + 78900 + + + + + + FirstName290 + LastName290 + + analyst + LON Office + + 79000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName291 + LastName291 + + director + TKY Office + + 79100 + + + + + + + + + FirstName292 + LastName292 + + intern + BER Office + + 79200 + + + + + + FirstName293 + LastName293 + + architect + SYD Office + + 79300 + + + + + + FirstName294 + LastName294 + + lead + NYC Office + + 79400 + + + + + + + + + FirstName295 + LastName295 + + specialist + SFO Office + + 79500 + + + + + + + + + + + + + FirstName296 + LastName296 + + developer + LON Office + + 79600 + + + Employee 296 joined in 2021 and works on Project-Eng. + + + + FirstName297 + LastName297 + + manager + TKY Office + + 79700 + + + + + + + + + FirstName298 + LastName298 + + analyst + BER Office + + 79800 + + + + + + FirstName299 + LastName299 + + director + SYD Office + + 79900 + + + + + + FirstName300 + LastName300 + + intern + NYC Office + + 80000 + + + + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName301 + LastName301 + + architect + SFO Office + + 80100 + + + + + + FirstName302 + LastName302 + + lead + LON Office + + 80200 + + + + + + FirstName303 + LastName303 + + specialist + TKY Office + + 80300 + + + + + + + + + FirstName304 + LastName304 + + developer + BER Office + + 80400 + + + Employee 304 joined in 2019 and works on Project-Eng. + + + + FirstName305 + LastName305 + + manager + SYD Office + + 80500 + + + + + + + + + + + + + FirstName306 + LastName306 + + analyst + NYC Office + + 80600 + + + + + + + + + FirstName307 + LastName307 + + director + SFO Office + + 80700 + + + + + + FirstName308 + LastName308 + + intern + LON Office + + 80800 + + + + + + FirstName309 + LastName309 + + architect + TKY Office + + 80900 + + + + + + + + + FirstName310 + LastName310 + + lead + BER Office + + 81000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName311 + LastName311 + + specialist + SYD Office + + 81100 + + + + + + FirstName312 + LastName312 + + developer + NYC Office + + 81200 + + + + + + Employee 312 joined in 2017 and works on Project-Eng. + + + + FirstName313 + LastName313 + + manager + SFO Office + + 81300 + + + + + + FirstName314 + LastName314 + + analyst + LON Office + + 81400 + + + + + + FirstName315 + LastName315 + + director + TKY Office + + 81500 + + + + + + + + + + + + + + + + FirstName316 + LastName316 + + intern + BER Office + + 81600 + + + + + + FirstName317 + LastName317 + + architect + SYD Office + + 81700 + + + + + + FirstName318 + LastName318 + + lead + NYC Office + + 81800 + + + + + + + + + FirstName319 + LastName319 + + specialist + SFO Office + + 81900 + + + + + + FirstName320 + LastName320 + + developer + LON Office + + 82000 + + + + + + + + + + data & "sensitive" info.]]> + Employee 320 joined in 2015 and works on Project-Eng. + + + + FirstName321 + LastName321 + + manager + TKY Office + + 82100 + + + + + + + + + FirstName322 + LastName322 + + analyst + BER Office + + 82200 + + + + + + FirstName323 + LastName323 + + director + SYD Office + + 82300 + + + + + + FirstName324 + LastName324 + + intern + NYC Office + + 82400 + + + + + + + + + FirstName325 + LastName325 + + architect + SFO Office + + 82500 + + + + + + + + + + + + + FirstName326 + LastName326 + + lead + LON Office + + 82600 + + + + + + FirstName327 + LastName327 + + specialist + TKY Office + + 82700 + + + + + + + + + FirstName328 + LastName328 + + developer + BER Office + + 82800 + + + Employee 328 joined in 2023 and works on Project-Eng. + + + + FirstName329 + LastName329 + + manager + SYD Office + + 82900 + + + + + + FirstName330 + LastName330 + + analyst + NYC Office + + 83000 + + + + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName331 + LastName331 + + director + SFO Office + + 83100 + + + + + + FirstName332 + LastName332 + + intern + LON Office + + 83200 + + + + + + FirstName333 + LastName333 + + architect + TKY Office + + 83300 + + + + + + + + + FirstName334 + LastName334 + + lead + BER Office + + 83400 + + + + + + FirstName335 + LastName335 + + specialist + SYD Office + + 83500 + + + + + + + + + + + + + FirstName336 + LastName336 + + developer + NYC Office + + 83600 + + + + + + Employee 336 joined in 2021 and works on Project-Eng. + + + + FirstName337 + LastName337 + + manager + SFO Office + + 83700 + + + + + + FirstName338 + LastName338 + + analyst + LON Office + + 83800 + + + + + + FirstName339 + LastName339 + + director + TKY Office + + 83900 + + + + + + + + + FirstName340 + LastName340 + + intern + BER Office + + 84000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName341 + LastName341 + + architect + SYD Office + + 84100 + + + + + + FirstName342 + LastName342 + + lead + NYC Office + + 84200 + + + + + + + + + FirstName343 + LastName343 + + specialist + SFO Office + + 84300 + + + + + + FirstName344 + LastName344 + + developer + LON Office + + 84400 + + + Employee 344 joined in 2019 and works on Project-Eng. + + + + FirstName345 + LastName345 + + manager + TKY Office + + 84500 + + + + + + + + + + + + + + + + FirstName346 + LastName346 + + analyst + BER Office + + 84600 + + + + + + FirstName347 + LastName347 + + director + SYD Office + + 84700 + + + + + + FirstName348 + LastName348 + + intern + NYC Office + + 84800 + + + + + + + + + FirstName349 + LastName349 + + architect + SFO Office + + 84900 + + + + + + FirstName350 + LastName350 + + lead + LON Office + + 85000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName351 + LastName351 + + specialist + TKY Office + + 85100 + + + + + + + + + FirstName352 + LastName352 + + developer + BER Office + + 85200 + + + Employee 352 joined in 2017 and works on Project-Eng. + + + + FirstName353 + LastName353 + + manager + SYD Office + + 85300 + + + + + + FirstName354 + LastName354 + + analyst + NYC Office + + 85400 + + + + + + + + + FirstName355 + LastName355 + + director + SFO Office + + 85500 + + + + + + + + + + + + + FirstName356 + LastName356 + + intern + LON Office + + 85600 + + + + + + FirstName357 + LastName357 + + architect + TKY Office + + 85700 + + + + + + + + + FirstName358 + LastName358 + + lead + BER Office + + 85800 + + + + + + FirstName359 + LastName359 + + specialist + SYD Office + + 85900 + + + + + + FirstName360 + LastName360 + + developer + NYC Office + + 86000 + + + + + + + + + + + + + data & "sensitive" info.]]> + Employee 360 joined in 2015 and works on Project-Eng. + + + + FirstName361 + LastName361 + + manager + SFO Office + + 86100 + + + + + + FirstName362 + LastName362 + + analyst + LON Office + + 86200 + + + + + + FirstName363 + LastName363 + + director + TKY Office + + 86300 + + + + + + + + + FirstName364 + LastName364 + + intern + BER Office + + 86400 + + + + + + FirstName365 + LastName365 + + architect + SYD Office + + 86500 + + + + + + + + + + + + + FirstName366 + LastName366 + + lead + NYC Office + + 86600 + + + + + + + + + FirstName367 + LastName367 + + specialist + SFO Office + + 86700 + + + + + + FirstName368 + LastName368 + + developer + LON Office + + 86800 + + + Employee 368 joined in 2023 and works on Project-Eng. + + + + FirstName369 + LastName369 + + manager + TKY Office + + 86900 + + + + + + + + + FirstName370 + LastName370 + + analyst + BER Office + + 87000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName371 + LastName371 + + director + SYD Office + + 87100 + + + + + + FirstName372 + LastName372 + + intern + NYC Office + + 87200 + + + + + + + + + FirstName373 + LastName373 + + architect + SFO Office + + 87300 + + + + + + FirstName374 + LastName374 + + lead + LON Office + + 87400 + + + + + + FirstName375 + LastName375 + + specialist + TKY Office + + 87500 + + + + + + + + + + + + + + + + FirstName376 + LastName376 + + developer + BER Office + + 87600 + + + Employee 376 joined in 2021 and works on Project-Eng. + + + + FirstName377 + LastName377 + + manager + SYD Office + + 87700 + + + + + + FirstName378 + LastName378 + + analyst + NYC Office + + 87800 + + + + + + + + + FirstName379 + LastName379 + + director + SFO Office + + 87900 + + + + + + FirstName380 + LastName380 + + intern + LON Office + + 88000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName381 + LastName381 + + architect + TKY Office + + 88100 + + + + + + + + + FirstName382 + LastName382 + + lead + BER Office + + 88200 + + + + + + FirstName383 + LastName383 + + specialist + SYD Office + + 88300 + + + + + + FirstName384 + LastName384 + + developer + NYC Office + + 88400 + + + + + + Employee 384 joined in 2019 and works on Project-Eng. + + + + FirstName385 + LastName385 + + manager + SFO Office + + 88500 + + + + + + + + + + + + + FirstName386 + LastName386 + + analyst + LON Office + + 88600 + + + + + + FirstName387 + LastName387 + + director + TKY Office + + 88700 + + + + + + + + + FirstName388 + LastName388 + + intern + BER Office + + 88800 + + + + + + FirstName389 + LastName389 + + architect + SYD Office + + 88900 + + + + + + FirstName390 + LastName390 + + lead + NYC Office + + 89000 + + + + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName391 + LastName391 + + specialist + SFO Office + + 89100 + + + + + + FirstName392 + LastName392 + + developer + LON Office + + 89200 + + + Employee 392 joined in 2017 and works on Project-Eng. + + + + FirstName393 + LastName393 + + manager + TKY Office + + 89300 + + + + + + + + + FirstName394 + LastName394 + + analyst + BER Office + + 89400 + + + + + + FirstName395 + LastName395 + + director + SYD Office + + 89500 + + + + + + + + + + + + + FirstName396 + LastName396 + + intern + NYC Office + + 89600 + + + + + + + + + FirstName397 + LastName397 + + architect + SFO Office + + 89700 + + + + + + FirstName398 + LastName398 + + lead + LON Office + + 89800 + + + + + + FirstName399 + LastName399 + + specialist + TKY Office + + 89900 + + + + + + + + + FirstName400 + LastName400 + + developer + BER Office + + 90000 + + + + + + + + + + data & "sensitive" info.]]> + Employee 400 joined in 2015 and works on Project-Eng. + + + + FirstName401 + LastName401 + + manager + SYD Office + + 90100 + + + + + + FirstName402 + LastName402 + + analyst + NYC Office + + 90200 + + + + + + + + + FirstName403 + LastName403 + + director + SFO Office + + 90300 + + + + + + FirstName404 + LastName404 + + intern + LON Office + + 90400 + + + + + + FirstName405 + LastName405 + + architect + TKY Office + + 90500 + + + + + + + + + + + + + + + + FirstName406 + LastName406 + + lead + BER Office + + 90600 + + + + + + FirstName407 + LastName407 + + specialist + SYD Office + + 90700 + + + + + + FirstName408 + LastName408 + + developer + NYC Office + + 90800 + + + + + + Employee 408 joined in 2023 and works on Project-Eng. + + + + FirstName409 + LastName409 + + manager + SFO Office + + 90900 + + + + + + FirstName410 + LastName410 + + analyst + LON Office + + 91000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName411 + LastName411 + + director + TKY Office + + 91100 + + + + + + + + + FirstName412 + LastName412 + + intern + BER Office + + 91200 + + + + + + FirstName413 + LastName413 + + architect + SYD Office + + 91300 + + + + + + FirstName414 + LastName414 + + lead + NYC Office + + 91400 + + + + + + + + + FirstName415 + LastName415 + + specialist + SFO Office + + 91500 + + + + + + + + + + + + + FirstName416 + LastName416 + + developer + LON Office + + 91600 + + + Employee 416 joined in 2021 and works on Project-Eng. + + + + FirstName417 + LastName417 + + manager + TKY Office + + 91700 + + + + + + + + + FirstName418 + LastName418 + + analyst + BER Office + + 91800 + + + + + + FirstName419 + LastName419 + + director + SYD Office + + 91900 + + + + + + FirstName420 + LastName420 + + intern + NYC Office + + 92000 + + + + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName421 + LastName421 + + architect + SFO Office + + 92100 + + + + + + FirstName422 + LastName422 + + lead + LON Office + + 92200 + + + + + + FirstName423 + LastName423 + + specialist + TKY Office + + 92300 + + + + + + + + + FirstName424 + LastName424 + + developer + BER Office + + 92400 + + + Employee 424 joined in 2019 and works on Project-Eng. + + + + FirstName425 + LastName425 + + manager + SYD Office + + 92500 + + + + + + + + + + + + + FirstName426 + LastName426 + + analyst + NYC Office + + 92600 + + + + + + + + + FirstName427 + LastName427 + + director + SFO Office + + 92700 + + + + + + FirstName428 + LastName428 + + intern + LON Office + + 92800 + + + + + + FirstName429 + LastName429 + + architect + TKY Office + + 92900 + + + + + + + + + FirstName430 + LastName430 + + lead + BER Office + + 93000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName431 + LastName431 + + specialist + SYD Office + + 93100 + + + + + + FirstName432 + LastName432 + + developer + NYC Office + + 93200 + + + + + + Employee 432 joined in 2017 and works on Project-Eng. + + + + FirstName433 + LastName433 + + manager + SFO Office + + 93300 + + + + + + FirstName434 + LastName434 + + analyst + LON Office + + 93400 + + + + + + FirstName435 + LastName435 + + director + TKY Office + + 93500 + + + + + + + + + + + + + + + + FirstName436 + LastName436 + + intern + BER Office + + 93600 + + + + + + FirstName437 + LastName437 + + architect + SYD Office + + 93700 + + + + + + FirstName438 + LastName438 + + lead + NYC Office + + 93800 + + + + + + + + + FirstName439 + LastName439 + + specialist + SFO Office + + 93900 + + + + + + FirstName440 + LastName440 + + developer + LON Office + + 94000 + + + + + + + + + + data & "sensitive" info.]]> + Employee 440 joined in 2015 and works on Project-Eng. + + + + FirstName441 + LastName441 + + manager + TKY Office + + 94100 + + + + + + + + + FirstName442 + LastName442 + + analyst + BER Office + + 94200 + + + + + + FirstName443 + LastName443 + + director + SYD Office + + 94300 + + + + + + FirstName444 + LastName444 + + intern + NYC Office + + 94400 + + + + + + + + + FirstName445 + LastName445 + + architect + SFO Office + + 94500 + + + + + + + + + + + + + FirstName446 + LastName446 + + lead + LON Office + + 94600 + + + + + + FirstName447 + LastName447 + + specialist + TKY Office + + 94700 + + + + + + + + + FirstName448 + LastName448 + + developer + BER Office + + 94800 + + + Employee 448 joined in 2023 and works on Project-Eng. + + + + FirstName449 + LastName449 + + manager + SYD Office + + 94900 + + + + + + FirstName450 + LastName450 + + analyst + NYC Office + + 95000 + + + + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName451 + LastName451 + + director + SFO Office + + 95100 + + + + + + FirstName452 + LastName452 + + intern + LON Office + + 95200 + + + + + + FirstName453 + LastName453 + + architect + TKY Office + + 95300 + + + + + + + + + FirstName454 + LastName454 + + lead + BER Office + + 95400 + + + + + + FirstName455 + LastName455 + + specialist + SYD Office + + 95500 + + + + + + + + + + + + + FirstName456 + LastName456 + + developer + NYC Office + + 95600 + + + + + + Employee 456 joined in 2021 and works on Project-Eng. + + + + FirstName457 + LastName457 + + manager + SFO Office + + 95700 + + + + + + FirstName458 + LastName458 + + analyst + LON Office + + 95800 + + + + + + FirstName459 + LastName459 + + director + TKY Office + + 95900 + + + + + + + + + FirstName460 + LastName460 + + intern + BER Office + + 96000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName461 + LastName461 + + architect + SYD Office + + 96100 + + + + + + FirstName462 + LastName462 + + lead + NYC Office + + 96200 + + + + + + + + + FirstName463 + LastName463 + + specialist + SFO Office + + 96300 + + + + + + FirstName464 + LastName464 + + developer + LON Office + + 96400 + + + Employee 464 joined in 2019 and works on Project-Eng. + + + + FirstName465 + LastName465 + + manager + TKY Office + + 96500 + + + + + + + + + + + + + + + + FirstName466 + LastName466 + + analyst + BER Office + + 96600 + + + + + + FirstName467 + LastName467 + + director + SYD Office + + 96700 + + + + + + FirstName468 + LastName468 + + intern + NYC Office + + 96800 + + + + + + + + + FirstName469 + LastName469 + + architect + SFO Office + + 96900 + + + + + + FirstName470 + LastName470 + + lead + LON Office + + 97000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName471 + LastName471 + + specialist + TKY Office + + 97100 + + + + + + + + + FirstName472 + LastName472 + + developer + BER Office + + 97200 + + + Employee 472 joined in 2017 and works on Project-Eng. + + + + FirstName473 + LastName473 + + manager + SYD Office + + 97300 + + + + + + FirstName474 + LastName474 + + analyst + NYC Office + + 97400 + + + + + + + + + FirstName475 + LastName475 + + director + SFO Office + + 97500 + + + + + + + + + + + + + FirstName476 + LastName476 + + intern + LON Office + + 97600 + + + + + + FirstName477 + LastName477 + + architect + TKY Office + + 97700 + + + + + + + + + FirstName478 + LastName478 + + lead + BER Office + + 97800 + + + + + + FirstName479 + LastName479 + + specialist + SYD Office + + 97900 + + + + + + FirstName480 + LastName480 + + developer + NYC Office + + 98000 + + + + + + + + + + + + + data & "sensitive" info.]]> + Employee 480 joined in 2015 and works on Project-Eng. + + + + FirstName481 + LastName481 + + manager + SFO Office + + 98100 + + + + + + FirstName482 + LastName482 + + analyst + LON Office + + 98200 + + + + + + FirstName483 + LastName483 + + director + TKY Office + + 98300 + + + + + + + + + FirstName484 + LastName484 + + intern + BER Office + + 98400 + + + + + + FirstName485 + LastName485 + + architect + SYD Office + + 98500 + + + + + + + + + + + + + FirstName486 + LastName486 + + lead + NYC Office + + 98600 + + + + + + + + + FirstName487 + LastName487 + + specialist + SFO Office + + 98700 + + + + + + FirstName488 + LastName488 + + developer + LON Office + + 98800 + + + Employee 488 joined in 2023 and works on Project-Eng. + + + + FirstName489 + LastName489 + + manager + TKY Office + + 98900 + + + + + + + + + FirstName490 + LastName490 + + analyst + BER Office + + 99000 + + + + + + + + + + data & "sensitive" info.]]> + + + + FirstName491 + LastName491 + + director + SYD Office + + 99100 + + + + + + FirstName492 + LastName492 + + intern + NYC Office + + 99200 + + + + + + + + + FirstName493 + LastName493 + + architect + SFO Office + + 99300 + + + + + + FirstName494 + LastName494 + + lead + LON Office + + 99400 + + + + + + FirstName495 + LastName495 + + specialist + TKY Office + + 99500 + + + + + + + + + + + + + + + + FirstName496 + LastName496 + + developer + BER Office + + 99600 + + + Employee 496 joined in 2021 and works on Project-Eng. + + + + FirstName497 + LastName497 + + manager + SYD Office + + 99700 + + + + + + FirstName498 + LastName498 + + analyst + NYC Office + + 99800 + + + + + + + + + FirstName499 + LastName499 + + director + SFO Office + + 99900 + + + + + + FirstName500 + LastName500 + + intern + LON Office + + 100000 + + + + + + + + + + data & "sensitive" info.]]> + + diff --git a/experimental/plugins/collections/testdata/medium_deep.xml b/experimental/plugins/collections/testdata/medium_deep.xml new file mode 100644 index 000000000..e1a4f49a5 --- /dev/null +++ b/experimental/plugins/collections/testdata/medium_deep.xml @@ -0,0 +1,136 @@ + + + + + Level 1 Node + Value at depth 1 + + Level 2 Node + Value at depth 2 + + Level 3 Node + Value at depth 3 + + Level 4 Node + Value at depth 4 + + Level 5 Node + Value at depth 5 + Sibling A at depth 5 + Sibling B at depth 5 + + Level 6 Node + Value at depth 6 + + Level 7 Node + Value at depth 7 + + Level 8 Node + Value at depth 8 + + Level 9 Node + Value at depth 9 + + Level 10 Node + Value at depth 10 + Sibling A at depth 10 + Sibling B at depth 10 + + Level 11 Node + Value at depth 11 + + Level 12 Node + Value at depth 12 + + Level 13 Node + Value at depth 13 + + Level 14 Node + Value at depth 14 + + Level 15 Node + Value at depth 15 + Sibling A at depth 15 + Sibling B at depth 15 + + Level 16 Node + Value at depth 16 + + Level 17 Node + Value at depth 17 + + Level 18 Node + Value at depth 18 + + Level 19 Node + Value at depth 19 + + Level 20 Node + Value at depth 20 + Sibling A at depth 20 + Sibling B at depth 20 + + Level 21 Node + Value at depth 21 + + Level 22 Node + Value at depth 22 + + Level 23 Node + Value at depth 23 + + Level 24 Node + Value at depth 24 + + Level 25 Node + Value at depth 25 + Sibling A at depth 25 + Sibling B at depth 25 + + Level 26 Node + Value at depth 26 + + Level 27 Node + Value at depth 27 + + Level 28 Node + Value at depth 28 + + Level 29 Node + Value at depth 29 + + Level 30 Node + Value at depth 30 + Sibling A at depth 30 + Sibling B at depth 30 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/experimental/plugins/collections/testdata/medium_mixed.xml b/experimental/plugins/collections/testdata/medium_mixed.xml new file mode 100644 index 000000000..08bdfddf0 --- /dev/null +++ b/experimental/plugins/collections/testdata/medium_mixed.xml @@ -0,0 +1,520 @@ + + + + + + Book Title Number 1 + Author 2 + 978-0-0001001-1 + 11.99 + + + Book Title Number 2 + Author 3 + 978-0-0002002-2 + 12.99 + + + Book Title Number 3 + Author 4 + 978-0-0003003-3 + 13.99 + A fascinating exploration of tech topics in volume 3. + + + Book Title Number 4 + Author 5 + 978-0-0004004-4 + 14.99 + + + User41 + Review comment 1 for book 4 + + + User42 + Review comment 2 for book 4 + + + User43 + Review comment 3 for book 4 + + + + + Book Title Number 5 + Author 6 + 978-0-0005005-5 + 15.99 + characters & "quotes" that need CDATA.]]> + + + Book Title Number 6 + Author 7 + 978-0-0006006-6 + 16.99 + A fascinating exploration of science topics in volume 6. + + + Book Title Number 7 + Author 8 + 978-0-0007007-7 + 17.99 + + + + + + Book Title Number 8 + Author 9 + 978-0-0008008-8 + 18.99 + + + User81 + Review comment 1 for book 8 + + + User82 + Review comment 2 for book 8 + + + User83 + Review comment 3 for book 8 + + + + + Book Title Number 9 + Author 10 + 978-0-0009009-9 + 19.99 + A fascinating exploration of art topics in volume 9. + + + Book Title Number 10 + Author 11 + 978-0-0010010-0 + 20.99 + characters & "quotes" that need CDATA.]]> + + + Book Title Number 11 + Author 12 + 978-0-0011011-1 + 21.99 + + + Book Title Number 12 + Author 13 + 978-0-0012012-2 + 22.99 + A fascinating exploration of history topics in volume 12. + + + User121 + Review comment 1 for book 12 + + + User122 + Review comment 2 for book 12 + + + User123 + Review comment 3 for book 12 + + + + + Book Title Number 13 + Author 14 + 978-0-0013013-3 + 23.99 + + + Book Title Number 14 + Author 15 + 978-0-0014014-4 + 24.99 + + + + + + Book Title Number 15 + Author 16 + 978-0-0015015-5 + 25.99 + characters & "quotes" that need CDATA.]]> + + + Book Title Number 16 + Author 17 + 978-0-0016016-6 + 26.99 + + + User161 + Review comment 1 for book 16 + + + User162 + Review comment 2 for book 16 + + + User163 + Review comment 3 for book 16 + + + + + Book Title Number 17 + Author 18 + 978-0-0017017-7 + 27.99 + + + Book Title Number 18 + Author 19 + 978-0-0018018-8 + 28.99 + A fascinating exploration of tech topics in volume 18. + + + Book Title Number 19 + Author 20 + 978-0-0019019-9 + 29.99 + + + Book Title Number 20 + Author 1 + 978-0-0020020-0 + 30.99 + characters & "quotes" that need CDATA.]]> + + + User201 + Review comment 1 for book 20 + + + User202 + Review comment 2 for book 20 + + + User203 + Review comment 3 for book 20 + + + + + Book Title Number 21 + Author 2 + 978-0-0021021-1 + 31.99 + A fascinating exploration of science topics in volume 21. + + + + + + Book Title Number 22 + Author 3 + 978-0-0022022-2 + 32.99 + + + Book Title Number 23 + Author 4 + 978-0-0023023-3 + 33.99 + + + Book Title Number 24 + Author 5 + 978-0-0024024-4 + 34.99 + A fascinating exploration of art topics in volume 24. + + + User241 + Review comment 1 for book 24 + + + User242 + Review comment 2 for book 24 + + + User243 + Review comment 3 for book 24 + + + + + Book Title Number 25 + Author 6 + 978-0-0025025-5 + 35.99 + characters & "quotes" that need CDATA.]]> + + + Book Title Number 26 + Author 7 + 978-0-0026026-6 + 36.99 + + + Book Title Number 27 + Author 8 + 978-0-0027027-7 + 37.99 + A fascinating exploration of history topics in volume 27. + + + Book Title Number 28 + Author 9 + 978-0-0028028-8 + 38.99 + + + User281 + Review comment 1 for book 28 + + + User282 + Review comment 2 for book 28 + + + User283 + Review comment 3 for book 28 + + + + + + + + Book Title Number 29 + Author 10 + 978-0-0029029-9 + 39.99 + + + Book Title Number 30 + Author 11 + 978-0-0030030-0 + 40.99 + characters & "quotes" that need CDATA.]]> + + + Book Title Number 31 + Author 12 + 978-0-0031031-1 + 41.99 + + + Book Title Number 32 + Author 13 + 978-0-0032032-2 + 42.99 + + + User321 + Review comment 1 for book 32 + + + User322 + Review comment 2 for book 32 + + + User323 + Review comment 3 for book 32 + + + + + Book Title Number 33 + Author 14 + 978-0-0033033-3 + 43.99 + A fascinating exploration of tech topics in volume 33. + + + Book Title Number 34 + Author 15 + 978-0-0034034-4 + 44.99 + + + Book Title Number 35 + Author 16 + 978-0-0035035-5 + 45.99 + characters & "quotes" that need CDATA.]]> + + + + + + Book Title Number 36 + Author 17 + 978-0-0036036-6 + 46.99 + A fascinating exploration of science topics in volume 36. + + + User361 + Review comment 1 for book 36 + + + User362 + Review comment 2 for book 36 + + + User363 + Review comment 3 for book 36 + + + + + Book Title Number 37 + Author 18 + 978-0-0037037-7 + 47.99 + + + Book Title Number 38 + Author 19 + 978-0-0038038-8 + 48.99 + + + Book Title Number 39 + Author 20 + 978-0-0039039-9 + 49.99 + A fascinating exploration of art topics in volume 39. + + + Book Title Number 40 + Author 1 + 978-0-0040040-0 + 50.99 + characters & "quotes" that need CDATA.]]> + + + User401 + Review comment 1 for book 40 + + + User402 + Review comment 2 for book 40 + + + User403 + Review comment 3 for book 40 + + + + + Book Title Number 41 + Author 2 + 978-0-0041041-1 + 51.99 + + + Book Title Number 42 + Author 3 + 978-0-0042042-2 + 52.99 + A fascinating exploration of history topics in volume 42. + + + + + + Book Title Number 43 + Author 4 + 978-0-0043043-3 + 53.99 + + + Book Title Number 44 + Author 5 + 978-0-0044044-4 + 54.99 + + + User441 + Review comment 1 for book 44 + + + User442 + Review comment 2 for book 44 + + + User443 + Review comment 3 for book 44 + + + + + Book Title Number 45 + Author 6 + 978-0-0045045-5 + 55.99 + characters & "quotes" that need CDATA.]]> + + + Book Title Number 46 + Author 7 + 978-0-0046046-6 + 56.99 + + + Book Title Number 47 + Author 8 + 978-0-0047047-7 + 57.99 + + + Book Title Number 48 + Author 9 + 978-0-0048048-8 + 58.99 + A fascinating exploration of tech topics in volume 48. + + + User481 + Review comment 1 for book 48 + + + User482 + Review comment 2 for book 48 + + + User483 + Review comment 3 for book 48 + + + + + Book Title Number 49 + Author 10 + 978-0-0049049-9 + 59.99 + + + + + + Book Title Number 50 + Author 11 + 978-0-0050050-0 + 60.99 + characters & "quotes" that need CDATA.]]> + + diff --git a/experimental/plugins/collections/testdata/small_cdata.xml b/experimental/plugins/collections/testdata/small_cdata.xml new file mode 100644 index 000000000..8ec44651b --- /dev/null +++ b/experimental/plugins/collections/testdata/small_cdata.xml @@ -0,0 +1,11 @@ + + + + alice@example.com + world & "friends"!]]> + + + bob@example.com + Plain text message + + diff --git a/experimental/plugins/collections/testdata/small_simple.xml b/experimental/plugins/collections/testdata/small_simple.xml new file mode 100644 index 000000000..7cb8b4956 --- /dev/null +++ b/experimental/plugins/collections/testdata/small_simple.xml @@ -0,0 +1,5 @@ + + + alpha + beta + diff --git a/experimental/plugins/collections/testdata/small_soap.xml b/experimental/plugins/collections/testdata/small_soap.xml new file mode 100644 index 000000000..98635fde0 --- /dev/null +++ b/experimental/plugins/collections/testdata/small_soap.xml @@ -0,0 +1,12 @@ + + + + secret-token-123 + + + + GOOG + + + diff --git a/experimental/plugins/collections/testdata/small_xmlrpc.xml b/experimental/plugins/collections/testdata/small_xmlrpc.xml new file mode 100644 index 000000000..d4283b0d3 --- /dev/null +++ b/experimental/plugins/collections/testdata/small_xmlrpc.xml @@ -0,0 +1,10 @@ + + + wp.getUsersBlogs + + admin + password123 + 42 + 1 + + diff --git a/experimental/plugins/collections/xpath_map_test.go b/experimental/plugins/collections/xpath_map_test.go index d2d9ee8f3..c4ab0eaea 100644 --- a/experimental/plugins/collections/xpath_map_test.go +++ b/experimental/plugins/collections/xpath_map_test.go @@ -4,6 +4,8 @@ package collections import ( + "os" + "path/filepath" "regexp" "strings" "testing" @@ -13,7 +15,15 @@ import ( "github.com/corazawaf/coraza/v3/types/variables" ) -func parseTestDoc(t *testing.T, xml string) *xmlquery.Node { +// testdataDir returns the path to the testdata directory. +// Go test always sets the working directory to the package directory. +func testdataDir(t testing.TB) string { + t.Helper() + return "testdata" +} + +// parseTestDoc parses an XML string into an xmlquery document. +func parseTestDoc(t testing.TB, xml string) *xmlquery.Node { t.Helper() doc, err := xmlquery.Parse(strings.NewReader(xml)) if err != nil { @@ -22,12 +32,26 @@ func parseTestDoc(t *testing.T, xml string) *xmlquery.Node { return doc } +// loadTestdata loads and parses an XML file from the testdata directory. +func loadTestdata(t testing.TB, name string) *xmlquery.Node { + t.Helper() + data, err := os.ReadFile(filepath.Join(testdataDir(t), name)) + if err != nil { + t.Fatal(err) + } + return parseTestDoc(t, string(data)) +} + const testXML = ` alpha beta ` +// --------------------------------------------------------------------------- +// Unit tests — full coverage +// --------------------------------------------------------------------------- + // TestXPathMapGet verifies that Get evaluates an XPath expression and // returns the string values of matching nodes. func TestXPathMapGet(t *testing.T) { @@ -85,13 +109,16 @@ func TestXPathMapNilDoc(t *testing.T) { m := NewXPathMap(variables.RequestXML, nil) if got := m.Get("//item"); got != nil { - t.Errorf("expected nil, got %v", got) + t.Errorf("Get: expected nil, got %v", got) } if got := m.FindString("//item"); got != nil { - t.Errorf("expected nil, got %v", got) + t.Errorf("FindString: expected nil, got %v", got) } if got := m.FindAll(); got != nil { - t.Errorf("expected nil, got %v", got) + t.Errorf("FindAll: expected nil, got %v", got) + } + if got := m.FindRegex(regexp.MustCompile(".")); got != nil { + t.Errorf("FindRegex: expected nil, got %v", got) } } @@ -116,6 +143,18 @@ func TestXPathMapFindString(t *testing.T) { } } +// TestXPathMapFindStringNoMatch verifies FindString returns nil for +// a valid XPath that matches nothing. +func TestXPathMapFindStringNoMatch(t *testing.T) { + doc := parseTestDoc(t, testXML) + m := NewXPathMap(variables.RequestXML, doc) + + matches := m.FindString("//nonexistent") + if matches != nil { + t.Errorf("expected nil, got %d matches", len(matches)) + } +} + // TestXPathMapFindStringEmpty verifies that FindString with an empty key // delegates to FindAll. func TestXPathMapFindStringEmpty(t *testing.T) { @@ -135,7 +174,6 @@ func TestXPathMapFindRegex(t *testing.T) { re := regexp.MustCompile(`//@\*`) matches := m.FindRegex(re) - // Should match results from FindAll whose key is "//@*" for _, match := range matches { if !re.MatchString(match.Key()) { t.Errorf("FindRegex returned match with non-matching key: %q", match.Key()) @@ -143,6 +181,47 @@ func TestXPathMapFindRegex(t *testing.T) { } } +// TestXPathMapFindRegexNoMatch verifies FindRegex returns nil when +// regex matches no keys from FindAll. +func TestXPathMapFindRegexNoMatch(t *testing.T) { + doc := parseTestDoc(t, testXML) + m := NewXPathMap(variables.RequestXML, doc) + + re := regexp.MustCompile(`^WILL_NEVER_MATCH$`) + matches := m.FindRegex(re) + if len(matches) != 0 { + t.Errorf("expected 0 matches, got %d", len(matches)) + } +} + +// TestXPathMapFindAll verifies FindAll returns both attributes and text content. +func TestXPathMapFindAll(t *testing.T) { + doc := parseTestDoc(t, testXML) + m := NewXPathMap(variables.RequestXML, doc) + + all := m.FindAll() + if len(all) == 0 { + t.Fatal("expected results from FindAll, got none") + } + + // Should have attribute results (key="//@*") and text results (key="//*[text()]") + var attrCount, textCount int + for _, md := range all { + switch md.Key() { + case "//@*": + attrCount++ + case "//*[text()]": + textCount++ + } + } + if attrCount == 0 { + t.Error("expected attribute results in FindAll") + } + if textCount == 0 { + t.Error("expected text results in FindAll") + } +} + // TestXPathMapName verifies the collection name matches the variable. func TestXPathMapName(t *testing.T) { m := NewXPathMap(variables.RequestXML, nil) @@ -169,20 +248,419 @@ func TestXPathMapReset(t *testing.T) { } // TestXPathMapMutationNoOps verifies that Set, Add, SetIndex, and Remove -// are safe no-ops that do not panic. +// are safe no-ops that do not panic or alter query results. func TestXPathMapMutationNoOps(t *testing.T) { doc := parseTestDoc(t, testXML) m := NewXPathMap(variables.RequestXML, doc) - // These should not panic or change behavior m.Set("//item", []string{"new"}) m.Add("//item", "new") m.SetIndex("//item", 0, "new") m.Remove("//item") - // Original data should still be accessible got := m.Get("//item") if len(got) != 2 { t.Errorf("mutation methods should be no-ops, but data changed: %v", got) } } + +// TestXPathMapFormat verifies that Format writes the expected representation. +func TestXPathMapFormat(t *testing.T) { + m := NewXPathMap(variables.RequestXML, nil) + var b strings.Builder + m.Format(&b) + out := b.String() + if !strings.Contains(out, "REQUEST_XML") { + t.Errorf("Format output missing variable name: %q", out) + } + if !strings.Contains(out, "xpath-backed") { + t.Errorf("Format output missing type indicator: %q", out) + } +} + +// --------------------------------------------------------------------------- +// Tests with diverse XML structures from testdata +// --------------------------------------------------------------------------- + +// TestXPathMapSOAPNamespaces verifies XPath queries with XML namespaces. +func TestXPathMapSOAPNamespaces(t *testing.T) { + doc := loadTestdata(t, "small_soap.xml") + m := NewXPathMap(variables.RequestXML, doc) + + got := m.Get("//*[local-name()='StockName']") + if len(got) != 1 || got[0] != "GOOG" { + t.Errorf("expected [GOOG], got %v", got) + } + + got = m.Get("//*[local-name()='Token']") + if len(got) != 1 || got[0] != "secret-token-123" { + t.Errorf("expected [secret-token-123], got %v", got) + } +} + +// TestXPathMapCDATA verifies that CDATA content is accessible via XPath. +func TestXPathMapCDATA(t *testing.T) { + doc := loadTestdata(t, "small_cdata.xml") + m := NewXPathMap(variables.RequestXML, doc) + + got := m.Get("//body") + if len(got) != 2 { + t.Fatalf("expected 2 body elements, got %d: %v", len(got), got) + } + // CDATA content should be returned as-is (without the CDATA markers) + if !strings.Contains(got[0], "world") { + t.Errorf("expected CDATA content with HTML tags, got %q", got[0]) + } + + // Attribute queries on CDATA doc + got = m.Get("//message/@priority") + if len(got) != 2 || got[0] != "high" || got[1] != "low" { + t.Errorf("expected [high, low], got %v", got) + } +} + +// TestXPathMapXMLRPC verifies parsing of XML-RPC structures. +func TestXPathMapXMLRPC(t *testing.T) { + doc := loadTestdata(t, "small_xmlrpc.xml") + m := NewXPathMap(variables.RequestXML, doc) + + got := m.Get("//methodName") + if len(got) != 1 || got[0] != "wp.getUsersBlogs" { + t.Errorf("expected [wp.getUsersBlogs], got %v", got) + } + + got = m.Get("//params/param/value/string") + if len(got) != 2 || got[0] != "admin" || got[1] != "password123" { + t.Errorf("expected [admin, password123], got %v", got) + } + + got = m.Get("//params/param/value/i4") + if len(got) != 1 || got[0] != "42" { + t.Errorf("expected [42], got %v", got) + } +} + +// TestXPathMapMediumMixed verifies queries against a medium-sized document +// with namespaces, CDATA, attributes, and nested structures. +func TestXPathMapMediumMixed(t *testing.T) { + doc := loadTestdata(t, "medium_mixed.xml") + m := NewXPathMap(variables.RequestXML, doc) + + // Count all books + got := m.Get("//*[local-name()='book']") + if len(got) != 50 { + t.Errorf("expected 50 books, got %d", len(got)) + } + + // Query by attribute predicate + got = m.Get("//*[local-name()='book'][@category='fiction']") + if len(got) == 0 { + t.Error("expected fiction books, got none") + } + + // Query for specific attribute values + got = m.Get("//*[local-name()='book']/@format") + if len(got) != 50 { + t.Errorf("expected 50 format attributes, got %d", len(got)) + } + + // Nested review elements + got = m.Get("//*[local-name()='reviewer']") + if len(got) == 0 { + t.Error("expected reviewers, got none") + } + + // Metadata elements (every 7th book) + got = m.Get("//*[local-name()='metadata']/@key") + if len(got) == 0 { + t.Error("expected metadata attributes, got none") + } +} + +// TestXPathMapDeepNesting verifies XPath on deeply nested documents. +func TestXPathMapDeepNesting(t *testing.T) { + doc := loadTestdata(t, "medium_deep.xml") + m := NewXPathMap(variables.RequestXML, doc) + + // Query deepest level + got := m.Get("//*[local-name()='level'][@depth='30']/*[local-name()='data']") + if len(got) != 1 || got[0] != "Value at depth 30" { + t.Errorf("expected [Value at depth 30], got %v", got) + } + + // All data elements + got = m.Get("//*[local-name()='data']") + if len(got) != 30 { + t.Errorf("expected 30 data elements, got %d", len(got)) + } + + // Siblings (every 5th level) + got = m.Get("//*[local-name()='sibling']") + if len(got) != 12 { // 6 levels * 2 siblings + t.Errorf("expected 12 siblings, got %d", len(got)) + } +} + +// TestXPathMapLargeDocQueries verifies various query patterns against a +// large document (500 employee records with multiple namespaces). +func TestXPathMapLargeDocQueries(t *testing.T) { + doc := loadTestdata(t, "large_catalog.xml") + m := NewXPathMap(variables.RequestXML, doc) + + // Count all employees + got := m.Get("//*[local-name()='employee']") + if len(got) != 500 { + t.Errorf("expected 500 employees, got %d", len(got)) + } + + // Query specific namespace prefix using local-name() + got = m.Get("//*[local-name()='salary']") + if len(got) != 500 { + t.Errorf("expected 500 salaries, got %d", len(got)) + } + + // Predicate on attribute + got = m.Get("//*[local-name()='employee'][@department='Engineering']") + if len(got) == 0 { + t.Error("expected Engineering employees, got none") + } + + // CDATA notes (every 10th employee) + got = m.Get("//*[local-name()='notes']") + if len(got) != 50 { + t.Errorf("expected 50 notes, got %d", len(got)) + } + + // Access log entries (every 5th employee, 5 entries each) + got = m.Get("//*[local-name()='entry']/@action") + if len(got) != 500 { // 100 employees * 5 entries + t.Errorf("expected 500 access log actions, got %d", len(got)) + } +} + +// TestXPathMapWhitespaceOnlyNodes verifies that nodes with only whitespace +// text content are excluded from results. +func TestXPathMapWhitespaceOnlyNodes(t *testing.T) { + xml := ` + + + real value + +` + doc := parseTestDoc(t, xml) + m := NewXPathMap(variables.RequestXML, doc) + + got := m.Get("//*[local-name()='empty']") + if len(got) != 0 { + t.Errorf("expected whitespace-only node to be excluded, got %v", got) + } + got = m.Get("//*[local-name()='content']") + if len(got) != 1 || got[0] != "real value" { + t.Errorf("expected [real value], got %v", got) + } +} + +// --------------------------------------------------------------------------- +// Benchmarks — small, medium, large documents with various XPath patterns +// --------------------------------------------------------------------------- + +// benchmarkXPath is a helper that loads a document once and benchmarks +// repeated XPath evaluation. +func benchmarkXPath(b *testing.B, docName, xpath string) { + b.Helper() + data, err := os.ReadFile(filepath.Join(testdataDir(b), docName)) + if err != nil { + b.Fatal(err) + } + doc, err := xmlquery.Parse(strings.NewReader(string(data))) + if err != nil { + b.Fatal(err) + } + m := NewXPathMap(variables.RequestXML, doc) + + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + m.Get(xpath) + } +} + +// benchmarkParse measures the cost of parsing the XML document itself. +func benchmarkParse(b *testing.B, docName string) { + b.Helper() + data, err := os.ReadFile(filepath.Join(testdataDir(b), docName)) + if err != nil { + b.Fatal(err) + } + + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + _, err := xmlquery.Parse(strings.NewReader(string(data))) + if err != nil { + b.Fatal(err) + } + } +} + +// --- Small document benchmarks --- + +func BenchmarkSmall_Parse(b *testing.B) { + benchmarkParse(b, "small_simple.xml") +} + +func BenchmarkSmall_GetElement(b *testing.B) { + benchmarkXPath(b, "small_simple.xml", "//item") +} + +func BenchmarkSmall_GetAttribute(b *testing.B) { + benchmarkXPath(b, "small_simple.xml", "//item/@key") +} + +func BenchmarkSmall_GetAllAttributes(b *testing.B) { + benchmarkXPath(b, "small_simple.xml", "//@*") +} + +func BenchmarkSmall_FindAll(b *testing.B) { + data, _ := os.ReadFile(filepath.Join(testdataDir(b), "small_simple.xml")) + doc, _ := xmlquery.Parse(strings.NewReader(string(data))) + m := NewXPathMap(variables.RequestXML, doc) + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + m.FindAll() + } +} + +// --- Small SOAP (namespace-heavy) --- + +func BenchmarkSmallSOAP_Parse(b *testing.B) { + benchmarkParse(b, "small_soap.xml") +} + +func BenchmarkSmallSOAP_LocalName(b *testing.B) { + benchmarkXPath(b, "small_soap.xml", "//*[local-name()='StockName']") +} + +// --- Small CDATA --- + +func BenchmarkSmallCDATA_Parse(b *testing.B) { + benchmarkParse(b, "small_cdata.xml") +} + +func BenchmarkSmallCDATA_GetBody(b *testing.B) { + benchmarkXPath(b, "small_cdata.xml", "//body") +} + +// --- Medium document benchmarks (50 books, ~22KB) --- + +func BenchmarkMedium_Parse(b *testing.B) { + benchmarkParse(b, "medium_mixed.xml") +} + +func BenchmarkMedium_GetAllBooks(b *testing.B) { + benchmarkXPath(b, "medium_mixed.xml", "//*[local-name()='book']") +} + +func BenchmarkMedium_GetByAttribute(b *testing.B) { + benchmarkXPath(b, "medium_mixed.xml", "//*[local-name()='book'][@category='fiction']") +} + +func BenchmarkMedium_GetNestedReviews(b *testing.B) { + benchmarkXPath(b, "medium_mixed.xml", "//*[local-name()='review']/@rating") +} + +func BenchmarkMedium_GetAllAttributes(b *testing.B) { + benchmarkXPath(b, "medium_mixed.xml", "//@*") +} + +func BenchmarkMedium_FindAll(b *testing.B) { + data, _ := os.ReadFile(filepath.Join(testdataDir(b), "medium_mixed.xml")) + doc, _ := xmlquery.Parse(strings.NewReader(string(data))) + m := NewXPathMap(variables.RequestXML, doc) + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + m.FindAll() + } +} + +// --- Medium deep document benchmarks (30 levels, ~9KB) --- + +func BenchmarkMediumDeep_Parse(b *testing.B) { + benchmarkParse(b, "medium_deep.xml") +} + +func BenchmarkMediumDeep_QueryDeepest(b *testing.B) { + benchmarkXPath(b, "medium_deep.xml", "//*[local-name()='level'][@depth='30']/*[local-name()='data']") +} + +func BenchmarkMediumDeep_QueryAllData(b *testing.B) { + benchmarkXPath(b, "medium_deep.xml", "//*[local-name()='data']") +} + +// --- Large document benchmarks (500 employees, ~298KB) --- + +func BenchmarkLarge_Parse(b *testing.B) { + benchmarkParse(b, "large_catalog.xml") +} + +func BenchmarkLarge_GetAllEmployees(b *testing.B) { + benchmarkXPath(b, "large_catalog.xml", "//*[local-name()='employee']") +} + +func BenchmarkLarge_GetByDepartment(b *testing.B) { + benchmarkXPath(b, "large_catalog.xml", "//*[local-name()='employee'][@department='Engineering']") +} + +func BenchmarkLarge_GetSalaries(b *testing.B) { + benchmarkXPath(b, "large_catalog.xml", "//*[local-name()='salary']") +} + +func BenchmarkLarge_GetAccessLogActions(b *testing.B) { + benchmarkXPath(b, "large_catalog.xml", "//*[local-name()='entry']/@action") +} + +func BenchmarkLarge_GetCDATANotes(b *testing.B) { + benchmarkXPath(b, "large_catalog.xml", "//*[local-name()='notes']") +} + +func BenchmarkLarge_GetAllAttributes(b *testing.B) { + benchmarkXPath(b, "large_catalog.xml", "//@*") +} + +func BenchmarkLarge_FindAll(b *testing.B) { + data, _ := os.ReadFile(filepath.Join(testdataDir(b), "large_catalog.xml")) + doc, _ := xmlquery.Parse(strings.NewReader(string(data))) + m := NewXPathMap(variables.RequestXML, doc) + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + m.FindAll() + } +} + +// --- FindString benchmarks (measures MatchData allocation) --- + +func BenchmarkLarge_FindString_Employees(b *testing.B) { + data, _ := os.ReadFile(filepath.Join(testdataDir(b), "large_catalog.xml")) + doc, _ := xmlquery.Parse(strings.NewReader(string(data))) + m := NewXPathMap(variables.RequestXML, doc) + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + m.FindString("//*[local-name()='employee']") + } +} + +func BenchmarkLarge_FindRegex(b *testing.B) { + data, _ := os.ReadFile(filepath.Join(testdataDir(b), "large_catalog.xml")) + doc, _ := xmlquery.Parse(strings.NewReader(string(data))) + m := NewXPathMap(variables.RequestXML, doc) + re := regexp.MustCompile(`text`) + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + m.FindRegex(re) + } +} From dbf0b7c1d21b4e5c29d17ff2933fd0898f835e23 Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Sat, 4 Apr 2026 10:38:09 -0300 Subject: [PATCH 4/8] fix: use append with variadic spread per staticcheck S1011 --- experimental/plugins/collections/xpath_map.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/experimental/plugins/collections/xpath_map.go b/experimental/plugins/collections/xpath_map.go index 527ba3a36..3e4a333a7 100644 --- a/experimental/plugins/collections/xpath_map.go +++ b/experimental/plugins/collections/xpath_map.go @@ -104,14 +104,8 @@ func (c *XPathMap) FindRegex(key *regexp.Regexp) []types.MatchData { // equivalent to evaluating //@* and //* text nodes. func (c *XPathMap) FindAll() []types.MatchData { var result []types.MatchData - // All attribute values - for _, m := range c.FindString("//@*") { - result = append(result, m) - } - // All text content - for _, m := range c.FindString("//*[text()]") { - result = append(result, m) - } + result = append(result, c.FindString("//@*")...) + result = append(result, c.FindString("//*[text()]")...) return result } From 591faff4b8a16d7858f06ec359523fc6c37885f4 Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Sat, 4 Apr 2026 10:52:45 -0300 Subject: [PATCH 5/8] test: add direct unit tests for SetRequestXML/SetResponseXML setters Ensures the setter methods in transaction.go are covered by the default test run, fixing Codecov patch coverage for these lines. --- internal/corazawaf/transaction_test.go | 47 ++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/internal/corazawaf/transaction_test.go b/internal/corazawaf/transaction_test.go index 0e765f1e8..1577e1bcd 100644 --- a/internal/corazawaf/transaction_test.go +++ b/internal/corazawaf/transaction_test.go @@ -2663,3 +2663,50 @@ func BenchmarkRuleEvalWithRemovedRules(b *testing.B) { waf.Rules.Eval(types.PhaseRequestHeaders, tx) } } + +// TestTransactionVariablesSetRequestXML verifies that SetRequestXML replaces +// the RequestXML collection and updates the XML alias. +func TestTransactionVariablesSetRequestXML(t *testing.T) { + v := NewTransactionVariables() + + original := v.RequestXML() + replacement := collections.NewMap(variables.RequestXML) + replacement.Add("test", "value") + + v.SetRequestXML(replacement) + + if v.RequestXML() != replacement { + t.Error("RequestXML was not replaced") + } + if v.XML() != replacement { + t.Error("XML alias was not updated to match RequestXML") + } + if v.RequestXML() == original { + t.Error("RequestXML still points to original collection") + } + if got := v.RequestXML().Get("test"); len(got) != 1 || got[0] != "value" { + t.Errorf("replacement collection not accessible, got %v", got) + } +} + +// TestTransactionVariablesSetResponseXML verifies that SetResponseXML +// replaces the ResponseXML collection. +func TestTransactionVariablesSetResponseXML(t *testing.T) { + v := NewTransactionVariables() + + original := v.ResponseXML() + replacement := collections.NewMap(variables.ResponseXML) + replacement.Add("test", "value") + + v.SetResponseXML(replacement) + + if v.ResponseXML() != replacement { + t.Error("ResponseXML was not replaced") + } + if v.ResponseXML() == original { + t.Error("ResponseXML still points to original collection") + } + if got := v.ResponseXML().Get("test"); len(got) != 1 || got[0] != "value" { + t.Errorf("replacement collection not accessible, got %v", got) + } +} From f0a83cd7737ba4e756775ee5ff9762a83fc37051 Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Sat, 4 Apr 2026 10:57:57 -0300 Subject: [PATCH 6/8] test: cover ProcessResponse in xmlquery body processor --- experimental/plugins/xmlquery_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/experimental/plugins/xmlquery_test.go b/experimental/plugins/xmlquery_test.go index bc548378e..fb06b6a83 100644 --- a/experimental/plugins/xmlquery_test.go +++ b/experimental/plugins/xmlquery_test.go @@ -260,6 +260,20 @@ func TestXMLQueryBodyProcessorInvalidXML(t *testing.T) { } } +// TestXMLQueryBodyProcessorProcessResponse verifies that ProcessResponse +// returns nil (not yet implemented). +func TestXMLQueryBodyProcessorProcessResponse(t *testing.T) { + bp := xmlQueryProcessor(t) + err := bp.ProcessResponse( + bytes.NewReader(nil), + corazawaf.NewTransactionVariables(), + plugintypes.BodyProcessorOptions{}, + ) + if err != nil { + t.Errorf("expected nil, got %v", err) + } +} + // fallbackTestVars is a minimal TransactionVariables implementation that does // NOT implement xmlSettableVariables, forcing the body processor to use the // fallback path of populating the existing collection.Map directly. From c2afc2ee59c8bdfda1e6a830b07d704738510e2e Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Sat, 4 Apr 2026 12:36:06 -0300 Subject: [PATCH 7/8] test: add CRS FTW test suite run with xmlquery body processor Run the full OWASP CRS regression test suite using the experimental xmlquery body processor to verify compatibility. Uses SecRuleUpdateActionById to override rule 200000, switching from requestBodyProcessor=XML to requestBodyProcessor=XMLQUERY. All CRS tests pass, confirming the lazy XPath-backed collection is fully compatible with CRS rules that inspect XML request bodies. --- .../coreruleset/coreruleset_xmlquery_test.go | 187 ++++++++++++++++++ testing/coreruleset/go.mod | 3 + testing/coreruleset/go.sum | 72 +++++++ 3 files changed, 262 insertions(+) create mode 100644 testing/coreruleset/coreruleset_xmlquery_test.go diff --git a/testing/coreruleset/coreruleset_xmlquery_test.go b/testing/coreruleset/coreruleset_xmlquery_test.go new file mode 100644 index 000000000..afca50648 --- /dev/null +++ b/testing/coreruleset/coreruleset_xmlquery_test.go @@ -0,0 +1,187 @@ +// Copyright 2026 Juan Pablo Tosso and the OWASP Coraza contributors +// SPDX-License-Identifier: Apache-2.0 + +// These benchmarks don't currently compile with TinyGo +//go:build !tinygo + +package coreruleset + +import ( + "bufio" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/bmatcuk/doublestar/v4" + albedo "github.com/coreruleset/albedo/server" + "github.com/coreruleset/go-ftw/v2/config" + "github.com/coreruleset/go-ftw/v2/output" + "github.com/coreruleset/go-ftw/v2/runner" + "github.com/coreruleset/go-ftw/v2/test" + "github.com/rs/zerolog" + + coreruleset "github.com/corazawaf/coraza-coreruleset/v4" + crstests "github.com/corazawaf/coraza-coreruleset/v4/tests" + "github.com/corazawaf/coraza/v3" + "github.com/corazawaf/coraza/v3/experimental" + txhttp "github.com/corazawaf/coraza/v3/http" + "github.com/corazawaf/coraza/v3/types" + + // Register the experimental xmlquery body processor so that + // ctl:requestBodyProcessor=XMLQUERY is available. + _ "github.com/corazawaf/coraza/v3/experimental/plugins" +) + +// TestFTWXMLQuery runs the full CRS FTW test suite using the experimental +// xmlquery body processor instead of the default XML processor. +// This verifies that the lazy XPath-backed collection is fully compatible +// with CRS rules that inspect XML request bodies. +func TestFTWXMLQuery(t *testing.T) { + conf := coraza.NewWAFConfig() + + rec, err := os.ReadFile(filepath.Join("..", "..", "coraza.conf-recommended")) + if err != nil { + t.Fatal(err) + } + + // Override rule 200000 to use the experimental xmlquery processor. + // SecRuleUpdateActionById replaces the action list for the existing rule, + // switching from requestBodyProcessor=XML to requestBodyProcessor=XMLQUERY. + xmlqueryOverride := ` +SecRuleUpdateActionById 200000 "phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=XMLQUERY" +` + + customTestingConfig := ` +SecResponseBodyMimeType text/plain +SecDefaultAction "phase:3,log,auditlog,pass" +SecDefaultAction "phase:4,log,auditlog,pass" +SecDefaultAction "phase:5,log,auditlog,pass" + +# Rule 900005 from https://github.com/coreruleset/coreruleset/blob/v4.0/dev/tests/regression/README.md#requirements +SecAction "id:900005,\ + phase:1,\ + nolog,\ + pass,\ + ctl:ruleEngine=DetectionOnly,\ + ctl:ruleRemoveById=910000,\ + setvar:tx.blocking_paranoia_level=4,\ + setvar:tx.crs_validate_utf8_encoding=1,\ + setvar:tx.arg_name_length=100,\ + setvar:tx.arg_length=400,\ + setvar:tx.total_arg_length=64000,\ + setvar:tx.max_num_args=255,\ + setvar:tx.max_file_size=64100,\ + setvar:tx.combined_file_sizes=65535" + +# Write the value from the X-CRS-Test header as a marker to the log +# Requests with X-CRS-Test header will not be matched by any rule. See https://github.com/coreruleset/go-ftw/pull/133 +SecRule REQUEST_HEADERS:X-CRS-Test "@rx ^.*$" \ + "id:999999,\ + phase:1,\ + pass,\ + t:none,\ + log,\ + msg:'X-CRS-Test %{MATCHED_VAR}',\ + ctl:ruleRemoveById=1-999999" +` + conf = conf. + WithRootFS(coreruleset.FS). + WithDirectives(string(rec)). + WithDirectives(customTestingConfig). + WithDirectives("Include @crs-setup.conf.example"). + WithDirectives("Include @owasp_crs/*.conf"). + WithDirectives(xmlqueryOverride) + + errorPath := filepath.Join(t.TempDir(), "error.log") + errorFile, err := os.Create(errorPath) + if err != nil { + t.Fatalf("failed to create error log: %v", err) + } + defer errorFile.Close() + + errorWriter := bufio.NewWriter(errorFile) + conf = conf.WithErrorCallback(func(rule types.MatchedRule) { + msg := rule.ErrorLog() + "\n" + if _, err := io.WriteString(errorWriter, msg); err != nil { + t.Fatal(err) + } + if err := errorWriter.Flush(); err != nil { + t.Fatal(err) + } + }) + + waf, err := coraza.NewWAF(conf) + if err != nil { + t.Fatal(err) + } + if closer, ok := waf.(experimental.WAFCloser); ok { + defer closer.Close() + } + + s := httptest.NewServer(txhttp.WrapHandler(waf, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + w.Header().Set("Content-Type", "text/plain") + albedo.Handler().ServeHTTP(w, r) + }))) + defer s.Close() + + var tests []*test.FTWTest + err = doublestar.GlobWalk(crstests.FS, "**/*.yaml", func(path string, d os.DirEntry) error { + yaml, err := fs.ReadFile(crstests.FS, path) + if err != nil { + return err + } + ftwt, err := test.GetTestFromYaml(yaml, path) + if err != nil { + return err + } + tests = append(tests, ftwt) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(tests) == 0 { + t.Fatal("no tests found") + } + + u, _ := url.Parse(s.URL) + host := u.Hostname() + port, _ := strconv.Atoi(u.Port()) + zerolog.SetGlobalLevel(zerolog.InfoLevel) + cfg, err := config.NewConfigFromFile(".ftw.yml") + if err != nil { + t.Fatal(err) + } + cfg.LogFile = errorPath + cfg.TestOverride.Overrides.DestAddr = &host + cfg.TestOverride.Overrides.Port = &port + + if err := loadMultiphaseOverrides(cfg); err != nil { + t.Fatal(err) + } + runnerCfg := config.NewRunnerConfiguration(cfg) + runnerCfg.ReadTimeout = 3 * time.Second + if err := runnerCfg.LoadPlatformOverrides(".ftw-overrides.yml"); err != nil { + t.Fatal(err) + } + res, err := runner.Run(runnerCfg, tests, output.NewOutput("quiet", os.Stdout)) + if err != nil { + t.Fatal(err) + } + totalIgnored := len(res.Stats.Ignored) + if totalIgnored > 0 { + t.Logf("[info] %d ignored tests: %v", totalIgnored, res.Stats.Ignored) + } + totalFailed := len(res.Stats.Failed) + if totalFailed > 0 { + t.Errorf("[fatal] %d failed tests: %v", totalFailed, res.Stats.Failed) + } +} diff --git a/testing/coreruleset/go.mod b/testing/coreruleset/go.mod index 09cece66c..31dc29447 100644 --- a/testing/coreruleset/go.mod +++ b/testing/coreruleset/go.mod @@ -16,12 +16,15 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/antchfx/xmlquery v1.5.1 // indirect + github.com/antchfx/xpath v1.3.6 // indirect github.com/corazawaf/libinjection-go v0.3.2 // indirect github.com/coreruleset/ftw-tests-schema/v2 v2.3.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect diff --git a/testing/coreruleset/go.sum b/testing/coreruleset/go.sum index 51da5c231..34cad2281 100644 --- a/testing/coreruleset/go.sum +++ b/testing/coreruleset/go.sum @@ -6,6 +6,10 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/antchfx/xmlquery v1.5.1 h1:T9I4Ns1EXiWHy0IqKupGhnfTQtJwlGrpXtauYOoNv78= +github.com/antchfx/xmlquery v1.5.1/go.mod h1:bVqnl7TaDXSReKINrhZz+2E/PbCu2tUahb+wZ7WZNT8= +github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI= +github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc= @@ -34,6 +38,10 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -111,29 +119,93 @@ github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5 github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk= github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 5e3c8a3724c47b082b3976f533d5ad0423876ca9 Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Sat, 4 Apr 2026 12:44:03 -0300 Subject: [PATCH 8/8] bench: add CRS XML body processor benchmarks for both XML and xmlquery Add matching benchmarks for the original XML and experimental xmlquery body processors processing XML request bodies through the full CRS rule pipeline: - BenchmarkCRSXMLSimplePOST / BenchmarkCRSXMLQuerySimplePOST: Small XML-RPC payload (~250B) - BenchmarkCRSXMLLargeSOAP / BenchmarkCRSXMLQueryLargeSOAP: Large SOAP envelope (~15KB, 100 items, namespaces) Results on Apple M2 show xmlquery is ~1.8-2x slower due to full DOM construction, which is the expected tradeoff for real XPath support. --- testing/coreruleset/coreruleset_test.go | 95 +++++++++++ .../coreruleset/coreruleset_xmlquery_test.go | 150 ++++++++++++++++++ 2 files changed, 245 insertions(+) diff --git a/testing/coreruleset/coreruleset_test.go b/testing/coreruleset/coreruleset_test.go index 7f1863e9d..60730bd6a 100644 --- a/testing/coreruleset/coreruleset_test.go +++ b/testing/coreruleset/coreruleset_test.go @@ -151,6 +151,101 @@ func BenchmarkCRSLargePOST(b *testing.B) { } } +func BenchmarkCRSXMLSimplePOST(b *testing.B) { + waf := crsWAF(b) + + xmlPayload := []byte(` + + wp.getUsersBlogs + + admin + password123 + +`) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + tx := waf.NewTransaction() + tx.ProcessConnection("127.0.0.1", 8080, "127.0.0.1", 8080) + tx.ProcessURI("/xmlrpc.php", "POST", "HTTP/1.1") + tx.AddRequestHeader("Host", "localhost") + tx.AddRequestHeader("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36") + tx.AddRequestHeader("Content-Type", "text/xml") + tx.ProcessRequestHeaders() + if _, _, err := tx.WriteRequestBody(xmlPayload); err != nil { + b.Error(err) + } + if _, err := tx.ProcessRequestBody(); err != nil { + b.Error(err) + } + tx.AddResponseHeader("Content-Type", "text/xml") + tx.ProcessResponseHeaders(200, "OK") + if _, err := tx.ProcessResponseBody(); err != nil { + b.Error(err) + } + tx.ProcessLogging() + if err := tx.Close(); err != nil { + b.Error(err) + } + } +} + +func BenchmarkCRSXMLLargeSOAP(b *testing.B) { + waf := crsWAF(b) + + // ~4KB SOAP envelope with multiple items + var sb strings.Builder + sb.WriteString(` + + + + + + `) + for i := 0; i < 100; i++ { + fmt.Fprintf(&sb, ` + + Item number %d + Description for item %d with some extra text to add size + %d.99 + `, i, i, i, i*10+99) + } + sb.WriteString(` + + +`) + xmlPayload := []byte(sb.String()) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + tx := waf.NewTransaction() + tx.ProcessConnection("127.0.0.1", 8080, "127.0.0.1", 8080) + tx.ProcessURI("/api/batch", "POST", "HTTP/1.1") + tx.AddRequestHeader("Host", "localhost") + tx.AddRequestHeader("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36") + tx.AddRequestHeader("Content-Type", "application/soap+xml") + tx.ProcessRequestHeaders() + if _, _, err := tx.WriteRequestBody(xmlPayload); err != nil { + b.Error(err) + } + if _, err := tx.ProcessRequestBody(); err != nil { + b.Error(err) + } + tx.AddResponseHeader("Content-Type", "application/soap+xml") + tx.ProcessResponseHeaders(200, "OK") + if _, err := tx.ProcessResponseBody(); err != nil { + b.Error(err) + } + tx.ProcessLogging() + if err := tx.Close(); err != nil { + b.Error(err) + } + } +} + // BenchmarkCRSPrefilter measures CRS request processing across diverse traffic // patterns. Run with and without the coraza.rule.rx_prefilter build tag and // compare via benchstat: diff --git a/testing/coreruleset/coreruleset_xmlquery_test.go b/testing/coreruleset/coreruleset_xmlquery_test.go index afca50648..cecf03618 100644 --- a/testing/coreruleset/coreruleset_xmlquery_test.go +++ b/testing/coreruleset/coreruleset_xmlquery_test.go @@ -8,6 +8,7 @@ package coreruleset import ( "bufio" + "fmt" "io" "io/fs" "net/http" @@ -16,6 +17,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "testing" "time" @@ -185,3 +187,151 @@ SecRule REQUEST_HEADERS:X-CRS-Test "@rx ^.*$" \ t.Errorf("[fatal] %d failed tests: %v", totalFailed, res.Stats.Failed) } } + +// crsWAFXMLQuery returns a CRS WAF configured to use the experimental xmlquery +// body processor for XML content types instead of the default XML processor. +func crsWAFXMLQuery(t testing.TB) coraza.WAF { + t.Helper() + rec, err := os.ReadFile(filepath.Join("..", "..", "coraza.conf-recommended")) + if err != nil { + t.Fatal(err) + } + customTestingConfig := ` +SecResponseBodyMimeType text/plain +SecDefaultAction "phase:3,log,auditlog,pass" +SecDefaultAction "phase:4,log,auditlog,pass" + +# Rule 900005 from https://github.com/coreruleset/coreruleset/blob/v4.0/dev/tests/regression/README.md#requirements +SecAction "id:900005,\ + phase:1,\ + nolog,\ + pass,\ + ctl:ruleEngine=DetectionOnly,\ + ctl:ruleRemoveById=910000,\ + setvar:tx.blocking_paranoia_level=4,\ + setvar:tx.crs_validate_utf8_encoding=1,\ + setvar:tx.arg_name_length=100,\ + setvar:tx.arg_length=400,\ + setvar:tx.total_arg_length=64000,\ + setvar:tx.max_num_args=255,\ + setvar:tx.max_file_size=64100,\ + setvar:tx.combined_file_sizes=65535" +` + xmlqueryOverride := ` +SecRuleUpdateActionById 200000 "phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=XMLQUERY" +` + conf := coraza.NewWAFConfig(). + WithRootFS(coreruleset.FS). + WithDirectives(string(rec)). + WithDirectives(customTestingConfig). + WithDirectives("Include @crs-setup.conf.example"). + WithDirectives("Include @owasp_crs/*.conf"). + WithDirectives(xmlqueryOverride) + + waf, err := coraza.NewWAF(conf) + if err != nil { + t.Fatal(err) + } + if closer, ok := waf.(experimental.WAFCloser); ok { + if _, isBenchmark := t.(*testing.B); !isBenchmark { + t.Cleanup(func() { closer.Close() }) + } + } + + return waf +} + +func BenchmarkCRSXMLQuerySimplePOST(b *testing.B) { + waf := crsWAFXMLQuery(b) + + xmlPayload := []byte(` + + wp.getUsersBlogs + + admin + password123 + +`) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + tx := waf.NewTransaction() + tx.ProcessConnection("127.0.0.1", 8080, "127.0.0.1", 8080) + tx.ProcessURI("/xmlrpc.php", "POST", "HTTP/1.1") + tx.AddRequestHeader("Host", "localhost") + tx.AddRequestHeader("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36") + tx.AddRequestHeader("Content-Type", "text/xml") + tx.ProcessRequestHeaders() + if _, _, err := tx.WriteRequestBody(xmlPayload); err != nil { + b.Error(err) + } + if _, err := tx.ProcessRequestBody(); err != nil { + b.Error(err) + } + tx.AddResponseHeader("Content-Type", "text/xml") + tx.ProcessResponseHeaders(200, "OK") + if _, err := tx.ProcessResponseBody(); err != nil { + b.Error(err) + } + tx.ProcessLogging() + if err := tx.Close(); err != nil { + b.Error(err) + } + } +} + +func BenchmarkCRSXMLQueryLargeSOAP(b *testing.B) { + waf := crsWAFXMLQuery(b) + + // ~4KB SOAP envelope with multiple items + var sb strings.Builder + sb.WriteString(` + + + + + + `) + for i := 0; i < 100; i++ { + fmt.Fprintf(&sb, ` + + Item number %d + Description for item %d with some extra text to add size + %d.99 + `, i, i, i, i*10+99) + } + sb.WriteString(` + + +`) + xmlPayload := []byte(sb.String()) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + tx := waf.NewTransaction() + tx.ProcessConnection("127.0.0.1", 8080, "127.0.0.1", 8080) + tx.ProcessURI("/api/batch", "POST", "HTTP/1.1") + tx.AddRequestHeader("Host", "localhost") + tx.AddRequestHeader("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36") + tx.AddRequestHeader("Content-Type", "application/soap+xml") + tx.ProcessRequestHeaders() + if _, _, err := tx.WriteRequestBody(xmlPayload); err != nil { + b.Error(err) + } + if _, err := tx.ProcessRequestBody(); err != nil { + b.Error(err) + } + tx.AddResponseHeader("Content-Type", "application/soap+xml") + tx.ProcessResponseHeaders(200, "OK") + if _, err := tx.ProcessResponseBody(); err != nil { + b.Error(err) + } + tx.ProcessLogging() + if err := tx.Close(); err != nil { + b.Error(err) + } + } +}