diff --git a/docs/03.Advanced-Cookbook/3.14.WAF.md b/docs/03.Advanced-Cookbook/3.14.WAF.md new file mode 100644 index 0000000000..6416a9241a --- /dev/null +++ b/docs/03.Advanced-Cookbook/3.14.WAF.md @@ -0,0 +1,260 @@ +# Web Application Firewall + + +- [Introduction](#introduction) +- [Configuring Web Application Firewall](#configuring-web-application-firewall) + - [Custom](#custom) + - [OWASP Core Rule Set](#owasp-core-rule-set) + - [IP Blocker](#ip-blocker) + - [Whitelist Mode](#whitelist-mode) + - [Blacklist Mode](#blacklist-mode) + - [Combined Mode (Whitelist \& Blacklist)](#combined-mode-whitelist--blacklist) + - [GEO IP Blocker](#geo-ip-blocker) + - [Whitelist Mode (allowedCountries)](#whitelist-mode-allowedcountries) + - [Blacklist Mode (deniedCountries)](#blacklist-mode-deniedcountries) + - [Rate Limiter](#rate-limiter) +- [Observability](#observability) + + +## Introduction + +The Web Application Firewall (WAF) protects your apps by inspecting HTTP(S) traffic and blocking malicious requests at the application layer. It supports standard rule sets like OWASP CRS and flexible custom policies. + +Beyond basic request filtering, the WAF includes IP/Geo access control, rate limiting, and rich observability for tuning and incident response. This ensures strong protection with low false positives and clear operational insight. + +## Configuring Web Application Firewall + +Here is a simple example shows how to configure Easegress to protect your backend using a WAF with a minimal subset of the OWASP Core Rule Set (CRS) focused on SQL Injection (SQLi) detection. + +```yaml +name: waf-controller +kind: WAFController +ruleGroups: +- name: sqlinjection + rules: + customRules: | + // https://github.com/corazawaf/coraza-coreruleset/blob/main/rules/%40crs-setup.conf.example + // check coraza core rule set recommend setup file + owaspRules: + - REQUEST-901-INITIALIZATION.conf + - REQUEST-942-APPLICATION-ATTACK-SQLI.conf + - REQUEST-949-BLOCKING-EVALUATION.conf + +------ + +name: waf-server +kind: HTTPServer +port: 10080 +rules: +- paths: + - pathPrefix: / + backend: waf-pipeline + +----- + +name: waf-pipeline +kind: Pipeline +filters: +- name: waf-filter + kind: WAF + ruleGroup: sqlinjection +- name: proxy + kind: Proxy + pools: + - servers: + - url: http://127.0.0.1:9095 + - url: http://127.0.0.1:9096 + loadBalance: + policy: roundRobin +``` + +It will do following things: + +1. Loads a minimal, SQLi-focused subset of the OWASP CRS: + - 901: Initialization and variable setup + - 942: SQL Injection detection + - 949: Final blocking evaluation +2. Routes traffic to port 10080 through the waf-pipeline, where the WAF filter evaluates requests against the sqlinjection rule group before proxying to your backend pool. + +### Custom + +This example shows how to load the full OWASP Core Rule Set (CRS) and add your own custom Coraza/ModSecurity rules in Easegress. The example below blocks all POST requests at both `phase 1` and `phase 2` for demonstration purposes. + +```yaml +name: waf-controller +kind: WAFController +ruleGroups: +- name: sqlinjection + rules: + loadOwaspCrs: true + customRules: | + SecRule REQUEST_METHOD "POST" "id:1000001,phase:1,block,log,msg:'Block all POST requests (phase 1)',severity:'CRITICAL'" + SecRule REQUEST_METHOD "POST" "id:1000002,phase:2,block,log,msg:'Block all POST requests (phase 2)',severity:'CRITICAL'" +``` + +What this does: + +- `loadOwaspCrs`: true loads the full OWASP CRS bundle. You can includes them as you needed. For full rule set, please check: [here](https://github.com/corazawaf/coraza-coreruleset/tree/main/rules/%40owasp_crs). +- `customRules` adds two rules that explicitly block all POST requests. They run in different phases to illustrate the request processing lifecycle. For more details about Coraza/Modsecurity rules please check: [here](https://coraza.io/docs/seclang/) + +Feel free to add your own rules as needed. + +### OWASP Core Rule Set + +This example shows how to use the corazawaf/coraza-coreruleset package to protect your applications. This package provides a straightforward way to embed the official OWASP Core Rule Set (CRS) directly into a Go application using the Coraza Web Application Firewall (WAF). + +To effectively use the CRS, a proper setup is essential. This involves including foundational configuration files that define the behavior of both the Coraza engine and the rule set itself. + +Coraza Configuration [@coraza.conf-recommended](https://github.com/corazawaf/coraza-coreruleset/blob/main/rules/%40coraza.conf-recommended): This file provides the recommended base settings for the Coraza WAF engine. + +CRS Setup [@crs-setup.conf.example](https://github.com/corazawaf/coraza-coreruleset/blob/main/rules/%40crs-setup.conf.example): This file is crucial for customizing the Core Rule Set. Here, you can configure paranoia levels, anomaly score thresholds, and other CRS-specific behaviors. You should copy this file and tailor it to your application's needs. + +```yaml +name: waf-controller +kind: WAFController +ruleGroups: +- name: sqlinjection + rules: + customRules: | + // https://github.com/corazawaf/coraza-coreruleset/blob/main/rules/%40crs-setup.conf.example + // The crs-setup.conf file is required for CRS to function. + // This is a minimal example; a full setup file offers more customization. + SecRequestBodyAccess On + SecDefaultAction "phase:1,log,auditlog,pass" + SecDefaultAction "phase:2,log,auditlog,pass" + SecAction \ + "id:900990,\ + phase:1,\ + pass,\ + t:none,\ + nolog,\ + tag:'OWASP_CRS',\ + ver:'OWASP_CRS/4.16.0',\ + setvar:tx.crs_setup_version=4160" + owaspRules: + - REQUEST-901-INITIALIZATION.conf + - REQUEST-913-SCANNER-DETECTION.conf + - REQUEST-921-PROTOCOL-ATTACK.conf + - REQUEST-922-MULTIPART-ATTACK.conf + - REQUEST-949-BLOCKING-EVALUATION.conf +``` + +For all rules, please check [here](https://github.com/corazawaf/coraza-coreruleset/tree/main/rules/%40owasp_crs). + +### IP Blocker + +The `ipBlocker` rule filters requests based on the source IP address, specified in `CIDR` format. + +#### Whitelist Mode + +Logic: Allows requests only from IPs on the list. All others are blocked. + +```yaml +kind: WAFController +name: waf-controller +ruleGroups: + - name: ipblocker + rules: + ipBlocker: + whitelist: + - 136.252.0.2/32 +``` + +#### Blacklist Mode + +Logic: Blocks requests from IPs on the list. All others are allowed. + +```yaml +kind: WAFController +name: waf-controller +ruleGroups: + - name: ipblocker + rules: + ipBlocker: + blacklist: + - 158.160.2.1/32 +``` + +#### Combined Mode (Whitelist & Blacklist) + +Logic: Blocks request if IP is in blacklist OR not in whitelist. + +```yaml +kind: WAFController +name: waf-controller +ruleGroups: + - name: ipblocker + rules: + ipBlocker: + whitelist: + - 136.252.0.2/32 + blacklist: + - 158.160.2.1/32 +``` + +### GEO IP Blocker + +The `geoIPBlocker` rule allows or denies requests based on the visitor's country of origin. It requires a GeoIP database to function. + +- `dbPath`: The file path to your GeoIP database (.mmdb file). +- `dbUpdateCron`: (Optional) A cron expression to schedule automatic updates for the database. +- `Country Codes`: Use standard two-letter ISO codes (e.g., US, CN). + +#### Whitelist Mode (allowedCountries) + +Logic: Only allows requests from the specified countries. All other countries will be blocked. + +```yaml +kind: WAFController +name: waf-controller +ruleGroups: + - name: geoipblocker + rules: + geoIPBlocker: + dbPath: /Country.mmdb + allowedCountries: + - XX +``` + +#### Blacklist Mode (deniedCountries) + +Logic: Blocks requests from the specified countries. All other countries will be allowed. + +```yaml +kind: WAFController +name: waf-controller +ruleGroups: + - name: geoipblocker + rules: + geoIPBlocker: + dbPath: /Country.mmdb + dbUpdateCron: "0 0 1 * *" + deniedCountries: + - XX +``` + +### Rate Limiter + +The RateLimiter restricts how many requests a client can make within a specific time window (e.g., requests per second). This is a crucial defense against brute-force attacks and helps prevent system overload from excessive traffic. + +For detailed configuration, please refer to the official documentation for the built-in Easegress RateLimiter filter [here](./../07.Reference/7.02.Filters.md#ratelimiter). + +## Observability + +To monitor requests blocked by the WAF, you can use the `waf_total_refused_requests` counter metric exposed to Prometheus. This metric increments every time a request is denied by a WAF rule. + +It includes several labels to help you pinpoint the cause of the block: + +Common Labels: + +- `kind`, `clusterName`, `clusterRole`, `instanceName`: Standard labels to identify the Easegress instance. + +Metric-Specific Labels: + +- `ruleGroup`: The name of the rule group that blocked the request. +- `ruleID`: The ID of the specific rule that was triggered. +- `action`: The WAF action, canbe one of `drop`, `deny`, `redirect`. + +Using these labels, you can create detailed dashboards and alerts, for instance, to track which rules are most active or to identify repeated block events from a specific source. + +For a complete list of all available metrics, please see the [Metrics](../07.Reference/7.08.Metrics.md) reference documentation. diff --git a/docs/07.Reference/7.01.Controllers.md b/docs/07.Reference/7.01.Controllers.md index 4fe4cc7bd4..3e225b9398 100644 --- a/docs/07.Reference/7.01.Controllers.md +++ b/docs/07.Reference/7.01.Controllers.md @@ -21,6 +21,7 @@ - [NacosServiceRegistry](#nacosserviceregistry) - [AutoCertManager](#autocertmanager) - [AIGatewayController](#aigatewaycontroller) + - [WAFController](#wafcontroller) - [Common Types](#common-types) - [tracing.Spec](#tracingspec) - [spanlimits.Spec](#spanlimitsspec) @@ -47,14 +48,18 @@ - [resilience.Policy](#resiliencepolicy) - [Retry Policy](#retry-policy) - [CircuitBreaker Policy](#circuitbreaker-policy) - - [aigatewaycontroller.ProviderSpec](#aigatewaycontrollerproviderspec) + - [AIGatewayController.ProviderSpec](#aigatewaycontrollerproviderspec) - [Supported Providers](#supported-providers) - - [aigatewaycontroller.MiddlewareSpec](#aigatewaycontrollermiddlewarespec) - - [aigatewaycontroller.SemanticCacheSpec](#aigatewaycontrollersemanticcachespec) - - [aigatewaycontroller.EmbeddingSpec](#aigatewaycontrollerembeddingspec) - - [aigatewaycontroller.VectorDBSpec](#aigatewaycontrollervectordbspec) - - [aigatewaycontroller.RedisSpec](#aigatewaycontrollerredisspec) - - [aigatewaycontroller.PostgresSpec](#aigatewaycontrollerpostgresspec) + - [AIGatewayController.MiddlewareSpec](#aigatewaycontrollermiddlewarespec) + - [AIGatewayController.SemanticCacheSpec](#aigatewaycontrollersemanticcachespec) + - [AIGatewayController.EmbeddingSpec](#aigatewaycontrollerembeddingspec) + - [AIGatewayController.VectorDBSpec](#aigatewaycontrollervectordbspec) + - [AIGatewayController.RedisSpec](#aigatewaycontrollerredisspec) + - [AIGatewayController.PostgresSpec](#aigatewaycontrollerpostgresspec) + - [WAFController.RuleGroupSpec](#wafcontrollerrulegroupspec) + - [WAFController.RuleSpec](#wafcontrollerrulespec) + - [WAFController.IPBlockerSpec](#wafcontrolleripblockerspec) + - [WAFController.GeoIPBlockerSpec](#wafcontrollergeoipblockerspec) As the [architecture diagram](../imgs/architecture.png) shows, the controller is the core entity to control kinds of working. There are two kinds of controllers overall: @@ -627,6 +632,37 @@ version: easegress.megaease.com/v2 | providers | [][ProviderSpec](#aigatewaycontrollerproviderspec) | List of AI providers configuration | No | | middlewares | [][MiddlewareSpec](#aigatewaycontrollermiddlewarespec) | List of middleware configuration for request processing | No | + +### WAFController + +```yaml +name: waf-controller +kind: WAFController +ruleGroups: +- name: sqlinjection + rules: + customRules: | + // https://github.com/corazawaf/coraza-coreruleset/blob/main/rules/%40crs-setup.conf.example + // check coraza core rule set recommend setup file + owaspRules: + - REQUEST-901-INITIALIZATION.conf + - REQUEST-942-APPLICATION-ATTACK-SQLI.conf + - REQUEST-949-BLOCKING-EVALUATION.conf +- name: geoipblocker + rules: + geoIPBlocker: + dbPath: /Country.mmdb + dbUpdateCron: "0 0 1 * *" + deniedCountries: + - XX +``` + + +| Name | Type | Description | Required | +| ----------- | ----------------------------------------- | ----------------------------------------------------- | -------- | +|ruleGroups | [][RuleGroupSpec](#wafcontrollerrulegroupspec) | A list of configurations for one or more WAF rule groups. | Yes | + + ## Common Types ### tracing.Spec @@ -1002,3 +1038,32 @@ The providerType can be one of the following: | Name | Type | Description | Required | | ------------- | ------ | ------------------------------ | -------- | | connectionURL | string | PostgreSQL connection URL | Yes | + +### WAFController.RuleGroupSpec +| Name | Type | Description | Required | +| ----------- | ----------------------------------------- | ----------------------------------------------------- | -------- | +| name | string | A unique name for the rule group. | Yes | +| loadOwaspCrs | bool | Indicates whether to load the OWASP Core Rule Set. For more details, please check Coraza CRS. | No | +| rules | [RuleSpec](#wafcontrollerrulespec) | Defines the specific rules included in this rule group. | Yes | + +### WAFController.RuleSpec +| Name | Type | Description | Required | +| ----------- | ----------------------------------------- | ----------------------------------------------------- | -------- | +| owaspRules | []string | Defines the OWASP rules to be applied. See the examples at Coraza CRS for more details. | No | +| customRules | string | Defines custom WAF rules. | No | +| ipBlocker | [IPBlockerSpec](#wafcontrolleripblockerspec) | Defines access control rules based on IP addresses (whitelist/blacklist). | No | +| geoIPBlocker | [GeoIPBlockerSpec](#wafcontrollergeoipblockerspec) | Defines access control rules based on geolocation (GeoIP). | No | + +### WAFController.IPBlockerSpec +| Name | Type | Description | Required | +| ----------- | ----------------------------------------- | ----------------------------------------------------- | -------- | +| whitelist | []string | A list of IP addresses that are allowed access. | No | +| blacklist | []string | A list of IP addresses that are denied access. | No | + +### WAFController.GeoIPBlockerSpec +| Name | Type | Description | Required | +| ----------- | ----------------------------------------- | ----------------------------------------------------- | -------- | +| dbPath | string | The file path to the GeoIP database. | Yes | +| dbUpdateCron | string | A cron expression for automatically updating the GeoIP database on a schedule. | No | +| allowedCountries | []string | A list of country codes (e.g., "US", "CN") that are allowed access. | No | +| deniedCountries | []string | A list of country codes that are denied access. | No| \ No newline at end of file diff --git a/docs/07.Reference/7.02.Filters.md b/docs/07.Reference/7.02.Filters.md index d851e13add..a2977e1d38 100644 --- a/docs/07.Reference/7.02.Filters.md +++ b/docs/07.Reference/7.02.Filters.md @@ -81,6 +81,9 @@ - [AIGatewayProxy](#aigatewayproxy) - [Configuration](#configuration-25) - [Results](#results-25) +- [WAF](#waf) + - [Configuration](#configuration-26) + - [Results](#results-26) - [Common Types](#common-types) - [pathadaptor.Spec](#pathadaptorspec) - [pathadaptor.RegexpReplace](#pathadaptorregexpreplace) @@ -1787,6 +1790,44 @@ filters: | middlewareError | Error occurred in one of the configured middlewares | | requestProcessed | Request was successfully processed | + +## WAF + +Example with complete pipeline: + +```yaml +name: waf-pipeline +kind: Pipeline +filters: +- name: waf-filter + kind: WAF + ruleGroup: sqlinjection +- name: proxy + kind: Proxy + pools: + - servers: + - url: http://127.0.0.1:9095 + - url: http://127.0.0.1:9096 + loadBalance: + policy: roundRobin +``` + +### Configuration + +| Name | Type | Description | Required | +|--------------|-----------|------------------------------------------------------------------|----------| +| ruleGroup | string | Name of the WAF rule configured in the WAFController | Yes | + +### Results +| Value | Description | +|-----------------------------|--------------------------------------------------| +| noWAFControllerError | No WAFController found or configured | +| ruleGroupNotFoundError | WAF rule group not found | +| blocked | Request blocked by WAF rule. | +| internalError | Error occurred during processing the request | + + + ## Common Types ### pathadaptor.Spec diff --git a/go.mod b/go.mod index a2690ff033..b0637e8159 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,8 @@ require ( github.com/MicahParks/keyfunc v1.9.0 github.com/Shopify/sarama v1.38.1 github.com/bytecodealliance/wasmtime-go v1.0.0 + github.com/corazawaf/coraza-coreruleset/v4 v4.16.0 + github.com/corazawaf/coraza/v3 v3.3.3 github.com/dave/jennifer v1.7.0 github.com/eclipse/paho.mqtt.golang v1.4.3 github.com/fatih/color v1.18.0 @@ -32,6 +34,7 @@ require ( github.com/invopop/jsonschema v0.12.0 github.com/invopop/yaml v0.2.0 github.com/jackc/pgx/v5 v5.7.5 + github.com/jcchavezs/mergefs v0.1.0 github.com/jtblin/go-ldap-client v0.0.0-20170223121919-b73f66626b33 github.com/libdns/alidns v1.0.3 github.com/libdns/azure v0.3.0 @@ -52,6 +55,7 @@ require ( github.com/nginxinc/nginx-go-crossplane v0.4.33 github.com/open-policy-agent/opa v0.58.0 github.com/openzipkin/zipkin-go v0.4.2 + github.com/oschwald/geoip2-golang/v2 v2.0.0-beta.3 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pgvector/pgvector-go v0.3.0 github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 @@ -121,6 +125,7 @@ require ( github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect + github.com/corazawaf/libinjection-go v0.2.2 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.2.2+incompatible // indirect @@ -147,6 +152,7 @@ require ( github.com/jstemmer/go-junit-report v1.0.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magefile/mage v1.15.1-0.20241126214340-bdc92f694516 // indirect github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/maxbrunsfeld/counterfeiter/v6 v6.6.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -161,6 +167,8 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect + github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.7 // indirect + github.com/petar-dambovaliev/aho-corasick v0.0.0-20240411101913-e07a1f0e8eb4 // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/quic-go/qpack v0.4.0 // indirect @@ -171,8 +179,12 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tchap/go-patricia/v2 v2.3.1 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect github.com/vultr/govultr/v3 v3.3.4 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -187,6 +199,7 @@ require ( gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect gopkg.in/evanphx/json-patch.v5 v5.7.0 // indirect gopkg.in/ldap.v2 v2.5.1 // indirect + rsc.io/binaryregexp v0.2.0 // indirect ) require ( @@ -279,7 +292,7 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/miekg/dns v1.1.56 // indirect + github.com/miekg/dns v1.1.57 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -297,7 +310,7 @@ require ( github.com/prometheus/statsd_exporter v0.25.0 // indirect github.com/rickb777/date v1.20.5 // indirect github.com/rickb777/plural v1.4.1 // indirect - github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/robfig/cron/v3 v3.0.1 github.com/sashabaranov/go-openai v1.40.5 github.com/sirupsen/logrus v1.9.3 // indirect github.com/soheilhy/cmux v0.1.5 // indirect diff --git a/go.sum b/go.sum index 067cdc57b2..d9f7991f61 100644 --- a/go.sum +++ b/go.sum @@ -208,6 +208,14 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc= +github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU= +github.com/corazawaf/coraza-coreruleset/v4 v4.16.0 h1:xbC785u2JYTkoZpYDchW3NOys8sKdFBmh2JTpva1Czc= +github.com/corazawaf/coraza-coreruleset/v4 v4.16.0/go.mod h1:yeZPZUM23HVL0jMAzLfKF3M7XjCQBXDrvcQT/gkjwhg= +github.com/corazawaf/coraza/v3 v3.3.3 h1:kqjStHAgWqwP5dh7n0vhTOF0a3t+VikNS/EaMiG0Fhk= +github.com/corazawaf/coraza/v3 v3.3.3/go.mod h1:xSaXWOhFMSbrV8qOOfBKAyw3aOqfwaSaOy5BgSF8XlA= +github.com/corazawaf/libinjection-go v0.2.2 h1:Chzodvb6+NXh6wew5/yhD0Ggioif9ACrQGR4qjTCs1g= +github.com/corazawaf/libinjection-go v0.2.2/go.mod h1:OP4TM7xdJ2skyXqNX1AN1wN5nNZEmJNuWbNPOItn7aw= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= @@ -285,8 +293,8 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= -github.com/foxcpp/go-mockdns v1.0.0 h1:7jBqxd3WDWwi/6WhDvacvH1XsN3rOLXyHM1uhvIx6FI= -github.com/foxcpp/go-mockdns v1.0.0/go.mod h1:lgRN6+KxQBawyIghpnl5CezHFGS9VLzvtVlwxvzXTQ4= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -544,6 +552,8 @@ github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jcchavezs/mergefs v0.1.0 h1:7oteO7Ocl/fnfFMkoVLJxTveCjrsd//UB0j89xmnpec= +github.com/jcchavezs/mergefs v0.1.0/go.mod h1:eRLTrsA+vFwQZ48hj8p8gki/5v9C2bFtHH5Mnn4bcGk= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= @@ -638,6 +648,8 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magefile/mage v1.15.1-0.20241126214340-bdc92f694516 h1:aAO0L0ulox6m/CLRYvJff+jWXYYCKGpEm3os7dM/Z+M= +github.com/magefile/mage v1.15.1-0.20241126214340-bdc92f694516/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -671,8 +683,8 @@ github.com/megaease/yaml v0.0.0-20220804061446-4f18d6510aed/go.mod h1:N67rkx57qP github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.40/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= -github.com/miekg/dns v1.1.56 h1:5imZaSeoRNvpM9SzWNhEcP9QliKiz20/dA2QabIGVnE= -github.com/miekg/dns v1.1.56/go.mod h1:cRm6Oo2C8TY9ZS/TqsSrseAcncm74lfK5G+ikN2SWWY= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -734,6 +746,10 @@ github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:Ff github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= github.com/openzipkin/zipkin-go v0.4.2 h1:zjqfqHjUpPmB3c1GlCvvgsM1G4LkvqQbBDueDOCg/jA= github.com/openzipkin/zipkin-go v0.4.2/go.mod h1:ZeVkFjuuBiSy13y8vpSDCjMi9GoI3hPpCJSBx/EYFhY= +github.com/oschwald/geoip2-golang/v2 v2.0.0-beta.3 h1:K633WQsXWjRQeOAxroNcpMLuw/Sy6Cz7S7nmBGkBXO4= +github.com/oschwald/geoip2-golang/v2 v2.0.0-beta.3/go.mod h1:mN6THvXcxNmn58/SmW+aCVT4VrVEyb8EyedGIxHhgRk= +github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.7 h1:8ivtp2oRTsp7hTpkMgS5kLDvXC2SQoC2JuLph13ZXp8= +github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.7/go.mod h1:A1wLWQkiHqLUux3/cnHBBKxjYW4s7TZQnQ55fLa37NA= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= @@ -741,6 +757,8 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/petar-dambovaliev/aho-corasick v0.0.0-20240411101913-e07a1f0e8eb4 h1:1Kw2vDBXmjop+LclnzCb/fFy+sgb3gYARwfmoUcQe6o= +github.com/petar-dambovaliev/aho-corasick v0.0.0-20240411101913-e07a1f0e8eb4/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc= @@ -894,6 +912,13 @@ github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxd github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w= github.com/tg123/go-htpasswd v1.2.2 h1:tmNccDsQ+wYsoRfiONzIhDm5OkVHQzN3w4FOBAlN6BY= github.com/tg123/go-htpasswd v1.2.2/go.mod h1:FcIrK0J+6zptgVwK1JDlqyajW/1B4PtuJ/FLWl7nx8A= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= @@ -915,6 +940,8 @@ github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOAp github.com/uptrace/bun/dialect/pgdialect v1.1.12/go.mod h1:Ij6WIxQILxLlL2frUBxUBOZJtLElD2QQNDcu/PWDHTc= github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w= github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM= +github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw= +github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= @@ -1523,6 +1550,7 @@ mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo= mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw= nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q= nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= diff --git a/pkg/api/profile.go b/pkg/api/profile.go index d060206206..e29ef6dc7b 100644 --- a/pkg/api/profile.go +++ b/pkg/api/profile.go @@ -98,7 +98,7 @@ func (s *Server) startCPUProfile(w http.ResponseWriter, r *http.Request) { err = s.profile.UpdateCPUProfile(spr.Path) if err != nil { - HandleAPIError(w, r, http.StatusBadRequest, fmt.Errorf(err.Error())) + HandleAPIError(w, r, http.StatusBadRequest, err) return } } diff --git a/pkg/filters/waf/testdata/Country.mmdb b/pkg/filters/waf/testdata/Country.mmdb new file mode 100644 index 0000000000..41a19a7af1 Binary files /dev/null and b/pkg/filters/waf/testdata/Country.mmdb differ diff --git a/pkg/filters/waf/waf.go b/pkg/filters/waf/waf.go new file mode 100644 index 0000000000..35e694f501 --- /dev/null +++ b/pkg/filters/waf/waf.go @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2017, The Easegress Authors + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package waf + +import ( + "net/http" + + "github.com/megaease/easegress/v2/pkg/context" + "github.com/megaease/easegress/v2/pkg/filters" + "github.com/megaease/easegress/v2/pkg/object/wafcontroller" + "github.com/megaease/easegress/v2/pkg/protocols/httpprot" + "github.com/megaease/easegress/v2/pkg/util/codectool" +) + +const ( + // Kind is the kind of WAF filter. + Kind = "WAF" + // resultNoController is the result when no WAF controller is found. + resultNoController = "noWAFControllerError" +) + +type ( + // WAF is the filter that implements Web Application Firewall (WAF) functionality. + WAF struct { + spec *Spec + } + + // Spec is the specification for the WAF filter. + Spec struct { + filters.BaseSpec `json:",inline"` + // RuleGroupName is the name of the rule group to use for WAF. + RuleGroupName string `json:"ruleGroupName" jsonschema:"required"` + } +) + +var kind = &filters.Kind{ + Name: Kind, + Description: "Web Application Firewall (WAF) filter to protect web applications from attacks.", + Results: append([]string{resultNoController}, wafcontroller.GetResults()...), + DefaultSpec: func() filters.Spec { + return &Spec{} + }, + CreateInstance: func(spec filters.Spec) filters.Filter { + return &WAF{ + spec: spec.(*Spec), + } + }, +} + +func init() { + filters.Register(kind) +} + +func (w *WAF) Name() string { + return w.spec.Name() +} + +func (w *WAF) Kind() *filters.Kind { + return kind +} + +func (w *WAF) Spec() filters.Spec { + return w.spec +} + +func (w *WAF) Init() { + w.reload() +} + +func (w *WAF) Inherit(previousGeneration filters.Filter) { + w.Init() +} + +func (w *WAF) reload() {} + +func (w *WAF) Handle(context *context.Context) (result string) { + handler, err := wafcontroller.GetGlobalWAFController() + if err != nil { + setErrResponse(context, err) + return resultNoController + } + return handler.Handle(context, w.spec.RuleGroupName) +} + +func setErrResponse(ctx *context.Context, err error) { + resp, _ := ctx.GetOutputResponse().(*httpprot.Response) + if resp == nil { + resp, _ = httpprot.NewResponse(nil) + } + resp.SetStatusCode(http.StatusInternalServerError) + errMsg := map[string]string{ + "Message": err.Error(), + } + data, _ := codectool.MarshalJSON(errMsg) + resp.SetPayload(data) + ctx.SetOutputResponse(resp) +} + +func (w *WAF) Status() interface{} { + return nil +} + +func (w *WAF) Close() {} diff --git a/pkg/filters/waf/waf_test.go b/pkg/filters/waf/waf_test.go new file mode 100644 index 0000000000..b1ad1269ca --- /dev/null +++ b/pkg/filters/waf/waf_test.go @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2017, The Easegress Authors + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package waf + +import ( + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/megaease/easegress/v2/pkg/context" + "github.com/megaease/easegress/v2/pkg/filters" + "github.com/megaease/easegress/v2/pkg/logger" + "github.com/megaease/easegress/v2/pkg/object/wafcontroller" + "github.com/megaease/easegress/v2/pkg/object/wafcontroller/protocol" + "github.com/megaease/easegress/v2/pkg/option" + "github.com/megaease/easegress/v2/pkg/protocols/httpprot" + "github.com/megaease/easegress/v2/pkg/supervisor" + "github.com/megaease/easegress/v2/pkg/util/codectool" +) + +func setRequest(t *testing.T, ctx *context.Context, ns string, req *http.Request) { + httpreq, err := httpprot.NewRequest(req) + assert.Nil(t, err) + ctx.SetRequest(ns, httpreq) + ctx.UseNamespace(ns) +} + +func TestMain(m *testing.M) { + logger.InitNop() + code := m.Run() + os.Exit(code) +} + +func TestWafOwaspRules(t *testing.T) { + assert := assert.New(t) + yamlConfig := ` +kind: WAF +name: waf-test +ruleGroupName: test-waf-group +` + rawSpec := make(map[string]interface{}) + codectool.MustUnmarshal([]byte(yamlConfig), &rawSpec) + spec, e := filters.NewSpec(nil, "", rawSpec) + assert.Nil(e, "Failed to create WAF spec") + + p := kind.CreateInstance(spec) + p.Init() + defer p.Close() + + assert.Equal(Kind, p.Kind().Name, "Filter kind should be WAF") + + // test no WAF rule group + ctx := context.New(nil) + { + req, err := http.NewRequest("GET", "http://example.com", nil) + assert.Nil(err, "Failed to create HTTP request") + setRequest(t, ctx, "default", req) + + result := p.Handle(ctx) + assert.Equal(result, resultNoController, "Expected result when no WAF controller is found") + + resp := ctx.GetResponse("default").(*httpprot.Response) + assert.Equal(http.StatusInternalServerError, resp.StatusCode()) + } + + // test with WAF rule group + { + controllerConfig := ` +kind: WAFController +name: waf-controller +ruleGroups: + - name: test-waf-group + rules: + owaspRules: + - REQUEST-901-INITIALIZATION.conf + - REQUEST-942-APPLICATION-ATTACK-SQLI.conf + - REQUEST-949-BLOCKING-EVALUATION.conf +` + super := supervisor.NewMock(option.New(), nil, nil, nil, false, nil, nil) + spec, err := super.NewSpec(controllerConfig) + assert.Nil(err, "Failed to create WAFController spec") + controller := wafcontroller.WAFController{} + controller.Init(spec) + + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8080/test?id=1' OR '1'='1", nil) + assert.Nil(err, "Failed to create HTTP request") + setRequest(t, ctx, "controller", req) + result := p.Handle(ctx) + assert.Equal(string(protocol.ResultBlocked), result) + + resp := ctx.GetResponse("controller").(*httpprot.Response) + assert.Equal(http.StatusInternalServerError, resp.StatusCode(), "Expected status code to be 500 Internal Server Error") + + controller.Close() + } + + { + controllerConfig := ` +kind: WAFController +name: waf-controller +ruleGroups: + - name: test-waf-group + rules: + owaspRules: + - REQUEST-901-INITIALIZATION.conf + - REQUEST-942-APPLICATION-ATTACK-SQLI.conf + - REQUEST-949-BLOCKING-EVALUATION.conf + customRules: | + SecDefaultAction "phase:1,log,auditlog,pass" + SecDefaultAction "phase:2,log,auditlog,pass" + SecAction \ + "id:900990,\ + phase:1,\ + pass,\ + t:none,\ + nolog,\ + tag:'OWASP_CRS',\ + ver:'OWASP_CRS/4.16.0',\ + setvar:tx.crs_setup_version=4160" +` + + super := supervisor.NewMock(option.New(), nil, nil, nil, false, nil, nil) + spec, err := super.NewSpec(controllerConfig) + assert.Nil(err, "Failed to create WAFController spec") + controller := wafcontroller.WAFController{} + controller.Init(spec) + + req, err := http.NewRequest(http.MethodGet, "http://127.0.1:8080/test?email=alice@example.com", nil) + assert.Nil(err, "Failed to create HTTP request") + setRequest(t, ctx, "controller", req) + result := p.Handle(ctx) + assert.Equal(string(protocol.ResultOk), result) + } + + { + controllerConfig := ` +kind: WAFController +name: waf-controller +ruleGroups: + - name: test-waf-group + rules: + ipBlocker: + whitelist: + - 136.252.0.2/32 +` + + super := supervisor.NewMock(option.New(), nil, nil, nil, false, nil, nil) + spec, err := super.NewSpec(controllerConfig) + assert.Nil(err, "Failed to create WAFController spec") + + controller := wafcontroller.WAFController{} + controller.Init(spec) + + req, err := http.NewRequest(http.MethodGet, "http://127.0.1:8080/test", nil) + assert.Nil(err, "Failed to create HTTP request") + req.RemoteAddr = "136.252.0.2:12345" + req.Header.Set("X-Forwarded-For", "136.252.0.2") + req.Header.Set("X-Real-IP", "136.252.0.2") + + setRequest(t, ctx, "controller", req) + result := p.Handle(ctx) + assert.Equal(string(protocol.ResultOk), result, "Expected request to pass through WAF with IPBlocker rules") + } + + { + controllerConfig := ` +kind: WAFController +name: waf-controller +ruleGroups: + - name: test-waf-group + rules: + ipBlocker: + blacklist: + - 158.160.2.1/32 +` + + super := supervisor.NewMock(option.New(), nil, nil, nil, false, nil, nil) + spec, err := super.NewSpec(controllerConfig) + assert.Nil(err, "Failed to create WAFController spec") + + controller := wafcontroller.WAFController{} + controller.Init(spec) + + req, err := http.NewRequest(http.MethodGet, "http://127.0.1:8080/test", nil) + assert.Nil(err, "Failed to create HTTP request") + req.RemoteAddr = "158.160.2.1:12345" + req.Header.Set("X-Forwarded-For", "158.160.2.1") + req.Header.Set("X-Real-IP", "158.160.2.1") + + setRequest(t, ctx, "controller", req) + result := p.Handle(ctx) + assert.Equal(string(protocol.ResultBlocked), result, "Expected request to pass through WAF with IPBlocker rules") + } + { + controllerConfig := ` +kind: WAFController +name: waf-controller +ruleGroups: + - name: test-waf-group + rules: + geoIPBlocker: + dbPath: ./testdata/Country.mmdb + allowedCountries: + - CN +` + + super := supervisor.NewMock(option.New(), nil, nil, nil, false, nil, nil) + spec, err := super.NewSpec(controllerConfig) + assert.Nil(err, "Failed to create WAFController spec") + + controller := wafcontroller.WAFController{} + controller.Init(spec) + + req, err := http.NewRequest(http.MethodGet, "http://127.0.1:8080/test", nil) + assert.Nil(err, "Failed to create HTTP request") + req.RemoteAddr = "124.232.149.1" + req.Header.Set("X-Forwarded-For", "124.232.149.1") + req.Header.Set("X-Real-IP", "124.232.149.1") + + setRequest(t, ctx, "controller", req) + result := p.Handle(ctx) + assert.Equal(string(protocol.ResultOk), result, "Expected request to be blocked by GeoIPBlocker rules") + } + + { + controllerConfig := ` +kind: WAFController +name: waf-controller +ruleGroups: + - name: test-waf-group + rules: + geoIPBlocker: + dbPath: ./testdata/Country.mmdb + deniedCountries: + - CN +` + + super := supervisor.NewMock(option.New(), nil, nil, nil, false, nil, nil) + spec, err := super.NewSpec(controllerConfig) + assert.Nil(err, "Failed to create WAFController spec") + + controller := wafcontroller.WAFController{} + controller.Init(spec) + + req, err := http.NewRequest(http.MethodGet, "http://127.0.1:8080/test", nil) + assert.Nil(err, "Failed to create HTTP request") + req.RemoteAddr = "124.232.149.1" + req.Header.Set("X-Forwarded-For", "124.232.149.1") + req.Header.Set("X-Real-IP", "124.232.149.1") + + setRequest(t, ctx, "controller", req) + result := p.Handle(ctx) + assert.Equal(string(protocol.ResultBlocked), result, "Expected request to be blocked by GeoIPBlocker rules") + } +} diff --git a/pkg/object/autocertmanager/autocertmanager_test.go b/pkg/object/autocertmanager/autocertmanager_test.go index 4964c48508..d7d3ad88f3 100644 --- a/pkg/object/autocertmanager/autocertmanager_test.go +++ b/pkg/object/autocertmanager/autocertmanager_test.go @@ -544,7 +544,7 @@ domains: directoryURL: ` + url etcdDirName, err := os.MkdirTemp("", "autocertmanager-test") if err != nil { - t.Errorf(err.Error()) + t.Error(err.Error()) } defer os.RemoveAll(etcdDirName) @@ -599,7 +599,7 @@ directoryURL: ` + url w := httptest.NewRecorder() r, err := http.NewRequest("GET", "http://example.org/challenge-suffix", nil) if err != nil { - t.Errorf(err.Error()) + t.Error(err.Error()) } acm.spec.EnableHTTP01 = false acm.HandleHTTP01Challenge(w, r) @@ -624,7 +624,7 @@ directoryURL: ` + url token := "asdlijasdoiashvouid" err = cls.Put(key, token) // add data for http01 challenge if err != nil { - t.Errorf(err.Error()) + t.Error(err.Error()) } w = httptest.NewRecorder() @@ -678,7 +678,7 @@ domains: directoryURL: ` + url etcdDirName, err := os.MkdirTemp("", "autocertmanager-test") if err != nil { - t.Errorf(err.Error()) + t.Error(err.Error()) } defer os.RemoveAll(etcdDirName) @@ -726,7 +726,7 @@ domains: directoryURL: ` + url etcdDirName, err := os.MkdirTemp("", "autocertmanager-test") if err != nil { - t.Errorf(err.Error()) + t.Error(err.Error()) } defer os.RemoveAll(etcdDirName) @@ -819,7 +819,7 @@ func waitDNSRecordTest(t *testing.T, d Domain) { func TestDomain(t *testing.T) { etcdDirName, err := os.MkdirTemp("", "autocertmanager-domain-test") if err != nil { - t.Errorf(err.Error()) + t.Error(err.Error()) } defer os.RemoveAll(etcdDirName) cls := cluster.CreateClusterForTest(etcdDirName) diff --git a/pkg/object/httpserver/mux_test.go b/pkg/object/httpserver/mux_test.go index a5883edcfe..97404ff421 100644 --- a/pkg/object/httpserver/mux_test.go +++ b/pkg/object/httpserver/mux_test.go @@ -126,7 +126,7 @@ func TestServerACME(t *testing.T) { // NOTE: For loading system controller AutoCertManager. etcdDirName, err := os.MkdirTemp("", "autocertmanager-test") if err != nil { - t.Errorf(err.Error()) + t.Error(err.Error()) } defer os.RemoveAll(etcdDirName) diff --git a/pkg/object/httpserver/spec_test.go b/pkg/object/httpserver/spec_test.go index 7e673cf874..04f965fc24 100644 --- a/pkg/object/httpserver/spec_test.go +++ b/pkg/object/httpserver/spec_test.go @@ -114,7 +114,7 @@ func TestTlsConfig(t *testing.T) { // NOTE: For loading system controller AutoCertManager. etcdDirName, err := os.MkdirTemp("", "autocertmanager-test") if err != nil { - t.Errorf(err.Error()) + t.Error(err.Error()) } defer os.RemoveAll(etcdDirName) diff --git a/pkg/object/meshcontroller/spec/spec_test.go b/pkg/object/meshcontroller/spec/spec_test.go index 982b459c80..bbb6fc4b66 100644 --- a/pkg/object/meshcontroller/spec/spec_test.go +++ b/pkg/object/meshcontroller/spec/spec_test.go @@ -794,7 +794,7 @@ func TestSidecarIngressPipelineSpecCert(t *testing.T) { // NOTE: For loading system controller AutoCertManager. etcdDirName, err := os.MkdirTemp("", "autocertmanager-test") if err != nil { - t.Errorf(err.Error()) + t.Error(err.Error()) } defer os.RemoveAll(etcdDirName) diff --git a/pkg/object/meshcontroller/worker/worker.go b/pkg/object/meshcontroller/worker/worker.go index 4c4caea4f0..48d9cf4868 100644 --- a/pkg/object/meshcontroller/worker/worker.go +++ b/pkg/object/meshcontroller/worker/worker.go @@ -20,6 +20,7 @@ package worker import ( "encoding/base64" + "errors" "fmt" "net/http" "net/url" @@ -176,7 +177,7 @@ func (worker *Worker) validate() error { if len(worker.serviceName) == 0 { errMsg := "empty service name" logger.Errorf(errMsg) - return fmt.Errorf(errMsg) + return errors.New(errMsg) } _, err = url.ParseRequestURI(worker.aliveProbe) @@ -188,19 +189,19 @@ func (worker *Worker) validate() error { if worker.applicationPort == 0 { errMsg := "empty application port" logger.Errorf(errMsg) - return fmt.Errorf(errMsg) + return errors.New(errMsg) } if len(worker.instanceID) == 0 { errMsg := "empty env HOSTNAME" logger.Errorf(errMsg) - return fmt.Errorf(errMsg) + return errors.New(errMsg) } if len(worker.applicationIP) == 0 { errMsg := "empty env APPLICATION_IP" logger.Errorf(errMsg) - return fmt.Errorf(errMsg) + return errors.New(errMsg) } logger.Infof("sidecar works for service: %s", worker.serviceName) return nil diff --git a/pkg/object/wafcontroller/api.go b/pkg/object/wafcontroller/api.go new file mode 100644 index 0000000000..7f14106ee5 --- /dev/null +++ b/pkg/object/wafcontroller/api.go @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2017, The Easegress Authors + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package wafcontroller + +import ( + "net/http" + + "github.com/megaease/easegress/v2/pkg/api" + "github.com/megaease/easegress/v2/pkg/object/wafcontroller/metrics" + "github.com/megaease/easegress/v2/pkg/util/codectool" +) + +const ( + // APIGroupName is the name of the WAF API group. + APIGroupName = "waf" + // APIPrefix is the prefix for WAF API endpoints. + APIPrefix = "/waf" +) + +type ( + StatsResponse struct { + Stats []*metrics.MetricStats `json:"stats"` + } +) + +func (waf *WAFController) registerAPIs() { + group := &api.Group{ + Group: APIGroupName, + Entries: []*api.Entry{ + { + Path: APIPrefix + "/metrics", + Method: "GET", + Handler: waf.metrics, + }, + }, + } + + api.RegisterAPIs(group) +} + +func (waf *WAFController) unregisterAPIs() { + api.UnregisterAPIs(APIGroupName) +} + +func (waf *WAFController) metrics(w http.ResponseWriter, r *http.Request) { + stats := waf.metricHub.GetStats() + response := StatsResponse{ + Stats: stats, + } + + w.Write(codectool.MustMarshalJSON(response)) +} diff --git a/pkg/object/wafcontroller/metrics/metrics.go b/pkg/object/wafcontroller/metrics/metrics.go new file mode 100644 index 0000000000..f8c6f7373e --- /dev/null +++ b/pkg/object/wafcontroller/metrics/metrics.go @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2017, The Easegress Authors + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package metrics + +import ( + "github.com/megaease/easegress/v2/pkg/logger" + "github.com/megaease/easegress/v2/pkg/supervisor" + "github.com/megaease/easegress/v2/pkg/util/prometheushelper" + "github.com/prometheus/client_golang/prometheus" +) + +type ( + Metric struct { + // TotalRefusedRequests is the total number of refused requests. + TotalRefusedRequest int64 + RuleGroup string + RuleID string + Action string + } + + metricEvent struct { + metric *Metric + statsCh chan []*MetricStats + } + + // Metrics defines the interface for WAF metrics. + MetricHub struct { + // TotalRefusedRequests is the total number of refused requests. + TotalRefusedRequests *prometheus.CounterVec + + spec *supervisor.Spec + stats map[MetricLabels]*MetricDetails + eventCh chan *metricEvent + } + + MetricLabels struct { + RuleGroup string + RuleID string + Action string + } + + MetricDetails struct { + TotalRefusedRequests int64 + } + + MetricStats struct { + MetricLabels `json:",inline"` + MetricDetails `json:",inline"` + } +) + +func NewMetrics(spec *supervisor.Spec) *MetricHub { + commonLabels := prometheus.Labels{ + "kind": "AIGatewayController", + "clusterName": spec.Super().Options().ClusterName, + "clusterRole": spec.Super().Options().ClusterRole, + "instanceName": spec.Super().Options().Name, + } + labels := []string{ + // common labels + "kind", "clusterName", "clusterRole", "instanceName", + // metric labels + "ruleGroup", "ruleID", "action", + } + hub := &MetricHub{ + TotalRefusedRequests: prometheushelper.NewCounter( + "waf_total_refused_requests", + "Total number of refused requests by WAF", + labels, + ).MustCurryWith(commonLabels), + + spec: spec, + stats: make(map[MetricLabels]*MetricDetails), + eventCh: make(chan *metricEvent, 100), + } + logger.Infof("WAF metrics initialized for WAFController") + go hub.run() + return hub +} + +func (m *MetricHub) run() { + for { + select { + case event := <-m.eventCh: + if event != nil { + logger.Infof("Stopping WAF metrics collection") + return + } + if event.statsCh != nil { + event.statsCh <- m.currentStats() + } else if event.metric != nil { + m.updateMetrics(event.metric) + } + } + } +} + +func (m *MetricHub) updateMetrics(metric *Metric) { + labels := MetricLabels{ + RuleGroup: metric.RuleGroup, + RuleID: metric.RuleID, + Action: metric.Action, + } + var details *MetricDetails + if _, exists := m.stats[labels]; !exists { + details = &MetricDetails{} + m.stats[labels] = details + } + + details = m.stats[labels] + details.TotalRefusedRequests++ +} + +func (m *MetricHub) currentStats() []*MetricStats { + stats := make([]*MetricStats, 0, len(m.stats)) + for labels, details := range m.stats { + stats = append(stats, &MetricStats{ + MetricLabels: labels, + MetricDetails: *details, + }) + } + return stats +} + +func (m *MetricHub) sendEvent(event *metricEvent) { + defer func() { + if r := recover(); r != nil { + logger.Errorf("Recovered from panic in WAF metrics event handler: %v", r) + } + }() + + m.eventCh <- event +} + +func (m *MetricHub) Update(metric *Metric) { + if metric == nil { + return + } + + m.sendEvent(&metricEvent{ + metric: metric, + }) + + labels := prometheus.Labels{ + "ruleGroup": metric.RuleGroup, + "ruleID": metric.RuleID, + "action": metric.Action, + } + + m.TotalRefusedRequests.With(labels).Inc() +} + +func (m *MetricHub) GetStats() []*MetricStats { + ch := make(chan []*MetricStats, 1) + + m.sendEvent(&metricEvent{ + statsCh: ch, + }) + + stats := <-ch + if stats == nil { + return nil + } + return stats +} + +func (m *MetricHub) Close() { + logger.Infof("Closing WAF metrics for WAFController") + m.sendEvent(&metricEvent{ + metric: nil, + statsCh: nil, + }) + close(m.eventCh) + // m.eventCh = nil + m.stats = nil + logger.Infof("WAF metrics closed for WAFController") +} diff --git a/pkg/object/wafcontroller/protocol/protocol.go b/pkg/object/wafcontroller/protocol/protocol.go new file mode 100644 index 0000000000..2bdd78ebf4 --- /dev/null +++ b/pkg/object/wafcontroller/protocol/protocol.go @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2017, The Easegress Authors + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package protocol + +import ( + "reflect" + + "github.com/corazawaf/coraza/v3/types" + "github.com/megaease/easegress/v2/pkg/context" + "github.com/megaease/easegress/v2/pkg/protocols/httpprot" +) + +type ( + // PreWAFProcessor defines a function type for preprocessing requests before applying WAF rules. + PreWAFProcessor func(ctx *context.Context, tx types.Transaction, req *httpprot.Request) *WAFResult + + // WAFResultType defines the type of WAF result. + WAFResultType string + + // WAFResult defines the result structure for WAF rules. + WAFResult struct { + // Interruption indicates whether the request was interrupted by the WAF. + Interruption *types.Interruption + Message string `json:"message,omitempty"` + Result WAFResultType `json:"result,omitempty"` + } + + // RuleType defines the type of WAF rule. + RuleType string + + // Rule defines the interface for a WAF rule. + Rule interface { + // Type returns the type of the rule. + Type() RuleType + } + + // RuleGroupSpec defines the specification for a WAF rule group. + RuleGroupSpec struct { + Name string `json:"name" jsonschema:"required"` + // LoadOwaspCrs indicates whether to load the OWASP Core Rule Set. + // Please check https://github.com/corazawaf/coraza-coreruleset for more details. + LoadOwaspCrs bool `json:"loadOwaspCrs,omitempty"` + Rules RuleSpec `json:"rules" jsonschema:"required"` + } + + // RuleSpec defines a WAF rule. + RuleSpec struct { + // OwaspRules defines the OWASP rules to be applied. + // See the example of https://github.com/corazawaf/coraza-coreruleset for more details. + OwaspRules *OwaspRulesSpec `json:"owaspRules,omitempty"` + Customs *CustomsSpec `json:"customRules,omitempty"` + IPBlocker *IPBlockerSpec `json:"ipBlocker,omitempty"` + GeoIPBlocker *GeoIPBlockerSpec `json:"geoIPBlocker,omitempty"` + } + + // IPBlockerSpec defines the specification for IP blocking. + IPBlockerSpec struct { + WhiteList []string `json:"whitelist,omitempty"` + BlackList []string `json:"blacklist,omitempty"` + } + + GeoIPBlockerSpec struct { + DBPath string `json:"dbPath" jsonschema:"required"` + DBUpdateCron string `json:"dbUpdateCron,omitempty"` + AllowedCountries []string `json:"allowedCountries,omitempty"` + DeniedCountries []string `json:"deniedCountries,omitempty"` + } + + // OwaspRulesSpec defines the specification for OWASP rules. + OwaspRulesSpec []string + + // CustomsSpec defines a custom WAF rule. + CustomsSpec string +) + +const ( + // TypeCustoms defines the type for custom WAF rules. + TypeCustoms RuleType = "Customs" + // TypeOwaspRules defines the type for OWASP rules. + TypeOwaspRules RuleType = "OwaspRules" + // TypeSQLInjection defines the type for SQL injection rules. + TypeSQLInjection RuleType = "SQLInjection" + // TypeIPBlocker defines the type for IP blocking rules. + TypeIPBlocker RuleType = "IPBlocker" + // TypeGeoIPBlocker defines the type for GeoIP blocking rules. + TypeGeoIPBlocker RuleType = "GeoIPBlocker" +) + +const ( + // ResultOk indicates that the request is allowed. In easegress, this is empty string. + ResultOk WAFResultType = "" + // ResultBlocked indicates that the request is blocked. + ResultBlocked WAFResultType = "blocked" + // ResultError indicates that an internal error occurred while processing the request. + ResultError WAFResultType = "internalError" +) + +var _ Rule = (*CustomsSpec)(nil) +var _ Rule = (*OwaspRulesSpec)(nil) +var _ Rule = (*IPBlockerSpec)(nil) +var _ Rule = (*GeoIPBlockerSpec)(nil) + +// Type returns the type of the OWASP rule. +func (owasp *OwaspRulesSpec) Type() RuleType { + return TypeOwaspRules +} + +// Type returns the type of the custom rule. +func (rule *CustomsSpec) Type() RuleType { + return TypeCustoms +} + +func (blocker *IPBlockerSpec) Type() RuleType { + return TypeIPBlocker +} + +func (blocker *GeoIPBlockerSpec) Type() RuleType { + return TypeGeoIPBlocker +} + +// GetSpec retrieves the specific rule based on its type name. +func (rule *RuleSpec) GetSpec(typeName string) Rule { + v := reflect.ValueOf(rule) + field := v.FieldByName(typeName) + if !field.IsValid() { + return nil + } + if field.Kind() == reflect.Struct { + if spec, ok := field.Interface().(Rule); ok { + return spec + } + return nil + } + return nil +} diff --git a/pkg/object/wafcontroller/rulegroup.go b/pkg/object/wafcontroller/rulegroup.go new file mode 100644 index 0000000000..3b58ea3c3e --- /dev/null +++ b/pkg/object/wafcontroller/rulegroup.go @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2017, The Easegress Authors + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package wafcontroller + +import ( + "fmt" + "net" + + coreruleset "github.com/corazawaf/coraza-coreruleset/v4" + "github.com/corazawaf/coraza/v3" + "github.com/corazawaf/coraza/v3/types" + "github.com/jcchavezs/mergefs" + "github.com/jcchavezs/mergefs/io" + + "github.com/megaease/easegress/v2/pkg/context" + "github.com/megaease/easegress/v2/pkg/logger" + "github.com/megaease/easegress/v2/pkg/object/wafcontroller/protocol" + "github.com/megaease/easegress/v2/pkg/object/wafcontroller/rules" + "github.com/megaease/easegress/v2/pkg/protocols/httpprot" +) + +type ( + // RuleGroup defines the interface for a WAF rule group. + RuleGroup interface { + Name() string + // Handle processes the request and returns a WAF response. + // TODO: how to handle the response? + // TODO: should we process the stream request and stream response? + Handle(ctx *context.Context) *protocol.WAFResult + + Close() + } + + // ruleGroup implements the RuleGroup interface. + ruleGroup struct { + spec *protocol.RuleGroupSpec + preprocessors []protocol.PreWAFProcessor + waf coraza.WAF + rules []rules.Rule + } +) + +func newRuleGroup(spec *protocol.RuleGroupSpec) (RuleGroup, error) { + ruleset := rules.NewRules(spec.Rules) + loadOwaspCrs := spec.LoadOwaspCrs + if !loadOwaspCrs { + for _, r := range ruleset { + if r.NeedCrs() { + loadOwaspCrs = true + break + } + } + } + + directives := "" + preprocessors := make([]protocol.PreWAFProcessor, 0, len(ruleset)) + for _, r := range ruleset { + if r.Type() == protocol.TypeCustoms { + directives = r.Directives() + "\n" + directives + } else { + directives += r.Directives() + "\n" + } + if r.GetPreprocessor() != nil { + preprocessors = append(preprocessors, r.GetPreprocessor()) + } + } + + config := coraza.NewWAFConfig().WithErrorCallback(corazaErrorCallback) + if loadOwaspCrs { + config = config.WithRootFS(mergefs.Merge(coreruleset.FS, io.OSFS)) + } + logger.Infof("create WAF %s with config:\n%s", spec.Name, directives) + config = config.WithDirectives(directives) + + waf, err := coraza.NewWAF(config) + if err != nil { + for _, rule := range ruleset { + rule.Close() + } + return nil, err + } + + return &ruleGroup{ + spec: spec, + waf: waf, + preprocessors: preprocessors, + rules: ruleset, + }, nil +} + +func corazaErrorCallback(mr types.MatchedRule) { + logMsg := mr.ErrorLog() + switch mr.Rule().Severity() { + + case types.RuleSeverityEmergency, + types.RuleSeverityAlert, + types.RuleSeverityCritical, + types.RuleSeverityError: + logger.Errorf(logMsg) + + case types.RuleSeverityWarning: + logger.Warnf(logMsg) + + case types.RuleSeverityNotice: + logger.Infof(logMsg) + + case types.RuleSeverityInfo: + logger.Infof(logMsg) + + case types.RuleSeverityDebug: + logger.Debugf(logMsg) + } +} + +// Name returns the name of the rule group. +func (rg *ruleGroup) Name() string { + return rg.spec.Name +} + +// Handle processes the request and returns a WAF response. +func (rg *ruleGroup) Handle(ctx *context.Context) *protocol.WAFResult { + req := ctx.GetInputRequest().(*httpprot.Request) + tx := rg.waf.NewTransaction() + ctx.OnFinish(func() { + tx.ProcessLogging() + tx.Close() + }) + + if tx.IsRuleEngineOff() { + return &protocol.WAFResult{ + Result: protocol.ResultOk, + } + } + + // preprocessors + for _, preprocessor := range rg.preprocessors { + result := preprocessor(ctx, tx, req) + if result.Result != protocol.ResultOk { + return result + } + } + + // process the request + result := rg.processRequest(ctx, tx, req) + if result.Result != protocol.ResultOk { + return result + } + + // TODO: process the response + + return &protocol.WAFResult{ + Result: protocol.ResultOk, + } +} + +func parseServerName(host string) string { + serverName, _, err := net.SplitHostPort(host) + if err != nil { + return host + } + return serverName +} + +func formMessage(in *types.Interruption) string { + return fmt.Sprintf("WAF interruption: RuleID=%d, Action=%s, Status=%d, Data=%s", + in.RuleID, in.Action, in.Status, in.Data) +} + +func (rg *ruleGroup) processRequest(_ *context.Context, tx types.Transaction, req *httpprot.Request) *protocol.WAFResult { + stdReq := req.Std() + + tx.ProcessConnection(req.RealIP(), 0, "", 0) + tx.ProcessURI(stdReq.URL.String(), stdReq.Method, stdReq.Proto) + + // process headers + for k, vs := range stdReq.Header { + for _, v := range vs { + tx.AddRequestHeader(k, v) + } + } + if stdReq.Host != "" { + tx.AddRequestHeader("Host", stdReq.Host) + tx.SetServerName(parseServerName(stdReq.Host)) + } + if req.TransferEncoding != nil { + tx.AddRequestHeader("Transfer-Encoding", req.TransferEncoding[0]) + } + it := tx.ProcessRequestHeaders() + if it != nil { + return &protocol.WAFResult{ + Interruption: it, + Message: formMessage(it), + Result: protocol.ResultBlocked, + } + } + + if tx.IsRequestBodyAccessible() { + if req.IsStream() { + // for streaming requests, we do not read the body or process it. + return &protocol.WAFResult{ + Result: protocol.ResultOk, + } + } + + it, _, err := tx.ReadRequestBodyFrom(req.GetPayload()) + if err != nil { + return &protocol.WAFResult{ + Message: fmt.Sprintf("failed to append request body: %s", err.Error()), + Result: protocol.ResultError, + } + } + if it != nil { + return &protocol.WAFResult{ + Interruption: it, + Message: formMessage(it), + Result: protocol.ResultBlocked, + } + } + } + + // still need to call this even not RequestBodyAcces + it, err := tx.ProcessRequestBody() + if err != nil { + return &protocol.WAFResult{ + Message: fmt.Sprintf("failed to process request body: %s", err.Error()), + Result: protocol.ResultError, + } + } + if it != nil { + return &protocol.WAFResult{ + Interruption: it, + Message: formMessage(it), + Result: protocol.ResultBlocked, + } + } + return &protocol.WAFResult{ + Result: protocol.ResultOk, + } +} + +func (rg *ruleGroup) Close() { + for _, rule := range rg.rules { + rule.Close() + } +} diff --git a/pkg/object/wafcontroller/rulegroup_test.go b/pkg/object/wafcontroller/rulegroup_test.go new file mode 100644 index 0000000000..6a97d531aa --- /dev/null +++ b/pkg/object/wafcontroller/rulegroup_test.go @@ -0,0 +1,1674 @@ +/* + * Copyright (c) 2017, The Easegress Authors + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package wafcontroller + +import ( + "bytes" + "fmt" + "mime/multipart" + "net/http" + "net/textproto" + "strings" + "testing" + + "github.com/megaease/easegress/v2/pkg/context" + "github.com/megaease/easegress/v2/pkg/object/wafcontroller/protocol" + "github.com/megaease/easegress/v2/pkg/protocols/httpprot" + "github.com/stretchr/testify/assert" +) + +func setRequest(t *testing.T, ctx *context.Context, ns string, req *http.Request) { + httpreq, err := httpprot.NewRequest(req) + assert.Nil(t, err) + err = httpreq.FetchPayload(1024 * 1024) + assert.Nil(t, err) + ctx.SetRequest(ns, httpreq) + ctx.UseNamespace(ns) +} + +func TestSQLInjectionRules(t *testing.T) { + assert := assert.New(t) + + type sqlInjectionTestCase struct { + Name string + Method string + URL string + Headers map[string]string + Cookies map[string]string + RuleID string + Description string + } + + var testCases = []sqlInjectionTestCase{ + // Rule 942100 - libinjection detection + { + Name: "942100_basic_sqli", + Method: "GET", + URL: "/test?id=1' OR '1'='1", + RuleID: "942100", + Description: "Basic SQL injection using libinjection", + }, + { + Name: "942100_union_select", + Method: "GET", + URL: "/test?search=test' UNION SELECT username,password FROM users--", + RuleID: "942100", + Description: "UNION SELECT injection", + }, + + // Rule 942140 - Database names + { + Name: "942140_information_schema", + Method: "GET", + URL: "/test?query=SELECT * FROM information_schema.tables", + RuleID: "942140", + Description: "Information schema access", + }, + { + Name: "942140_pg_catalog", + Method: "GET", + URL: "/test?meta=pg_catalog.pg_tables", + RuleID: "942140", + Description: "PostgreSQL catalog access", + }, + + // Rule 942151 - SQL function names + { + Name: "942151_concat_function", + Method: "GET", + URL: "/test?data=concat(user(),database())", + RuleID: "942151", + Description: "SQL CONCAT function", + }, + { + Name: "942151_substring_function", + Method: "GET", + URL: "/test?extract=substring(password,1,1)", + RuleID: "942151", + Description: "SQL SUBSTRING function", + }, + + // Rule 942160 - Sleep/Benchmark functions + { + Name: "942160_sleep_function", + Method: "GET", + URL: "/test?delay=sleep(5)", + RuleID: "942160", + Description: "SQL SLEEP function for time-based injection", + }, + { + Name: "942160_benchmark_function", + Method: "GET", + URL: "/test?test=benchmark(1000000,md5(1))", + RuleID: "942160", + Description: "SQL BENCHMARK function", + }, + + // Rule 942170 - Conditional sleep/benchmark + { + Name: "942170_conditional_sleep", + Method: "GET", + URL: "/test?check=SELECT IF(1=1,sleep(5),0)", + RuleID: "942170", + Description: "Conditional sleep injection", + }, + { + Name: "942170_select_benchmark", + Method: "GET", + URL: "/test?time=; SELECT benchmark(1000000,sha1(1))", + RuleID: "942170", + Description: "SELECT with benchmark", + }, + + // Rule 942190 - MSSQL code execution + { + Name: "942190_mssql_exec", + Method: "GET", + URL: "/test?cmd=' exec master..xp_cmdshell 'dir'--", + RuleID: "942190", + Description: "MSSQL command execution", + }, + { + Name: "942190_union_select_user", + Method: "GET", + URL: "/test?info=' UNION SELECT user()--", + RuleID: "942190", + Description: "UNION SELECT user function", + }, + + // Rule 942220 - Integer overflow + { + Name: "942220_integer_overflow", + Method: "GET", + URL: "/test?num=2147483648", + RuleID: "942220", + Description: "Integer overflow value", + }, + { + Name: "942220_magic_number", + Method: "GET", + URL: "/test?crash=2.2250738585072011e-308", + RuleID: "942220", + Description: "Magic number crash", + }, + + // Rule 942230 - Conditional SQL injection + { + Name: "942230_case_when", + Method: "GET", + URL: "/test?logic=(CASE WHEN 1=1 THEN 'true' ELSE 'false' END)", + RuleID: "942230", + Description: "CASE WHEN conditional", + }, + { + Name: "942230_if_condition", + Method: "GET", + URL: "/test?test=IF(1=1,'yes','no')", + RuleID: "942230", + Description: "IF condition", + }, + + // Rule 942240 - MySQL charset and MSSQL DoS + { + Name: "942240_alter_charset", + Method: "GET", + URL: "/test?set=ALTER TABLE users CHARACTER SET utf8", + RuleID: "942240", + Description: "ALTER charset command", + }, + { + Name: "942240_waitfor_delay", + Method: "GET", + URL: "/test?delay='; WAITFOR DELAY '00:00:05'--", + RuleID: "942240", + Description: "MSSQL WAITFOR DELAY", + }, + + // Rule 942250 - MATCH AGAINST, MERGE, EXECUTE IMMEDIATE + { + Name: "942250_match_against", + Method: "GET", + URL: "/test?search=MATCH(title,content) AGAINST('test')", + RuleID: "942250", + Description: "MATCH AGAINST fulltext search", + }, + { + Name: "942250_execute_immediate", + Method: "GET", + URL: "/test?exec=EXECUTE IMMEDIATE 'SELECT * FROM users'", + RuleID: "942250", + Description: "EXECUTE IMMEDIATE command", + }, + + // Rule 942270 - Basic UNION SELECT + { + Name: "942270_union_select_from", + Method: "GET", + URL: "/test?id=1 UNION SELECT username FROM users", + RuleID: "942270", + Description: "Basic UNION SELECT FROM", + }, + + // Rule 942280 - pg_sleep and shutdown + { + Name: "942280_pg_sleep", + Method: "GET", + URL: "/test?wait=SELECT pg_sleep(5)", + RuleID: "942280", + Description: "PostgreSQL pg_sleep function", + }, + { + Name: "942280_shutdown", + Method: "GET", + URL: "/test?cmd=; SHUTDOWN --", + RuleID: "942280", + Description: "Database shutdown command", + }, + + // Rule 942290 - MongoDB injection + { + Name: "942290_mongodb_where", + Method: "GET", + URL: "/test?filter=$where:function(){return true}", + RuleID: "942290", + Description: "MongoDB $where injection", + }, + + // Rule 942320 - Stored procedures + { + Name: "942320_create_procedure", + Method: "GET", + URL: "/test?sql=CREATE PROCEDURE test() - comment", + RuleID: "942320", + Description: "CREATE PROCEDURE command", + }, + + // Rule 942350 - UDF and data manipulation + { + Name: "942350_create_function", + Method: "GET", + URL: "/test?udf=CREATE FUNCTION test() RETURNS STRING", + RuleID: "942350", + Description: "CREATE FUNCTION for UDF", + }, + { + Name: "942350_alter_table", + Method: "GET", + URL: "/test?ddl=; ALTER TABLE users DROP COLUMN password", + RuleID: "942350", + Description: "ALTER TABLE command", + }, + + // Rule 942360 - Concatenated SQL injection + { + Name: "942360_load_file", + Method: "GET", + URL: "/test?file=LOAD_FILE('/etc/passwd')", + RuleID: "942360", + Description: "LOAD_FILE function", + }, + { + Name: "942360_select_into_outfile", + Method: "GET", + URL: "/test?export=SELECT * FROM users INTO OUTFILE '/tmp/dump'", + RuleID: "942360", + Description: "SELECT INTO OUTFILE", + }, + + // Rule 942500 - MySQL inline comments + { + Name: "942500_mysql_comment", + Method: "GET", + URL: "/test?bypass=/*! SELECT */ * FROM users", + RuleID: "942500", + Description: "MySQL inline comment bypass", + }, + { + Name: "942500_version_comment", + Method: "GET", + URL: "/test?ver=/*!50000 SELECT version() */", + RuleID: "942500", + Description: "MySQL version-specific comment", + }, + + // Rule 942540 - Authentication bypass + { + Name: "942540_quote_bypass", + Method: "GET", + URL: "/test?user=admin';", + RuleID: "942540", + Description: "Quote-based authentication bypass", + }, + + // Rule 942550 - JSON-based SQL injection + { + Name: "942550_json_extract", + Method: "GET", + URL: "/test?json=JSON_EXTRACT(data,'$.password')", + RuleID: "942550", + Description: "JSON_EXTRACT function", + }, + + // Header-based injections + { + Name: "942100_user_agent_sqli", + Method: "GET", + URL: "/test", + Headers: map[string]string{ + "User-Agent": "Mozilla/5.0' OR 1=1--", + }, + RuleID: "942100", + Description: "SQL injection in User-Agent header", + }, + { + Name: "942100_referer_sqli", + Method: "GET", + URL: "/test", + Headers: map[string]string{ + "Referer": "http://evil.com' UNION SELECT password FROM users--", + }, + RuleID: "942100", + Description: "SQL injection in Referer header", + }, + + // Cookie-based injections + { + Name: "942100_cookie_sqli", + Method: "GET", + URL: "/test", + Cookies: map[string]string{ + "sessionid": "abc123' OR 1=1--", + }, + RuleID: "942100", + Description: "SQL injection in cookie value", + }, + { + Name: "942140_cookie_db_name", + Method: "GET", + URL: "/test", + Cookies: map[string]string{ + "dbinfo": "information_schema.columns", + }, + RuleID: "942140", + Description: "Database name in cookie", + }, + } + + customRules := protocol.CustomsSpec(crsSetupConf) + + owaspRules := protocol.OwaspRulesSpec{ + "REQUEST-901-INITIALIZATION.conf", + "REQUEST-942-APPLICATION-ATTACK-SQLI.conf", + "REQUEST-949-BLOCKING-EVALUATION.conf", + } + spec := &protocol.RuleGroupSpec{ + Name: "testGroup", + Rules: protocol.RuleSpec{ + OwaspRules: &owaspRules, + Customs: &customRules, + }, + } + ruleGroup, err := newRuleGroup(spec) + assert.Nil(err, "Failed to create rule group") + ctx := context.New(nil) + + for _, tc := range testCases { + fmt.Println("Testing case:", tc.Name) + req, err := http.NewRequest(tc.Method, "http://127.0.0.1:8080"+tc.URL, nil) + assert.Nil(err, "Failed to create request", tc) + + for key, value := range tc.Headers { + req.Header.Set(key, value) + } + + for name, value := range tc.Cookies { + req.AddCookie(&http.Cookie{Name: name, Value: value}) + } + setRequest(t, ctx, tc.Name, req) + result := ruleGroup.Handle(ctx) + assert.NotNil(result.Interruption) + assert.Equal(http.StatusForbidden, result.Interruption.Status) + assert.Equal(protocol.ResultBlocked, result.Result) + } + + allowedUrls := []string{ + "/test?id=123", + "/test?id=hello", + "/test?id=alice&foo=bar", + "/test?id=2025-08-07", + "/test?user=alice&search=book", + "/test?category=electronics&page=2", + "/test?email=alice@example.com", + "/test?price=19.99", + "/test?name=张三", + "/test?comment=nice+post", + } + for _, u := range allowedUrls { + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8080"+u, nil) + assert.Nil(err) + setRequest(t, ctx, u, req) + result := ruleGroup.Handle(ctx) + assert.Equal(protocol.ResultOk, result.Result) + } +} + +func TestXssAttackRules(t *testing.T) { + assert := assert.New(t) + + type xssAttackTestCase struct { + Name string + Method string + URL string + Headers map[string]string + Cookies map[string]string + RuleID string + Description string + } + + var xssAttackTestCases = []xssAttackTestCase{ + { + Name: "941100_libinjection_script", + Method: "GET", + URL: "/test?input=%3Cscript%3Ealert(1)%3C%2Fscript%3E", + RuleID: "941100", + Description: "libinjection detects basic script XSS attack", + }, + { + Name: "941110_script_tag", + Method: "GET", + URL: "/test?msg=", + RuleID: "941110", + Description: "Direct