CfBouncer

Automatically syncs a Cloudflare WAF block rule from your Phoenix router. Any request to a path not defined in your routes, sockets, or static files gets blocked.

Keeps your WAF in sync with your code - run it in your deploy script and forget about it.

How it works

  1. Reads all route prefixes from your Phoenix router
  2. Reads WebSocket paths from your endpoint
  3. Reads static file paths from your web module
  4. Builds a Cloudflare WAF expression that blocks everything else
  5. Pushes the rule to Cloudflare (only if it changed)

Installation

Add cf_bouncer to your dependencies:

def deps do
  [
    {:cf_bouncer, "~> 0.2"}
  ]
end

Configuration

# config/config.exs
config :cf_bouncer,
  router: MyAppWeb.Router,
  endpoint: MyAppWeb.Endpoint,
  static_module: MyAppWeb,
  rule_description: "[CfBouncer] Block non-allowlisted paths"

# config/runtime.exs
config :cf_bouncer,
  zone_id: System.get_env("CLOUDFLARE_ZONE_ID"),
  api_token: System.get_env("CLOUDFLARE_API_TOKEN")

Options

Key Required Description
router yes Your Phoenix router module (e.g. MyAppWeb.Router)
endpoint yes Your Phoenix endpoint module (e.g. MyAppWeb.Endpoint)
static_module yes Module with static_paths/0 (e.g. MyAppWeb)
rule_description yes Description used to identify the rule in Cloudflare
zone_id yes Cloudflare Zone ID (see below)
api_token yes Cloudflare API token with Zone WAF and Firewall Services edit permissions
extra_paths no Additional path prefixes to allow (e.g. ["/cf-fonts/", "/webhooks/"])

Cloudflare Zone ID

Found in the Cloudflare dashboard: select your domain, then look in the right sidebar on the Overview page under API > Zone ID.

Cloudflare API token

Create at https://dash.cloudflare.com/profile/api-tokens. Use the "Edit zone WAF" template, or create a custom token with these permissions scoped to your zone:

Usage

Mix task

Preview the generated WAF expression:

mix cf_bouncer.sync --dry-run

Push to Cloudflare:

mix cf_bouncer.sync

Force push even if unchanged:

mix cf_bouncer.sync --force

The rule is only updated if the expression has changed. Use --force to push regardless.

In a deploy script

echo "Updating Cloudflare WAF..."
mix cf_bouncer.sync

echo "Syncing files..."
# ... rest of deploy

Programmatic usage

You can also call the library functions directly by passing config as a keyword list:

opts = [
  router: MyAppWeb.Router,
  endpoint: MyAppWeb.Endpoint,
  static_module: MyAppWeb,
  extra_paths: ["/cf-fonts/"],
  zone_id: "your-zone-id",
  api_token: "your-api-token",
  rule_description: "[CfBouncer] Block non-allowlisted paths"
]

# Just build the expression
CfBouncer.build_expression(opts)

# Build and sync to Cloudflare
CfBouncer.sync(opts)

Generated expression

The generated expression looks like:

not (
  http.request.uri.path eq "/"
  or starts_with(http.request.uri.path, "/auth")
  or starts_with(http.request.uri.path, "/users")
  or starts_with(http.request.uri.path, "/live")
  or starts_with(http.request.uri.path, "/assets/")
  or starts_with(http.request.uri.path, "/images/")
  or starts_with(http.request.uri.path, "/robots.txt")
  or starts_with(http.request.uri.path, "/cf-fonts/")
)

Static paths containing a . (like robots.txt) are treated as files. Paths without a . (like assets) are treated as directories and get a trailing /.