# Firewall rule model

> Repo-backed shape of firewallEnabled, managedRules, rules, conditionGroup, mitigate actions, and ips entries sent to Vercel.

- 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: "Firewall rule model"
description: "Repo-backed shape of firewallEnabled, managedRules, rules, conditionGroup, mitigate actions, and ips entries sent to Vercel."
---

`FirewallConfig` is the JSON object stored in the desired file (default `firewall.config.json`) and sent as the body of `PUT /v1/security/firewall/config`. `vwaffle init` writes a starter of that object. `vwaffle apply --yes` interpolates string placeholders, then `putConfig` JSON-serializes the same object. There is no runtime schema validator: `loadDesiredConfig` parses JSON and types it as `FirewallConfig`. Every modeled interface also accepts extra keys (`[key: string]: unknown`), so fields returned by a `pull` survive a later `apply`.

## Payload ownership

```mermaid
classDiagram
    class FirewallConfig {
        +boolean firewallEnabled
        +Record managedRules
        +Record crs
        +CustomRule[] rules
        +IpRule[] ips
    }
    class CustomRule {
        +string id
        +string name
        +string description
        +boolean active
        +ConditionGroup[] conditionGroup
    }
    class ConditionGroup {
        +RuleCondition[] conditions
    }
    class RuleCondition {
        +string type
        +string op
        +boolean neg
        +string key
        +value
    }
    class MitigateAction {
        +string action
        +object rateLimit
        +object redirect
        +string actionDuration
    }
    class ManagedRule {
        +boolean active
        +string action
    }
    class IpRule {
        +string id
        +string ip
        +string hostname
        +string action
        +string notes
    }
    FirewallConfig --> ManagedRule : managedRules, crs
    FirewallConfig --> CustomRule : rules
    FirewallConfig --> IpRule : ips
    CustomRule --> ConditionGroup : conditionGroup
    CustomRule --> MitigateAction : action.mitigate
    ConditionGroup --> RuleCondition : conditions
```

`getActiveConfig` reads `GET /v1/security/firewall/config/active` and unwraps `response.active` when that wrapper is present. The desired file is the inner object, not the wrapper.

:::endpoint PUT /v1/security/firewall/config
Send the interpolated `FirewallConfig` as the JSON body. Query params come from `resolveContext`, not from the config file.

**Base URL:** `VERCEL_API_URL` if set, otherwise `https://api.vercel.com`.

**Query**

| Name | Required | Source |
| --- | --- | --- |
| `projectId` | yes | `--project`, `VERCEL_PROJECT_ID`, or `.vercel/project.json` `projectId` |
| `teamId` | no | `--team`, `VERCEL_TEAM_ID`, or `.vercel/project.json` `orgId` |

**Headers:** `Authorization: Bearer $VERCEL_TOKEN`, `Content-Type: application/json`.

**Body:** the desired `FirewallConfig` after `${VAR_NAME}` interpolation. `apply --dry-run` prints that body with secret values replaced by `[REDACTED]` and does not call Vercel.
:::

## Top-level `FirewallConfig`

<ParamField body="firewallEnabled" type="boolean">
When present, included in the PUT body. The `init` starter sets `true`.
</ParamField>

<ParamField body="managedRules" type="Record<string, ManagedRule>">
Named managed-rule entries. The starter has a single `owasp` key.
</ParamField>

<ParamField body="crs" type="Record<string, ManagedRule>">
Typed as the same `ManagedRule` map as `managedRules`. Not written by `init`. Survives `pull` / `apply` because extra and typed keys are passed through.
</ParamField>

<ParamField body="rules" type="CustomRule[]">
Custom WAF rules. This is the only array `removeRulesWithMissingEnv` can drop entries from when a `${VAR_NAME}` is unset on a non-strict load.
</ParamField>

<ParamField body="ips" type="IpRule[]">
IP / CIDR entries. The starter writes `[]`. Missing-env filtering does not remove `ips` rows.
</ParamField>

All top-level keys are optional in the TypeScript type. Additional keys on the object are preserved.

## Starter object

`vwaffle init` refuses to overwrite an existing file, then writes this object with tab indentation:

<RequestExample>
```json
{
	"firewallEnabled": true,
	"managedRules": {
		"owasp": { "active": false }
	},
	"rules": [
		{
			"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" } }
		}
	],
	"ips": []
}
```
</RequestExample>

A typical authored file adds IP rows and `${VAR_NAME}` placeholders in string fields:

```json
{
	"firewallEnabled": true,
	"managedRules": {
		"owasp": { "active": false }
	},
	"rules": [
		{
			"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" } }
		},
		{
			"name": "Bypass for internal service",
			"active": true,
			"conditionGroup": [
				{
					"conditions": [
						{
							"type": "header",
							"op": "eq",
							"key": "x-internal-token",
							"value": "${INTERNAL_TOKEN}"
						}
					]
				}
			],
			"action": { "mitigate": { "action": "bypass" } }
		}
	],
	"ips": [
		{ "ip": "203.0.113.0/24", "hostname": "*", "action": "deny", "notes": "abuse" }
	]
}
```

## Custom rules

Each `rules[]` entry is a `CustomRule`.

<ParamField body="id" type="string">
Optional. Usually present on a pulled live config. Sent back unchanged if you leave it in the file.
</ParamField>

<ParamField body="name" type="string" required>
Rule name. Used in CLI interpolation warnings only as part of the serialized rule, not as a lookup key.
</ParamField>

<ParamField body="description" type="string">
Optional human-readable note. Included in the PUT body when set.
</ParamField>

<ParamField body="active" type="boolean" required>
Typed as required. Runtime JSON is not checked; a missing `active` is still sent if you omit it.
</ParamField>

<ParamField body="conditionGroup" type="ConditionGroup[]" required>
Array of groups. Each group has a `conditions` array of `RuleCondition` objects. Groups are AND/OR semantics as defined by the Vercel API, not reinterpreted by vwaffle.
</ParamField>

<ParamField body="action" type="object" required>
Must contain `mitigate` (`MitigateAction`). Extra keys on `action` are preserved.
</ParamField>

### `conditionGroup` and `RuleCondition`

<ParamField body="conditions" type="RuleCondition[]" required>
One or more matchers inside a group.
</ParamField>

<ParamField body="type" type="string" required>
Matcher kind. The repo only authors `path` and `header` examples. Any string is accepted.
</ParamField>

<ParamField body="op" type="string" required>
Operator. Starter / README examples: `pre` (prefix) and `eq`. Any string is accepted.
</ParamField>

<ParamField body="neg" type="boolean">
Optional negation flag.
</ParamField>

<ParamField body="key" type="string">
Optional. Used for header matchers (`x-internal-token` in the README example).
</ParamField>

<ParamField body="value" type="string | number | string[]">
Match value. String values may contain `${VAR_NAME}` placeholders.
</ParamField>

vwaffle does not enumerate Vercel condition types or operators. Unknown `type` / `op` values are forwarded in the PUT body.

### `action.mitigate`

<ParamField body="action" type="'log' | 'challenge' | 'deny' | 'bypass' | 'rate_limit' | 'redirect' | string" required>
Mitigation. The union is open-ended: any other string is still typed and sent.
</ParamField>

<ParamField body="rateLimit" type="{ algo: string; window: number; limit: number; keys: string[]; action?: string } | null">
Optional rate-limit block. Not present in the starter.
</ParamField>

<ParamField body="redirect" type="{ location: string; permanent: boolean } | null">
Optional redirect target. Not present in the starter.
</ParamField>

<ParamField body="actionDuration" type="string | null">
Optional duration string. Not present in the starter.
</ParamField>

Repo-authored examples use only `{ "mitigate": { "action": "deny" } }` and `{ "mitigate": { "action": "bypass" } }`.

## Managed rules

`managedRules` and `crs` are `Record<string, ManagedRule>`.

<ParamField body="active" type="boolean" required>
Whether that named managed rule is on.
</ParamField>

<ParamField body="action" type="string">
Optional action override for that managed rule.
</ParamField>

The only named entry vwaffle writes is `owasp: { "active": false }`. Other keys from a `pull` are kept because the map is a free record plus an index signature.

## IP entries

Each `ips[]` row is an `IpRule`.

<ParamField body="id" type="string">
Optional. Typically appears after `pull`.
</ParamField>

<ParamField body="ip" type="string" required>
Address or CIDR. README example: `203.0.113.0/24`.
</ParamField>

<ParamField body="hostname" type="string" required>
Host scope. README example: `*`.
</ParamField>

<ParamField body="action" type="'deny' | 'challenge' | 'log' | 'bypass' | string" required>
IP action. Open-ended string fallback, same as custom-rule mitigations except `rate_limit` / `redirect` are not in this union.
</ParamField>

<ParamField body="notes" type="string">
Optional note. README example: `abuse`.
</ParamField>

`removeRulesWithMissingEnv` never filters `ips`. An IP row whose string fields still contain `${UNSET}` is left in the object on `plan` and `apply --dry-run`.

## Interpolation and which rows drop

`interpolate` walks every string in the JSON tree (`${[A-Za-z_][A-Za-z0-9_]*}`). Replaced values are recorded in a secrets map and later printed as `[REDACTED]`.

| Command | Missing `${VAR_NAME}` | Effect on the model |
| --- | --- | --- |
| `plan`, `plan --check` | warn | Drop only `rules[]` entries whose serialized JSON still contains `${NAME}` |
| `apply --dry-run` | warn (not strict) | Same rule-drop as `plan`; print the resulting object |
| `apply --yes` | throw | No PUT. Error: `missing environment variables: NAME ($.path), ...` |

Unset variables in `managedRules`, `crs`, `ips`, or top-level strings are not stripped. On `plan` they remain as the literal `${NAME}` in the desired object used for the diff.

<Warning>
`loadDesiredConfig` does not reject unknown fields, missing TypeScript-required keys, or invalid `type` / `op` / `action` strings. Invalid shapes fail at the Vercel API (`Vercel API <status> ...`), not in local parsing.
</Warning>

## What is not modeled

- No local catalog of Vercel condition types, OWASP CRS rule IDs, or bot-protection presets beyond the typed maps.
- No transformation between dashboard UI names and JSON keys. `pull` is the supported way to capture the live shape.
- No per-field defaults except what `init` writes. Omitting `firewallEnabled`, `managedRules`, `rules`, or `ips` sends a body without those keys.

## Next

<CardGroup>
<Card title="Author firewall rules" href="/author-firewall-rules">
Edit custom rules, OWASP managed rules, IP denylists, and placeholders.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Default file path, PUT body contract, and `loadDesiredConfig` interpolation rules.
</Card>
<Card title="Desired vs live config" href="/desired-vs-live-config">
How the local file relates to the object `getActiveConfig` unwraps from `/active`.
</Card>
<Card title="Secret interpolation" href="/secret-interpolation">
When missing variables drop rules versus fail `apply`.
</Card>
<Card title="Scaffold and pull a config" href="/scaffold-and-pull">
Write the starter object with `init`, or capture the live object with `pull`.
</Card>
<Card title="Firewall API client" href="/firewall-api-client">
`putConfig` and `getActiveConfig` request paths used to read and write this object.
</Card>
</CardGroup>
