Protection rules¶
Your own policy on top of the defense posture: each rule matches requests and decides what happens to them. The allowlist names what mitigation must never block. Their place in the request path: pipeline. Requests need API access.
Endpoints¶
| Method | Path | Key | Purpose |
|---|---|---|---|
PATCH |
/services/{fqdn} |
write | Change a vService |
PATCH /services/{fqdn}
Body: ServicePatch
Returns: 200, Service
Request: waf_and_limit
{
"waf": {
"enabled": true
},
"rate_limiting": {
"req_per_10s": 800
}
}
Request: replace_origins
{
"origins": [
{
"name": "origin-1",
"address": "192.0.2.10",
"port": 443
}
]
}
Answer 200: waf_and_limit
{
"fqdn": "a.example.com",
"tenant": "tenant-a",
"enabled": true,
"mode": "https",
"ports": [
443
],
"udp_ports": [],
"created": "2026-10-01T00:00:00+00:00",
"created_by": "",
"account_id": "acc-a",
"network": {
"vips": [
{
"anycast_ipv4": "203.0.113.1",
"anycast_ipv6": "2001:db8:a::1"
}
],
"pool": null,
"edge_hostname": "a-example-com.edge.protect.zeroservices.eu"
},
"network_protection": {
"enabled": true
},
"backend": {
"algorithm": "roundrobin",
"health_check": {}
},
"origins": [
{
"name": "origin-1",
"address": "192.0.2.10",
"port": 443,
"tls": true,
"ssl_verify": true,
"weight": 100,
"maxconn": 100
}
],
"waf": {
"enabled": true,
"fail_open": true,
"mode": "On",
"paranoia_level": 1,
"inbound_threshold": 5,
"disabled_rules": []
},
"rate_limiting": {
"challenge_req_per_10s": 200,
"req_per_10s": 800,
"conn_per_10s": 50,
"conn_drop_per_10s": 500,
"path_default": 1000,
"path_aggregate_per_10s": 5000,
"geo_tcp_reject_sess_rate": 200,
"ja4_enabled": false,
"ja4_req_per_10s": 100,
"ja4_action": "challenge"
},
"pow": {
"enabled": true,
"difficulty": 18,
"cookie_ttl": 86400,
"always_challenge": false,
"exempt_crawlers": true,
"bind_ip": true,
"botcheck_per_10s": 1000
},
"admission": {
"enabled": false,
"credit_required": 5,
"credit_window": "60s",
"known_ttl": "24h",
"unknown_conn_rate": 1500,
"table_size": "1m"
},
"ssl": {
"mode": "acme",
"acme_provider": "zerossl",
"acme_email": null,
"https_redirect": false,
"cert_status": "none",
"cert_issued": null,
"cert_expires": null,
"cert_last_renewal": null,
"days_remaining": null
},
"aliases": [
{
"fqdn": "www.a.example.com",
"ssl": {
"mode": "acme",
"acme_provider": "zerossl",
"acme_email": null,
"https_redirect": false,
"cert_status": "none",
"cert_issued": null,
"cert_expires": null,
"cert_last_renewal": null,
"days_remaining": null
}
}
],
"cache": {
"enabled": true
},
"slow_http": {
"enabled": true,
"timeout_http_request": "5s",
"timeout_client_fin": "5s"
},
"tcp_timeout": {
"client": "30s",
"server": "30s",
"tunnel": "30s"
},
"udp_timeout": {
"idle": "300s",
"unreplied": "30s",
"unreplied_pkts": 32,
"src_pps": 0
},
"sni_policy": "allowlist",
"proxy_protocol": false,
"tls_termination": false,
"priority": {
"enabled": true,
"session_signal": []
},
"response_headers": {
"hsts": {},
"cors": {},
"x_frame_options": null,
"x_content_type_options": false,
"referrer_policy": null
},
"content_negotiation": {
"enabled": false,
"encodings": [
"zstd",
"gzip"
],
"file_types": [
"css",
"js",
"html"
]
},
"logging": {
"enabled": false
},
"threat_intel": {
"mode": "log",
"abuse": "log",
"tor": "log"
},
"redirects": [],
"origin_host": null,
"origin_sni": null,
"maintenance_mode": false,
"auto_mitigation": false,
"defense_posture": null,
"protection_allowlist": null,
"edges": [],
"protection_rules": [],
"append_trailing_slash": false,
"exceed_no_rtbh": false,
"exceed_no_flowspec": false,
"origin_kind": "dynamic",
"alias_limit": 20,
"serving_edges": [
"shared-1"
],
"posture_drift": [],
"baseline": null
}
Answer 200: replace_origins
{
"fqdn": "a.example.com",
"tenant": "tenant-a",
"enabled": true,
"mode": "https",
"ports": [
443
],
"udp_ports": [],
"created": "2026-10-01T00:00:00+00:00",
"created_by": "",
"account_id": "acc-a",
"network": {
"vips": [
{
"anycast_ipv4": "203.0.113.1",
"anycast_ipv6": "2001:db8:a::1"
}
],
"pool": null,
"edge_hostname": "a-example-com.edge.protect.zeroservices.eu"
},
"network_protection": {
"enabled": true
},
"backend": {
"algorithm": "roundrobin",
"health_check": {
"method": "GET",
"path": "/",
"host": null,
"interval": "10s",
"rise": 2,
"fall": 3,
"expect": null
}
},
"origins": [
{
"name": "origin-1",
"address": "192.0.2.10",
"port": 443,
"tls": true,
"ssl_verify": true,
"weight": 100,
"maxconn": 100
}
],
"waf": {
"enabled": true,
"fail_open": true,
"mode": "On",
"paranoia_level": 1,
"inbound_threshold": 5,
"disabled_rules": []
},
"rate_limiting": {
"challenge_req_per_10s": 200,
"req_per_10s": 800,
"conn_per_10s": 50,
"conn_drop_per_10s": 500,
"path_default": 1000,
"path_aggregate_per_10s": 5000,
"geo_tcp_reject_sess_rate": 200,
"ja4_enabled": false,
"ja4_req_per_10s": 100,
"ja4_action": "challenge"
},
"pow": {
"enabled": true,
"difficulty": 18,
"cookie_ttl": 86400,
"always_challenge": false,
"exempt_crawlers": true,
"bind_ip": true,
"botcheck_per_10s": 1000
},
"admission": {
"enabled": false,
"credit_required": 5,
"credit_window": "60s",
"known_ttl": "24h",
"unknown_conn_rate": 1500,
"table_size": "1m"
},
"ssl": {
"mode": "acme",
"acme_provider": "zerossl",
"acme_email": null,
"https_redirect": false,
"cert_status": "none",
"cert_issued": null,
"cert_expires": null,
"cert_last_renewal": null,
"days_remaining": null
},
"aliases": [
{
"fqdn": "www.a.example.com",
"ssl": {
"mode": "acme",
"acme_provider": "zerossl",
"acme_email": null,
"https_redirect": false,
"cert_status": "none",
"cert_issued": null,
"cert_expires": null,
"cert_last_renewal": null,
"days_remaining": null
}
}
],
"cache": {
"enabled": true
},
"slow_http": {
"enabled": true,
"timeout_http_request": "5s",
"timeout_client_fin": "5s"
},
"tcp_timeout": {
"client": "30s",
"server": "30s",
"tunnel": "30s"
},
"udp_timeout": {
"idle": "300s",
"unreplied": "30s",
"unreplied_pkts": 32,
"src_pps": 0
},
"sni_policy": "allowlist",
"proxy_protocol": false,
"tls_termination": false,
"priority": {
"enabled": true,
"session_signal": []
},
"response_headers": {
"hsts": {
"enabled": false,
"max_age": 31536000,
"include_subdomains": false,
"preload": false
},
"cors": {
"enabled": false,
"rules": []
},
"x_frame_options": null,
"x_content_type_options": false,
"referrer_policy": null
},
"content_negotiation": {
"enabled": false,
"encodings": [
"zstd",
"gzip"
],
"file_types": [
"css",
"js",
"html"
]
},
"logging": {
"enabled": false
},
"threat_intel": {
"mode": "log",
"abuse": "log",
"tor": "log"
},
"redirects": [],
"origin_host": null,
"origin_sni": null,
"maintenance_mode": false,
"auto_mitigation": false,
"defense_posture": null,
"protection_allowlist": null,
"edges": [],
"protection_rules": [],
"append_trailing_slash": false,
"exceed_no_rtbh": false,
"exceed_no_flowspec": false,
"origin_kind": "dynamic",
"alias_limit": 20,
"serving_edges": [
"shared-1"
],
"posture_drift": [],
"baseline": null
}
Fields¶
Part of the service: protection_rules and protection_allowlist, sent with
PATCH /services/{fqdn}. A list is replaced as a whole: read the service, change the list,
send it back complete.
ProtectionRule¶
| Field | Type | Notes |
|---|---|---|
name |
string | may be null; Letters, digits, dot, dash and underscore, up to 40 characters, unique in the vService; logged as pr_hit= |
match_mode |
string | one of and, or; default "and"; Flat match only: and (every key must match) or or (one key is enough). The server stores the conditions as a group (any_of or all_of) and returns and here |
negate |
boolean | default false; Flat match only: the rule matches every request the match does not; rate is not inverted. The server stores it as not on the group and returns false here |
match |
MatchNode |
Which requests the rule matches |
rate |
RuleRate |
may be null; Extra condition: the source's request rate |
action (required) |
string | one of allow, skip, deny, challenge, block, drop; allow: exempt from the rules below, the blocklists, threat intelligence, the rate limits and proof-of-work; the WAF still inspects. skip: the same, for the stages in skip only. deny: 403. block: 429. drop: close the connection without an answer. challenge: proof-of-work unless solved, only with pow.enabled; the checks after the rules still run first. On tcp and anyconnect vServices deny and block refuse the connection; skip and challenge do not exist there |
skip |
list of string | may be null; skip rules only: the stages to skip. Omit it to skip all five, like allow |
enabled |
boolean | default true; false keeps the rule without applying it |
bind_to |
string | may be null; Apply the rule only to requests for this hostname: the FQDN or one of its aliases. Null: every hostname. HTTPS only |
_meta |
object | may be null; Set by ZERO-PROTECT on rules it adds itself: source (posture, allowlist or emergency) and for time-limited rules expires_at, after which the edges stop applying the rule. Send it back unchanged |
action |
Effect |
|---|---|
allow |
exempt from every later stage: later rules, blocklists, threat intelligence, all rate limits, proof-of-work. Only the WAF still inspects; slow-HTTP timeouts apply before any rule |
skip |
exempt only from the stages listed in skip; without skip all five, like allow |
deny |
403 with your branded error page |
block |
429 |
drop |
connection dropped without an answer |
challenge |
proof-of-work challenge |
MatchNode¶
| Field | Type | Notes |
|---|---|---|
geo |
list of string | at most 500; may be null; Countries of the source address: ISO 3166-1 alpha-2 codes such as DE, or group ids from GET /reference/geo-groups |
asn |
list of integer | at most 500; may be null; AS numbers of the source address |
ip |
list of string | at most 500; may be null; Source addresses or CIDR ranges, IPv4 or IPv6 |
path |
list of string | at most 100; may be null; Request paths without query string, after the edge normalised them, case-sensitive: exact, or a prefix when the entry ends in *. # is refused |
path_class |
string | one of auth, admin; may be null; Built-in path group: auth (login paths such as /wp-login.php or /login) or admin (admin areas such as /wp-admin/) |
crawler |
list of string | at most 100; may be null; Crawler names from GET /reference/crawlers, recognised by the crawler's published source networks, not by its User-Agent |
fingerprint |
list of string | at most 500; may be null; JA4 TLS fingerprints of the client, exact |
user_agent |
list of string | at most 100; may be null; Text anywhere in the User-Agent header, case-insensitive; backslashes and double quotes are dropped |
referer_host |
list of string | at most 100; may be null; Host of the Referer header, case-insensitive: a hostname, *.example.com for its subdomains (not example.com itself), or none for a request without Referer |
method |
list of string | at most 7; may be null; HTTP methods. HTTPS only |
content_type |
list of string | at most 20; may be null; Media types the Content-Type header starts with, case-insensitive: lower-case type/subtype without parameters, such as multipart/form-data. HTTPS only |
any_of |
list of MatchNode |
at most 20; may be null; Group: matches when at least one child node matches. 1 to 20 children, groups at most 2 levels deep; besides not the node holds nothing else |
all_of |
list of MatchNode |
at most 20; may be null; Group: matches when every child node matches. 1 to 20 children, groups at most 2 levels deep; besides not the node holds nothing else |
not |
boolean | may be null; Inverts this node. Only in a match with any_of or all_of; a flat match uses the rule's negate |
| Key | Matches |
|---|---|
path |
request path; a trailing * makes a prefix; # is rejected |
path_class |
a built-in path group, no list of paths needed |
geo |
country of the source address (DE; stored in capitals), or a group written out (Europe, European-Union, Africa) |
ip |
address or CIDR |
asn |
AS number of the source network; one of GET /reference/asns, others are refused |
fingerprint |
JA4 fingerprint of the client's TLS stack |
user_agent |
text anywhere in User-Agent, case-insensitive |
crawler |
a known crawler (google, bing, see reference data) by its source networks, not by its header |
referer_host |
host of Referer; *.example.com covers subdomains, none a request without one |
method |
HTTP method: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS; HTTPS services only |
content_type |
media type the Content-Type header starts with, lower-case type/subtype without parameters (multipart/form-data); HTTPS services only |
any_of, all_of |
group: at least one, or every, child node matches; up to two levels |
not |
inverts this node |
RuleRate¶
| Field | Type | Notes |
|---|---|---|
threshold (required) |
integer | 1–; Requests of the source to the vService within window, all paths together, above which the rule fires |
window (required) |
string | one of 10s, 30s, 1m, 100s, 5m, 10m; Length of the counting window |
ProtectionAllowlist¶
| Field | Type | Notes |
|---|---|---|
countries |
list of string | at most 500; Countries of the source, ISO 3166-1 alpha-2 |
asns |
list of integer | at most 500; AS numbers of the source |
ips |
list of string | Addresses or ranges, up to 128, none broader than /16 (IPv4) or /32 (IPv6). They are also exempt from the layer 3/4 limits at the edge |
paths |
list of string | at most 500; Request paths: exact, or a prefix when the entry ends in * |
Example¶
Append a rule to the existing list. Deny one network:
RULE='{"name": "deny-as64496", "match": {"asn": [64496]}, "action": "deny"}'
Or answer 429 to requests for /search from a source that sent more than 50 requests, to any path, in 10 s:
RULE='{"name": "search-flood", "match": {"path": ["/search*"]},
"rate": {"threshold": 50, "window": "10s"}, "action": "block"}'
curl -s -H "Authorization: Bearer $ZP_KEY" $API/services/www.example.com \
| jq --argjson r "$RULE" '{protection_rules: (.protection_rules + [$r])}' \
| curl -s -X PATCH -H "Authorization: Bearer $ZP_KEY" -H 'Content-Type: application/json' \
$API/services/www.example.com -d @-
Behaviour¶
- Rules run top to bottom, and between rules it is AND: each
denyends the request on its own, so a chain of negated deny rules narrows, it never widens. - Inside a rule, entries of one key are OR-ed and keys combine by
match_mode;negateinverts the whole match. For "(A or B) and C" useany_of/all_ofwithnot; such a rule carries nomatch_modeornegate. - The API stores every rule as a group and returns it that way; a flat rule stays valid input. TCP and AnyConnect services take only rules a flat match can express.
rateis an extra condition per source on top of the match,negateincluded: the rule fires only when the source sent more thanthresholdrequests withinwindow. All requests of the source count, not only those the rule matches.- Keep
allownarrow, an address together with the path it needs, and never a whole country or consumer network. Preferskipwith the stages to bypass (HTTPS services only; an empty list is rejected). Both must come before the rules they bypass. name: letters, digits,.,-,_, up to 40 characters, unique per service; logged aspr_hit=<name>-<action>. Without one the rule isrule<position>.bind_tolimits a rule to one hostname of the service's aliases.- Allowlist entries become
allowrules at the top of the list, and automatic mitigation never acts against them. Up to 200 entries, of which 128 addresses or ranges, none broader than/16(IPv4) or/32(IPv6). - Up to 100 of your own rules per service; posture and allowlist rules do not count. See Limits.
In the portal: the rule editor of your service; drag and drop sets the order.