Security Rules
Overview
Security Rules are ordered, first-match-wins rules that evaluate the coreX expression language against request properties and execute an action. They are the primary access control and traffic shaping mechanism in coreX Platform, sitting between WAF evaluation and backend routing.
Each rule has:
- A name for identification
- An expression in the coreX expression language
- An action to execute when the expression matches
- A priority (drag-and-drop ordering)
- Listener scoping (which listeners the rule applies to)
The coreX Expression Language
The expression language is coreX Platform’s own DSL for matching request properties. It supports field access, comparison operators, logical operators, and list membership.
Fields
Fields are grouped by category in the rule builder. The tables below list every available field, its type, and a description. Fields marked request-phase are available during request processing; response-phase fields are only available after the backend responds (rules referencing them are emitted as http-response lines).
Several field groups require features to be enabled in Settings > Global Options or have infrastructure prerequisites:
- Request Fingerprint fields require
req_fp_enabled(default off). The Rustreq_fp_capturemodule builds the fingerprint and populates thetxn.req_fp.*subfields. - JA4 (
http.request.ja4) requiresja4_enabled(default on). Toggling it off auto-disables rules that reference JA4 to prevent a broken config. - GeoIP fields require MaxMind databases (auto-downloaded every 24h). City-level fields (city, region, postal, timezone, lat/long, continent) require the GeoLite2-City DB; ASN requires the GeoLite2-ASN DB.
- Risk Scoring fields require
req_fp_enabled(the risk engine reads fingerprint metadata). See Risk Scoring. - GraphQL, API Schema, Auth, Profiling fields require API Armor to be enabled and the Rust
body_parsermodule loaded. See API Armor. - Beacon Trust (
ip.beacon_trusted) requires Page Protect with beacon enforcement enabled. See Page Protect.
Risk Scoring
Computed by the Risk Scoring engine before Security Rules run. Available in request-phase rules. See Risk Scoring for the full scoring model.
| Field | Type | Phase | Description |
|---|---|---|---|
risk.score | int | request | Default ruleset’s aggregate risk score (0–99). Computed from signed point contributions of all matched risk rules. Use thresholds like risk.score > 50 to gate actions. |
risk.rules_hit | string | request | Comma-separated names of all risk rules that matched this request. Use contains to check for a specific rule: risk.rules_hit contains "known_bot". |
risk.rules_hit_count | int | request | Number of risk rules that matched. Useful for detecting requests that tripped multiple signals: risk.rules_hit_count >= 3. |
risk.hit_density | int | request | Hit density percentage — how many of the ruleset’s rules fired relative to its total. A high density means the request matched a broad swath of rules, indicating a more suspicious profile than a single high-weight match. |
Request
Basic request properties available in every deployment — no feature flags required.
| Field | Type | Phase | Description |
|---|---|---|---|
http.request.method | string | request | HTTP method (GET, POST, PUT, DELETE, etc.). Uppercase. |
http.request.uri.path | string | request | Request path only, without query string (e.g. /api/v1/users). |
http.request.uri | string | request | Full request URI — path + query string (e.g. /api/v1/users?active=true). |
http.request.full_uri | string | request | Absolute URI including scheme and host (e.g. https://app.example.com/api/v1/users). |
http.request.uri.query | string | request | Query string only, without the leading ? (e.g. active=true&page=1). Use bracket access for specific params: http.request.uri.query["page"]. |
http.host | string | request | Host request header value. |
http.request.user_agent | string | request | User-Agent request header value. |
http.request.referer | string | request | Referer request header value. |
http.request.version | string | request | HTTP protocol version string as sent by the client (e.g. HTTP/1.1, HTTP/2.0). |
http.request.scheme | bool | request | Whether the request is over TLS (https). Compare with == "https" or == "http", or use as a bare boolean. |
http.request.tls | bool | request | Whether the connection is over TLS. Bare boolean — use without an operator: http.request.tls and .... |
http.request.keep_alive | bool | request | Whether the request has a Connection: keep-alive (or HTTP/2) header. Set by the Risk Scoring metadata capture; requires req_fp_enabled. |
http.request.hour | int | request | Hour of the day (0–23, server timezone) when the request arrived. Set by the Risk Scoring metadata capture; requires req_fp_enabled. Useful for time-based rules: http.request.hour >= 22 or http.request.hour < 6. |
Request Headers & Cookies
Bracket-access fields — use ["key"] to read a specific header or cookie.
| Field | Type | Phase | Description |
|---|---|---|---|
http.request.headers["name"] | string | request | Value of the named request header. Header name is case-insensitive. Returns the first value if multiple are present. Example: http.request.headers["x-api-key"] == "secret". |
http.request.cookies["name"] | string | request | Value of the named cookie. Returns the first value if multiple cookies share the name. Example: http.request.cookies["session"] != "". |
IP & GeoIP
Client IP and GeoIP enrichment fields. GeoIP fields use a three-tier fallback (Rust Lua module → native geoip2 converter → map_ip fallback for country/ASN only) and require the corresponding MaxMind database to be present.
| Field | Type | Phase | Description |
|---|---|---|---|
ip.src | ip | request | Client IP address. Supports in $network:name for CIDR list membership. |
ip.geoip.country | string | request | ISO 3166-1 alpha-2 country code (e.g. US, DE, JP). Requires GeoLite2-Country or GeoLite2-City DB. Supports in $geo:name. |
ip.geoip.asnum | string | request | Autonomous system number, formatted as AS<n> (e.g. AS15169). Requires GeoLite2-ASN DB. Supports in $asn:name. |
ip.geoip.continent | string | request | Two-letter continent code (e.g. NA, EU, AS). Requires GeoLite2-City DB. |
ip.geoip.city | string | request | City name in English (e.g. San Francisco). Requires GeoLite2-City DB. |
ip.geoip.region | string | request | First-level subdivision ISO code (e.g. CA for California, ON for Ontario). Requires GeoLite2-City DB. |
ip.geoip.postal_code | string | request | Postal code (e.g. 94107). Requires GeoLite2-City DB. |
ip.geoip.timezone | string | request | IANA timezone identifier (e.g. America/Los_Angeles). Requires GeoLite2-City DB. |
ip.geoip.latitude | int | request | Latitude (approximate, rounded). Requires GeoLite2-City DB. |
ip.geoip.longitude | int | request | Longitude (approximate, rounded). Requires GeoLite2-City DB. |
ip.beacon_trusted | int | request | Page Protect beacon trust count — how many times this IP has been seen sending the Page Protect beacon. A value > 0 means the IP is trusted via the beacon stick table. Requires Page Protect with beacon enforcement. |
http.request.geo_lang_mismatch | bool | request | Whether the Accept-Language header’s primary language does not match the GeoIP country’s primary language (e.g. Accept-Language: ru from a US IP). Set by Risk Scoring metadata capture; requires req_fp_enabled + GeoIP. |
http.request.geoip.timezone_mismatch | bool | request | Whether the client IP’s GeoIP timezone does not match the timezone implied by any X-Forwarded-Timezone or similar header. Set by Risk Scoring metadata capture; requires req_fp_enabled + GeoIP. |
TLS
TLS connection properties. Available only on HTTPS listeners (request-phase only, since TLS is terminated before routing).
| Field | Type | Phase | Description |
|---|---|---|---|
http.request.tls.cipher | string | request | Negotiated TLS cipher suite name (e.g. TLS_AES_256_GCM_SHA384). |
http.request.tls.version | string | request | Negotiated TLS protocol version (e.g. TLSv1.3, TLSv1.2). |
http.request.ja4 | string | request | JA4 TLS fingerprint — a compact hash of the TLS ClientHello that identifies the client’s TLS stack. Format: {proto}{version}{sni}{cipher_count}{ext_count}_{alpn}_{cipher_hash}_{ext_hash} (e.g. t13d1516h2_8daaf6152771_b186095e22b6). Unlike User-Agent, JA4 cannot be spoofed without changing the TLS library. Requires ja4_enabled. Supports in $ja4:name. |
http.request.alpn | string | request | Application-Layer Protocol Negotiation value negotiated during TLS (e.g. h2, http/1.1). |
Request Fingerprint (req_fp)
The request fingerprint is a structured profile of the HTTP request built by the Rust req_fp_capture module. It decomposes the request into normalized subfields that are stable across minor request variations but distinctive enough to identify client behavior patterns. All fields in this group require req_fp_enabled (default off) — enable it in Settings > Global Options.
The fingerprint has two layers:
- Request-phase subfields (
txn.req_fp.*) — set bylua.req_fp_captureearly in request processing. Available for request-phase security rules. - Full fingerprint string (
txn.req_fp) — assembled in the response phase by appending the response status and body byte count to the partial fingerprint. Available only in response-phase rules.
| Field | Type | Phase | Description |
|---|---|---|---|
http.request.fingerprint | string | response | Full request fingerprint string. Combines the request-phase partial fingerprint with the response status code and body byte count. Format: {partial_fp}_{status}_{body_bytes}. Use for allowlisting known-good client+endpoint combinations via in $pattern:name. |
http.request.fingerprint.content_type | string | request | Normalized Content-Type of the request body, lowercased with parameters stripped (e.g. application/json, application/x-www-form-urlencoded). Empty for bodyless requests. |
http.request.fingerprint.param_keys | string | request | Comma-separated, sorted list of parameter names from the query string and/or request body (e.g. email,password,redirect). Order-independent — two requests with the same params in different order produce the same value. Useful for detecting unusual parameter combinations. |
http.request.fingerprint.param_types | string | request | Comma-separated list of parameter types paired with param_keys (e.g. string,string,int). Types are inferred from the value: string, int, float, bool, null, array, object. |
http.request.fingerprint.param_lens | string | request | Comma-separated list of parameter value lengths paired with param_keys (e.g. 24,8,1). Useful for detecting anomalously long parameter values. |
http.request.fingerprint.path_depth | int | request | Number of path segments in the request URI (e.g. /api/v1/users → depth 3). Useful for detecting path traversal or unusually deep paths. |
http.request.fingerprint.header_count | int | request | Total number of request headers. Unusually high or low counts can indicate automated tooling. |
http.request.fingerprint.header_list | string | request | Comma-separated, sorted list of lowercased request header names (e.g. accept,authorization,content-type,user-agent). Order-independent. Compare against known-good header sets via in $pattern:name. |
http.request.fingerprint.auth_type | string | request | Detected authentication type from the request: bearer, api_key, basic, jwt, or empty if none detected. Set by the req_fp_capture module (not API Armor). |
http.request.fingerprint.body_depth | int | request | Maximum nesting depth of a JSON or form-encoded request body (e.g. {"a":{"b":{"c":1}}} → depth 3). 0 for non-JSON bodies or bodyless requests. Useful for detecting deeply nested payloads used in attacks. |
The following metadata fields are derived from the request fingerprint by the Risk Scoring engine’s risk_capture phase and are also available in security rule expressions. They require both req_fp_enabled and the Risk Scoring engine to be active.
| Field | Type | Phase | Description |
|---|---|---|---|
http.request.fingerprint.cipher_count | int | request | Number of cipher suites offered in the TLS ClientHello. Extracted from the JA4 fingerprint (positions 5–6). Requires ja4_enabled. A very low count can indicate a scanner; a very high count can indicate a fingerprint-spoofing tool. |
http.request.fingerprint.ext_count | int | request | Number of TLS extensions in the ClientHello. Extracted from the JA4 fingerprint (positions 7–8). Requires ja4_enabled. |
http.request.user_agent_length | int | request | Length of the User-Agent header string. Unusually short or long UAs can indicate automation. |
http.request.uri_length | int | request | Total length of the request URI (path + query string). Unusually long URIs can indicate injection attempts or scanning. |
http.request.param_count | int | request | Total number of parameters across query string and body. |
http.request.version_numeric | int | request | HTTP version as a numeric value (e.g. 11 for HTTP/1.1, 20 for HTTP/2). |
Response Fingerprint
Available only in response-phase rules (rules that reference any http.response.* field are emitted as http-response lines).
| Field | Type | Phase | Description |
|---|---|---|---|
http.response.fingerprint.status | int | response | HTTP response status code (e.g. 200, 403, 500). Appended to the full fingerprint string. |
http.response.fingerprint.body_bytes | int | response | Response body size in bytes. Appended to the full fingerprint string. |
GraphQL
GraphQL analysis fields, set by the API Armor Rust body_parser module when it detects a GraphQL request. Require API Armor enabled and the body_parser module loaded. See API Armor.
| Field | Type | Phase | Description |
|---|---|---|---|
graphql.operation | string | request | GraphQL operation type: query, mutation, or subscription. Empty for non-GraphQL requests. |
graphql.depth | int | request | Maximum nesting depth of the GraphQL query (e.g. users { posts { comments { author } } } → depth 3). Deep queries can cause excessive DB load. |
graphql.complexity | int | request | Query complexity score — a weighted sum of fields, fragments, and nesting. Higher complexity means more server-side work. |
graphql.field_count | int | request | Total number of fields selected in the query, including nested fields. |
graphql.alias_count | int | request | Number of GraphQL aliases used. Aliases allow multiple fields with the same name; high alias counts can indicate batching attacks. |
graphql.fragment_count | int | request | Number of fragment definitions (fragment X on Y { ... }) in the query. |
graphql.query_hash | string | request | SHA-256 hash of the normalized query string. Stable across whitespace/formatting changes. Use for allowlisting known-good queries via in $pattern:name. |
graphql.valid | bool | request | Whether the GraphQL query parsed successfully. Bare boolean — use without an operator: not graphql.valid. |
API Schema Validation
JSON schema validation against an uploaded OpenAPI/Swagger spec. Set by the API Armor Rust body_parser. Require API Armor enabled.
| Field | Type | Phase | Description |
|---|---|---|---|
api.schema_valid | bool | request | Whether the request body validated against the endpoint’s OpenAPI schema. Bare boolean. false means the body didn’t match the spec (extra fields, wrong types, missing required fields). |
api.schema_errors | string | request | Human-readable validation error(s) from the schema check (e.g. body.user.email: expected string, got number). Empty when valid. |
Auth Validation
Authentication validation fields, set by the API Armor Rust body_parser when an auth policy is attached to the listener. Require API Armor enabled.
| Field | Type | Phase | Description |
|---|---|---|---|
auth.valid | bool | request | Whether the request passed auth validation (API-key, JWT, or bearer token, depending on the attached policy). Bare boolean. |
auth.type | string | request | Detected auth type: jwt, api_key, bearer, or empty if no auth was attempted. |
auth.error | string | request | Auth validation error message (e.g. invalid signature, expired token, missing api key). Empty when auth passed or wasn’t attempted. |
auth.claim.sub | string | request | JWT sub (subject) claim value, if a valid JWT was provided. |
auth.claim.iss | string | request | JWT iss (issuer) claim value. |
auth.claim.aud | string | request | JWT aud (audience) claim value. |
auth.claim["key"] | string | request | Any arbitrary JWT claim by key. Bracket access: auth.claim["custom_claim"] == "value". |
Behavioral Profiling
Anomaly detection against learned API behavioral profiles. Set by the API Armor Rust body_parser. Require API Armor enabled and a finalized profile for the endpoint.
| Field | Type | Phase | Description |
|---|---|---|---|
api.profile_anomaly | bool | request | Whether the request was flagged as anomalous by the behavioral profiler (deviates from the learned profile for this endpoint). Bare boolean. Profiles must be finalized (min 100 samples by default) before anomaly detection activates. |
Response
Response-phase fields. Rules referencing these are emitted as http-response lines and evaluated after the backend responds. Skip actions are not valid in response-phase rules.
| Field | Type | Phase | Description |
|---|---|---|---|
http.response.status_code | int | response | HTTP response status code returned by the backend (e.g. 200, 404, 503). |
http.response.headers["name"] | string | response | Value of the named response header. Bracket access: http.response.headers["x-cache"] == "MISS". |
Operators
| Operator | Description |
|---|---|
== | Equal |
!= | Not equal |
> / >= | Greater than / greater or equal |
< / <= | Less than / less or equal |
in | Membership in a Security List |
not in | Not in a Security List |
contains | String contains substring |
matches | Regex match |
and | Logical AND |
or | Logical OR |
not | Logical NOT |
Examples
ip.geoip.country in $geo:high_risk_countries
http.request.uri.path matches "^/admin"
http.request.method == "POST" and http.request.fingerprint.content_type contains "json"
ip.src in $network:blocked_networks
risk.score > 50 and not auth.valid
http.request.ja4 in $ja4:bad_fingerprints
http.request.headers["x-api-key"] == ""
graphql.depth > 10
api.profile_anomaly and not auth.valid
Security Lists are referenced with the $type:name syntax (e.g. $geo:high_risk_countries, $network:blocked_networks). The list must exist before the rule can be applied.
Actions
| Action | Description |
|---|---|
| allow | Permit the request through |
| deny | Block with 403 |
| challenge | Present a CAPTCHA challenge |
| log | Log the request but allow it through |
| tarpit | Block and hold the connection (block duration) |
| skip_rules | Skip subsequent security rules |
| skip_ratelimit | Skip rate limiting for this request |
| skip_waf | Skip WAF evaluation for this request |
Rule Ordering
Rules are evaluated in priority order (drag-and-drop). The first rule whose expression matches determines the action. If no rules match, the request is allowed through to backend routing.
Only the first matching rule’s action is executed. Place more specific rules above more general ones. For example, a rule that allows a specific IP should come before a rule that blocks a broader range containing that IP.
Listener Scoping
Each rule can be scoped to specific listeners:
| Scope | Description |
|---|---|
| All listeners | Rule applies to every listener (default) |
| Specific listeners | Rule applies only to selected listener IDs |
| Frontend match | Rule applies to listeners matching a frontend name pattern |
Step-by-Step: Block a Country
-
Create a GeoIP Security List:
- Navigate to Security > Security Lists > GeoIP tab
- Add a list named
blocked-countries - Add country codes (e.g.
XX,YY) - Save
-
Create the Security Rule:
- Navigate to Security > Security Rules
- Click Add Rule
- Name:
block-high-risk-countries - Expression:
ip.geoip.country in $geo:blocked_countries - Action:
deny - Scope: All listeners (or specific)
- Save
-
Apply:
- Click Apply Changes
Step-by-Step: Challenge High-Risk Requests
-
Ensure Risk Scoring is configured (see Risk Scoring)
-
Navigate to Security > Security Rules
-
Click Add Rule
-
Name:
challenge-high-risk -
Expression:
risk.score > 50 -
Action:
challenge -
Save and Apply
Use and not auth.valid to only challenge unauthenticated requests: risk.score > 50 and not auth.valid. This avoids challenging known-good API clients.
Step-by-Step: Skip WAF for a Trusted Path
- Navigate to Security > Security Rules
- Click Add Rule
- Name:
skip-waf-webhooks - Expression:
http.request.uri.path matches "^/webhooks/" and http.request.headers["x-webhook-secret"] == "my-secret" - Action:
skip_waf - Save and Apply
Verification
- Preview the config — on the Dashboard, click Preview and search for your rule name in the generated HAProxy config
- Test with a matching request:
curl -k https://localhost/ -H "X-Test: match" - Test with a non-matching request:
curl -k https://localhost/ - Check access logs — navigate to Observability > Access Logs and verify the security rule action is logged
Next Steps
- Security Lists — Create reusable IP, ASN, GeoIP, and JA4 lists
- Risk Scoring — Score requests and reference scores in rules
- WAF — Layer Coraza WAF rules
- CAPTCHA — Configure challenge providers