# Author firewall rules

> Edit the desired JSON for custom rules, OWASP managed rules, IP denylists, and environment placeholders without committing secrets.

- Repository: jaredpalmer/vwaffle
- GitHub: https://github.com/jaredpalmer/vwaffle
- Human docs: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581
- Complete Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/llms-full.txt

## Source Files

- `src/types.ts`
- `src/config.ts`
- `src/config.test.ts`
- `README.md`

---

---
title: "Author firewall rules"
description: "Edit the desired JSON for custom rules, OWASP managed rules, IP denylists, and environment placeholders without committing secrets."
---

`firewall.config.json` is the desired Vercel Firewall body `vwaffle` loads on `plan` and `apply`. `loadDesiredConfig` parses that file, expands `${VAR_NAME}` placeholders in string values from the process environment, then either diffs the result against live or PUTs it to `/v1/security/firewall/config`. The file is valid JSON only — no comments, no trailing commas — and is the same object shape `FirewallConfig` describes: `firewallEnabled`, `managedRules`, optional `crs`, `rules`, and `ips`.

```text
firewall.config.json          # default path; override with -c / --config
├── firewallEnabled           # boolean
├── managedRules              # map; starter key is owasp
├── crs                       # optional map; same ManagedRule values as managedRules
├── rules[]                   # custom CustomRule objects (placeholders live here)
└── ips[]                     # IpRule denylist / challenge / log / bypass entries
```

<Note>
`.gitignore` ignores `.env` and `.vercel/` but not `firewall.config.json`. Commit the desired file. Keep secret values in the environment, not in the JSON.
</Note>

## Start from a file

`vwaffle` does not generate rules from flags. Author by editing JSON.

<Steps>
<Step title="Create or fetch the file">
`vwaffle init` writes the starter object to `firewall.config.json` (or `--config FILE`) and refuses to overwrite an existing path. `vwaffle pull --output firewall.config.json` writes the live `active` config, including any `id` fields Vercel already assigned.
</Step>
<Step title="Edit JSON, not secrets">
Add or change `rules`, `managedRules`, and `ips`. Put tokens and other secret string values in `${VAR_NAME}` placeholders. Extra keys Vercel returns survive `interpolate` and are sent back on apply.
</Step>
<Step title="Load and inspect">
Run `vwaffle plan` to diff live versus the interpolated desired file. Run `vwaffle apply --dry-run` to print the interpolated payload with secret values replaced by `[REDACTED]`.
</Step>
</Steps>

<Warning>
`vwaffle plan` interpolates first, then diffs. It does **not** redact secret values. `apply --yes` redacts the printed diff via `redactText`. `apply --dry-run` redacts the printed payload via `redacted`.
</Warning>

## Top-level keys

`loadDesiredConfig` does not validate this schema. Missing required fields or invalid condition types fail at the Vercel API on `apply`, not at parse time.

<ParamField body="firewallEnabled" type="boolean">
Starter and README set `true`. Optional on `FirewallConfig`.
</ParamField>

<ParamField body="managedRules" type="Record<string, ManagedRule>">
Named managed-rule entries. Each value needs `active` and may include `action` plus extra keys.
</ParamField>

<ParamField body="crs" type="Record<string, ManagedRule>">
Same value shape as `managedRules`. Present on the TypeScript type; the starter file omits it. Keep it if `pull` returned it.
</ParamField>

<ParamField body="rules" type="CustomRule[]">
Custom rules. This is the only array `removeRulesWithMissingEnv` can drop when a referenced variable is unset and load is not strict.
</ParamField>

<ParamField body="ips" type="IpRule[]">
Per-IP (or CIDR) entries. Starter writes `[]`. Placeholders here are interpolated but never dropped by `removeRulesWithMissingEnv`.
</ParamField>

<ParamField body="id" type="string">
Optional on `CustomRule` and `IpRule`. Pulled live configs often include ids; keep them when you started from `pull`.
</ParamField>

## Custom rules

Each `rules[]` item is a `CustomRule`:

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | `string` | yes | Display name |
| `active` | `boolean` | yes | Inactive rules still travel in the desired body |
| `conditionGroup` | `ConditionGroup[]` | yes | Each group has `conditions: RuleCondition[]` |
| `action` | `{ mitigate: MitigateAction }` | yes | Mitigate payload is required by the type |
| `description` | `string` | no | Starter uses this for the path-deny rule |
| `id` | `string` | no | Present after `pull` |

A `RuleCondition` has `type`, `op`, optional `neg`, optional `key`, and optional `value` (`string | number | string[]`). The repo types those as open `string`s. The starter and README only demonstrate:

- `type: "path"`, `op: "pre"`, `value: "/.git"`
- `type: "header"`, `op: "eq"`, `key: "x-internal-token"`, `value: "${INTERNAL_TOKEN}"`

`MitigateAction.action` is typed as `'log' | 'challenge' | 'deny' | 'bypass' | 'rate_limit' | 'redirect' | string`. Optional companions:

| Field | Shape |
| --- | --- |
| `rateLimit` | `{ algo, window, limit, keys, action? } \| null` |
| `redirect` | `{ location, permanent } \| null` |
| `actionDuration` | `string \| null` |

```json
{
	"name": "Block sensitive paths",
	"description": "Deny requests to paths that should never be publicly reachable.",
	"active": true,
	"conditionGroup": [
		{ "conditions": [{ "type": "path", "op": "pre", "value": "/.git" }] }
	],
	"action": { "mitigate": { "action": "deny" } }
}
```

```json
{
	"name": "Bypass for internal service",
	"active": true,
	"conditionGroup": [
		{
			"conditions": [
				{
					"type": "header",
					"op": "eq",
					"key": "x-internal-token",
					"value": "${INTERNAL_TOKEN}"
				}
			]
		}
	],
	"action": { "mitigate": { "action": "bypass" } }
}
```

## OWASP managed rules

`vwaffle init` writes a single managed-rule entry and leaves OWASP off:

```json
{
	"firewallEnabled": true,
	"managedRules": {
		"owasp": { "active": false }
	}
}
```

`ManagedRule` requires `active` and allows optional `action` plus additional keys. The CLI does not expand OWASP CRS categories or generate managed-rule IDs. To turn OWASP on, set `managedRules.owasp.active` to `true` (and any `action` the live API expects). If `pull` returns a richer `managedRules` or `crs` map, edit those objects in place rather than replacing them with the starter stub.

## IP entries

Each `ips[]` item is an `IpRule`:

| Field | Type | Required |
| --- | --- | --- |
| `ip` | `string` | yes — address or CIDR |
| `hostname` | `string` | yes — README uses `"*"` |
| `action` | `'deny' \| 'challenge' \| 'log' \| 'bypass' \| string` | yes |
| `notes` | `string` | no |
| `id` | `string` | no |

```json
{
	"ips": [
		{ "ip": "203.0.113.0/24", "hostname": "*", "action": "deny", "notes": "abuse" }
	]
}
```

Placeholders in `ip`, `hostname`, or `notes` interpolate like any other string. Unset variables in `ips` do **not** drop the entry on `plan`; they fail `apply --yes` because `loadDesiredConfig` still records them in `missing`.

## Environment placeholders

`interpolate` walks every string in the desired object (nested objects and arrays included) and replaces `${VAR_NAME}` when `VAR_NAME` matches `[A-Za-z_][A-Za-z0-9_]*`. Object keys, numbers, and booleans are not expanded. `${ VAR }` (spaces) and `$VAR` do not match.

| Command | `strict` | Unset `${VAR_NAME}` |
| --- | --- | --- |
| `vwaffle plan` | `false` | Warns `vwaffle: warning: skipping rules that reference unset variables: NAME ($.path)`. Drops matching `rules[]` items. Leaves `ips` / `managedRules` in place. |
| `vwaffle apply --dry-run` | `false` | Same non-strict load, then prints the (possibly rule-stripped) redacted payload. No PUT. |
| `vwaffle apply --yes` | `true` | Throws `missing environment variables: NAME ($.path). Set them before applying the firewall configuration.` |

`removeRulesWithMissingEnv` stringifies each custom rule after interpolation. A missing variable is left as the literal `${NAME}` in that JSON, so the whole rule is dropped if that substring is present. A rule that never referenced the unset name is kept.

<Tip>
Export tokens in the shell or a gitignored `.env` that your process already loads. `apply --yes` requires every referenced name to be set. Local `plan` can run without every secret because those rules are skipped.
</Tip>

A resolved value is stored in the `secrets` map and later replaced with `[REDACTED]` in apply output. An empty-string env value interpolates as empty and is not redacted (`if (secret)` is false).

## Verify the authored file

<RequestExample>
```bash
# preview live vs desired (interpolated; not redacted)
vwaffle plan

# inspect the payload that would be PUT (redacted)
vwaffle apply --dry-run

# PUT after printing a redacted diff
vwaffle apply --yes
```
</RequestExample>

Expected signals:

- `plan` / `apply --yes` print `No drift detected.` when live JSON equals interpolated desired JSON.
- `apply --dry-run` prints tab-indented JSON with interpolated secret substrings replaced by `[REDACTED]`.
- `apply --yes` after a real change prints `Applied firewall configuration` and, when the API returns it, `(version N)`.
- Missing vars on apply: `vwaffle: missing environment variables: INTERNAL_TOKEN ($.rules[1].conditionGroup[0].conditions[0].value). Set them before applying the firewall configuration.`

## Authoring constraints

- Path default is `firewall.config.json` relative to `process.cwd()`. Use `-c` / `--config` for another file.
- `init` writes tab-indented JSON plus a trailing newline and exits with `Wrote FILE. Edit it, then run \`vwaffle plan\`.`
- Interpolation is one pass. A replacement that itself contains `${OTHER}` is not expanded again.
- `vwaffle` does not synthesize condition operators, OWASP rule packs, or IP lists. Those values must already be valid for `PUT /v1/security/firewall/config`.

## Next

<CardGroup>
<Card title="Firewall rule model" href="/firewall-rule-model">
Field-level `FirewallConfig`, `CustomRule`, `MitigateAction`, and `IpRule` shapes sent to Vercel.
</Card>
<Card title="Secret interpolation" href="/secret-interpolation">
How `${VAR_NAME}` expands, when missing names drop rules versus fail apply, and how `[REDACTED]` is applied.
</Card>
<Card title="Preview and apply changes" href="/preview-and-apply-changes">
Diff with `plan`, inspect with `apply --dry-run`, PUT with `apply --yes`.
</Card>
<Card title="Scaffold and pull a config" href="/scaffold-and-pull">
Write the starter file with `init`, or fetch live JSON with `pull --output`.
</Card>
</CardGroup>
