@gfargo/doorman 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,478 @@
1
+ # Rule Authoring Reference
2
+
3
+ Complete reference for creating and configuring Doorman firewall rules.
4
+
5
+ ## Rule Structure
6
+
7
+ Every rule in the `rules` array has this shape:
8
+
9
+ ```json
10
+ {
11
+ "id": "rule_descriptive_name",
12
+ "name": "Human-Readable Name",
13
+ "description": "What this rule does and why",
14
+ "active": true,
15
+ "conditionGroup": [
16
+ /* condition groups */
17
+ ],
18
+ "action": {
19
+ /* action config */
20
+ }
21
+ }
22
+ ```
23
+
24
+ ### Fields
25
+
26
+ | Field | Type | Required | Description |
27
+ | ---------------- | ------- | -------- | ------------------------------------------------------------------------------------- |
28
+ | `id` | string | No | Unique identifier. Convention: `rule_` prefix, snake_case. Auto-generated if omitted. |
29
+ | `name` | string | Yes | Display name shown in tables and logs. |
30
+ | `description` | string | No | Explains the rule's purpose. Improves health score. |
31
+ | `active` | boolean | Yes | `true` to enforce, `false` to disable without deleting. |
32
+ | `conditionGroup` | array | Yes | Array of condition groups (see below). |
33
+ | `action` | object | Yes | What to do when conditions match (see below). |
34
+
35
+ ## Condition Groups
36
+
37
+ ```json
38
+ "conditionGroup": [
39
+ {
40
+ "conditions": [ /* AND — all must match */ ]
41
+ },
42
+ {
43
+ "conditions": [ /* OR — this group is an alternative */ ]
44
+ }
45
+ ]
46
+ ```
47
+
48
+ **Logic**:
49
+
50
+ - Conditions **within** a group: AND (all must match)
51
+ - **Between** groups: OR (any group matching triggers the rule)
52
+
53
+ ### Example: Block POST to /admin OR any request to /wp-admin
54
+
55
+ ```json
56
+ "conditionGroup": [
57
+ {
58
+ "conditions": [
59
+ { "type": "path", "op": "pre", "value": "/admin" },
60
+ { "type": "method", "op": "eq", "value": "POST" }
61
+ ]
62
+ },
63
+ {
64
+ "conditions": [
65
+ { "type": "path", "op": "pre", "value": "/wp-admin" }
66
+ ]
67
+ }
68
+ ]
69
+ ```
70
+
71
+ ## Conditions
72
+
73
+ Each condition has:
74
+
75
+ ```json
76
+ {
77
+ "type": "field_type",
78
+ "op": "operator",
79
+ "value": "match_value",
80
+ "key": "header_name",
81
+ "neg": false
82
+ }
83
+ ```
84
+
85
+ ### Condition Fields
86
+
87
+ | Field | Type | Required | Description |
88
+ | ------- | ------------------- | -------- | ------------------------------------------------------------------------------------ |
89
+ | `type` | string | Yes | What to match against (see types below). |
90
+ | `op` | string | Yes | How to compare (see operators below). |
91
+ | `value` | string/number/array | Yes | Value to match. Arrays for `inc` operator. |
92
+ | `key` | string | No | Required for `header`, `query`, `cookie` types. Specifies which header/param/cookie. |
93
+ | `neg` | boolean | No | `true` to negate the condition (NOT logic). Default: `false`. |
94
+
95
+ ### Condition Types
96
+
97
+ | Type | Description | Example Value |
98
+ | -------------------- | -------------------------------- | ----------------------- |
99
+ | `path` | URL path | `"/api/users"` |
100
+ | `method` | HTTP method | `"POST"` |
101
+ | `host` | Hostname | `"example.com"` |
102
+ | `user_agent` | User-Agent header | `"Googlebot"` |
103
+ | `ip_address` | Client IP | `"192.168.1.1"` |
104
+ | `header` | HTTP header (requires `key`) | `"application/json"` |
105
+ | `query` | Query parameter (requires `key`) | `"true"` |
106
+ | `cookie` | Cookie value (requires `key`) | `"session_abc"` |
107
+ | `geo_country` | Country code (ISO 3166-1) | `"US"` or `["US","CA"]` |
108
+ | `geo_city` | City name | `"New York"` |
109
+ | `geo_continent` | Continent code | `"NA"` |
110
+ | `geo_country_region` | Region/state code | `"CA"` |
111
+ | `geo_as_number` | ASN number | `13335` |
112
+ | `scheme` | URL scheme | `"https"` |
113
+ | `protocol` | HTTP protocol version | `"HTTP/2"` |
114
+
115
+ **Vercel-only types** (not available on Cloudflare):
116
+
117
+ - `environment` — deployment environment (`"production"`, `"preview"`)
118
+ - `ja3_digest` — TLS fingerprint
119
+ - `ja4_digest` — TLS fingerprint v4
120
+ - `region` — Vercel edge region
121
+ - `rate_limit_api_id` — rate limit API identifier
122
+
123
+ ### Operators
124
+
125
+ | Operator | Name | Description | Value Type |
126
+ | -------- | ---------- | --------------------------- | ---------------------- |
127
+ | `eq` | Equals | Exact match | string/number |
128
+ | `pre` | Prefix | Starts with | string |
129
+ | `suf` | Suffix | Ends with | string |
130
+ | `sub` | Substring | Contains | string |
131
+ | `inc` | Includes | Is any of (array match) | string[] |
132
+ | `re` | Regex | Regular expression match | string (regex pattern) |
133
+ | `ex` | Exists | Field/header exists | `true` |
134
+ | `nex` | Not Exists | Field/header does not exist | `true` |
135
+
136
+ ### Using `key` for Header/Query/Cookie
137
+
138
+ ```json
139
+ { "type": "header", "op": "eq", "value": "application/json", "key": "Content-Type" }
140
+ { "type": "query", "op": "eq", "value": "true", "key": "debug" }
141
+ { "type": "cookie", "op": "ex", "value": true, "key": "session_id" }
142
+ ```
143
+
144
+ ### Using `neg` for Negation
145
+
146
+ ```json
147
+ { "type": "geo_country", "op": "inc", "value": ["US", "CA", "GB"], "neg": true }
148
+ ```
149
+
150
+ This matches requests NOT from US, CA, or GB.
151
+
152
+ ### Using `inc` with Arrays
153
+
154
+ ```json
155
+ { "type": "geo_country", "op": "inc", "value": ["CN", "RU", "KP", "IR"] }
156
+ { "type": "method", "op": "inc", "value": ["PUT", "DELETE", "PATCH"] }
157
+ ```
158
+
159
+ ## Actions
160
+
161
+ ### Deny (Block)
162
+
163
+ ```json
164
+ {
165
+ "action": {
166
+ "mitigate": {
167
+ "action": "deny"
168
+ }
169
+ }
170
+ }
171
+ ```
172
+
173
+ ### Deny with Duration
174
+
175
+ ```json
176
+ {
177
+ "action": {
178
+ "mitigate": {
179
+ "action": "deny",
180
+ "actionDuration": "1h"
181
+ }
182
+ }
183
+ }
184
+ ```
185
+
186
+ Duration formats: `"30s"`, `"5m"`, `"1h"`, `"1d"`, `"permanent"`
187
+
188
+ ### Challenge (CAPTCHA)
189
+
190
+ ```json
191
+ {
192
+ "action": {
193
+ "mitigate": {
194
+ "action": "challenge"
195
+ }
196
+ }
197
+ }
198
+ ```
199
+
200
+ ### Rate Limit
201
+
202
+ ```json
203
+ {
204
+ "action": {
205
+ "mitigate": {
206
+ "action": "rate_limit",
207
+ "rateLimit": {
208
+ "requests": 100,
209
+ "window": "60s"
210
+ }
211
+ }
212
+ }
213
+ }
214
+ ```
215
+
216
+ #### Rate Limit Fields
217
+
218
+ | Field | Type | Required | Description |
219
+ | -------------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
220
+ | `requests` | number | Yes | Max requests allowed in window. |
221
+ | `window` | string | Yes | Time window: `"10s"`, `"1m"`, `"5m"`, `"1h"` |
222
+ | `characteristics` | string[] | No | What to rate limit by. Default: `["ip.src"]`. Options: `"ip.src"`, `"http.request.uri.path"`, `"http.request.headers[\"user-agent\"]"` |
223
+ | `mitigationTimeout` | number | No | How long (seconds) to block after limit exceeded. Default: 3600. |
224
+ | `countingExpression` | string | No | Cloudflare-specific: expression for what counts toward the limit. |
225
+
226
+ ### Redirect
227
+
228
+ ```json
229
+ {
230
+ "action": {
231
+ "mitigate": {
232
+ "action": "redirect",
233
+ "redirect": {
234
+ "location": "https://example.com/blocked",
235
+ "permanent": false
236
+ }
237
+ }
238
+ }
239
+ }
240
+ ```
241
+
242
+ | Field | Type | Description |
243
+ | ----------- | ------- | ----------------------------------------- |
244
+ | `location` | string | Redirect URL (absolute or relative path). |
245
+ | `permanent` | boolean | `true` for 301, `false` for 302. |
246
+
247
+ ### Log Only
248
+
249
+ ```json
250
+ {
251
+ "action": {
252
+ "mitigate": {
253
+ "action": "log"
254
+ }
255
+ }
256
+ }
257
+ ```
258
+
259
+ ### Bypass
260
+
261
+ ```json
262
+ {
263
+ "action": {
264
+ "mitigate": {
265
+ "action": "bypass"
266
+ }
267
+ }
268
+ }
269
+ ```
270
+
271
+ ## IP Blocking Rules
272
+
273
+ IP rules go in the `ips` array, separate from `rules`:
274
+
275
+ ```json
276
+ {
277
+ "ips": [
278
+ {
279
+ "id": "ip_malicious_actor",
280
+ "ip": "192.168.1.100/32",
281
+ "hostname": "attacker.example.com",
282
+ "action": "deny",
283
+ "notes": "Blocked 2024-01-15: repeated brute force attempts"
284
+ }
285
+ ]
286
+ }
287
+ ```
288
+
289
+ ### IP Rule Fields
290
+
291
+ | Field | Type | Required | Description |
292
+ | ---------- | ------ | -------- | --------------------------------------------------- |
293
+ | `id` | string | No | Unique identifier. |
294
+ | `ip` | string | Yes | IP address or CIDR range. Use `/32` for single IPs. |
295
+ | `hostname` | string | No | Associated hostname (documentation only). |
296
+ | `action` | string | Yes | `"deny"` (only supported value currently). |
297
+ | `notes` | string | No | Why this IP was blocked. |
298
+
299
+ ### CIDR Notation
300
+
301
+ ```
302
+ 192.168.1.1/32 → single IP
303
+ 192.168.1.0/24 → 256 IPs (192.168.1.0 - 192.168.1.255)
304
+ 10.0.0.0/8 → class A range
305
+ 0.0.0.0/0 → all IPv4 (use with caution!)
306
+ ```
307
+
308
+ ## Common Patterns
309
+
310
+ ### Block multiple countries
311
+
312
+ ```json
313
+ {
314
+ "name": "OFAC Sanctions Compliance",
315
+ "conditionGroup": [
316
+ {
317
+ "conditions": [{ "type": "geo_country", "op": "inc", "value": ["CU", "IR", "KP", "SY", "RU"] }]
318
+ }
319
+ ],
320
+ "action": { "mitigate": { "action": "deny" } },
321
+ "active": true
322
+ }
323
+ ```
324
+
325
+ ### Allow only specific countries (block everyone else)
326
+
327
+ ```json
328
+ {
329
+ "name": "Allow Only US/CA/GB",
330
+ "conditionGroup": [
331
+ {
332
+ "conditions": [{ "type": "geo_country", "op": "inc", "value": ["US", "CA", "GB"], "neg": true }]
333
+ }
334
+ ],
335
+ "action": { "mitigate": { "action": "deny" } },
336
+ "active": true
337
+ }
338
+ ```
339
+
340
+ ### Rate limit API with path + method
341
+
342
+ ```json
343
+ {
344
+ "name": "Rate Limit POST to API",
345
+ "conditionGroup": [
346
+ {
347
+ "conditions": [
348
+ { "type": "path", "op": "pre", "value": "/api/" },
349
+ { "type": "method", "op": "eq", "value": "POST" }
350
+ ]
351
+ }
352
+ ],
353
+ "action": {
354
+ "mitigate": {
355
+ "action": "rate_limit",
356
+ "rateLimit": { "requests": 50, "window": "1m" },
357
+ "actionDuration": "5m"
358
+ }
359
+ },
360
+ "active": true
361
+ }
362
+ ```
363
+
364
+ ### Block by header (missing auth)
365
+
366
+ ```json
367
+ {
368
+ "name": "Block Missing API Key",
369
+ "conditionGroup": [
370
+ {
371
+ "conditions": [
372
+ { "type": "path", "op": "pre", "value": "/api/" },
373
+ { "type": "header", "op": "nex", "value": true, "key": "X-API-Key" }
374
+ ]
375
+ }
376
+ ],
377
+ "action": { "mitigate": { "action": "deny" } },
378
+ "active": true
379
+ }
380
+ ```
381
+
382
+ ### Challenge suspicious user agents
383
+
384
+ ```json
385
+ {
386
+ "name": "Challenge Suspicious Bots",
387
+ "conditionGroup": [
388
+ { "conditions": [{ "type": "user_agent", "op": "sub", "value": "curl" }] },
389
+ { "conditions": [{ "type": "user_agent", "op": "sub", "value": "wget" }] },
390
+ { "conditions": [{ "type": "user_agent", "op": "sub", "value": "python-requests" }] }
391
+ ],
392
+ "action": { "mitigate": { "action": "challenge" } },
393
+ "active": true
394
+ }
395
+ ```
396
+
397
+ ### Block WordPress attack paths (regex)
398
+
399
+ ```json
400
+ {
401
+ "name": "Block WordPress Paths",
402
+ "conditionGroup": [
403
+ {
404
+ "conditions": [{ "type": "path", "op": "re", "value": "/(wp-admin|wp-login\\.php|xmlrpc\\.php|wp-content)" }]
405
+ }
406
+ ],
407
+ "action": { "mitigate": { "action": "deny" } },
408
+ "active": true
409
+ }
410
+ ```
411
+
412
+ ### Redirect old paths
413
+
414
+ ```json
415
+ {
416
+ "name": "Redirect Legacy API",
417
+ "conditionGroup": [
418
+ {
419
+ "conditions": [{ "type": "path", "op": "pre", "value": "/v1/api/" }]
420
+ }
421
+ ],
422
+ "action": {
423
+ "mitigate": {
424
+ "action": "redirect",
425
+ "redirect": { "location": "/v2/api/", "permanent": true }
426
+ }
427
+ },
428
+ "active": true
429
+ }
430
+ ```
431
+
432
+ ## Adding Rules to Config
433
+
434
+ To add a rule, append it to the `rules` array in `.doorman.json`:
435
+
436
+ ```json
437
+ {
438
+ "rules": [
439
+ /* existing rules... */
440
+ {
441
+ "name": "New Rule",
442
+ "active": true,
443
+ "conditionGroup": [{ "conditions": [{ "type": "path", "op": "eq", "value": "/blocked" }] }],
444
+ "action": { "mitigate": { "action": "deny" } }
445
+ }
446
+ ]
447
+ }
448
+ ```
449
+
450
+ Then deploy:
451
+
452
+ ```bash
453
+ doorman validate # Check syntax
454
+ doorman diff # Preview changes
455
+ doorman sync # Deploy to provider
456
+ ```
457
+
458
+ ## Provider Differences
459
+
460
+ | Feature | Vercel | Cloudflare | Notes |
461
+ | ------------------------- | ---------------- | ---------------------- | ------------------------------------- |
462
+ | `re` operator | ✅ All plans | ⚠️ Enterprise only | Use `sub`/`pre`/`suf` as alternatives |
463
+ | `environment` type | ✅ | ❌ | Vercel-specific |
464
+ | `ja3_digest`/`ja4_digest` | ✅ | ❌ | Vercel-specific |
465
+ | IP Lists (bulk) | Individual rules | ✅ Lists API | Cloudflare needs `accountId` |
466
+ | Max rules | ~100 | 5-125 (plan dependent) | Free: 5, Pro: 20, Business: 100 |
467
+
468
+ ## Health Score Tips
469
+
470
+ To maximize your configuration health score:
471
+
472
+ - Add `description` to every rule
473
+ - Use `id` fields with `rule_` prefix and snake_case
474
+ - Avoid regex when simpler operators work
475
+ - Remove disabled rules you no longer need
476
+ - Include rate limiting for API endpoints
477
+ - Include bot protection rules
478
+ - Use IP blocking for known threats