Firewall

Peer allow-lists, trusted proxies and client IPs, and per-IP and per-instance rate limits for a Multisite, with or without Edge. Every request is checked before an instance serves it or it's forwarded to another edge.

Install · Configure · Use · Integrations · Example

Install

The firewall is built into pocketone. It turns on in host mode (a provider in pocketone.yaml) when the file has a firewall or rate_limits section; there's nothing else to install.

In your own Go build, importing the package registers it with the host, with the same rule:

import _ "github.com/pockethost/pocketone/ext/multisite-firewall"

Or build one in Go and add it to the host's gates, as the example does.

A single app doesn't need it: stock PocketBase has its own per-IP rate limits and trusted proxies.

Configure

Two sections, both optional; either one turns the firewall on (keys below).

firewall:
  allow_cidrs: []                 # peers allowed to connect (empty = everyone)
  trusted_proxies: []             # peers whose client-IP headers are believed
  client_ip_headers: [CF-Connecting-IP, True-Client-IP, X-Forwarded-For, X-Real-IP]
  trusted_clients: []             # client IPs that get the trusted rate limits on every instance
  trusted_client_headers: [X-Client-IP] # a trusted client names its end user: each user gets their own limits
  rate_limit_header_prefix: X-RateLimit
rate_limits:                      # defaults for every instance
  ip_hourly: 1000
  instance_hourly: 10000
  ip_concurrent: 15
  instance_concurrent: 250
  file_weight: 0.1                # a /api/files request costs a tenth
  trusted: { ip_hourly: 5000, instance_hourly: 20000, ip_concurrent: 50, instance_concurrent: 250 }
  # off: true                     # no rate limiting

The values shown are the defaults. In dev mode (instances: { dev: true }) rate limiting is off unless the file has a rate_limits section. A trusted limit is never lower than the normal one.

Each instance can set its own limits and trusted IPs in its extensions.firewall settings; missing fields keep the defaults:

instances:
  - id: api
    hosts: [api.example.com]
    extensions:
      firewall: { rate_limits: { ip_hourly: 5000, instance_concurrent: 500 }, trusted_ips: [203.0.113.0/24] }

pocketone.yaml reference

firewall:

Peers allowed to connect, trusted proxies and client-IP headers, trusted clients, the rate-limit header prefix. Turns the firewall on.

Key Type Default Description
allow_cidrs list of string Peers (IPs or CIDRs) allowed to connect; others get 403. Empty: everyone.
trusted_proxies list of string Peers whose client-IP headers are believed.
client_ip_headers list of string Headers read, in order, for the client IP from a trusted proxy. Default CF-Connecting-IP, True-Client-IP, X-Forwarded-For, X-Real-IP.
trusted_clients list of string Client IPs or CIDRs that get the trusted rate limits on every instance.
trusted_client_headers list of string Headers a trusted client uses to name its end user's IP, so each user gets their own limits. Default X-Client-IP.
rate_limit_header_prefix string Prefix of the rate-limit response headers. Default X-RateLimit.

rate_limits:

Default rate limits for every instance (per instance: extensions.firewall.rate_limits). Turns the firewall on.

Key Type Default Description
ip_hourly int Requests per hour from one client IP to one instance. Default 1000.
instance_hourly int Requests per hour to one instance from everyone. Default 10000.
ip_concurrent int Requests one client IP may have open on one instance at once. Default 15.
instance_concurrent int Requests one instance may have open at once. Default 250.
file_weight number What a /api/files request costs, as a share of a request. Default 0.1.
trusted.ip_hourly int Requests per hour from one trusted client IP to one instance.
trusted.instance_hourly int Requests per hour to one instance.
trusted.ip_concurrent int Requests one trusted client IP may have open on one instance at once.
trusted.instance_concurrent int Requests one instance may have open at once.
off bool No rate limiting. The default in dev mode unless rate_limits is set.

Use

What callers see:

  • Peers outside allow_cidrs get 403.
  • Client IP: taken from client_ip_headers only when the peer is a trusted proxy; otherwise it's the peer. The app then sees that client as the request's IP, so PocketBase's logs and its own rate limits see the visitor.
  • Over a limit: 429 with Retry-After and a message naming the limit and the IP.
  • Every routed response carries X-RateLimit-{Ip,Instance}-{Hourly,Concurrent}-{Limit,Remaining} and X-RateLimit-{Ip,Instance}-Hourly-Reset (with your prefix), listed in Access-Control-Expose-Headers so browser code can read them.
  • Long-lived responses (realtime) count toward the concurrency limits until they end.

Trusted clients that serve many users

A trusted client (process-wide trusted_clients, or an instance's own trusted_ips) that serves many users, such as a server-side proxy or SSR app, sends each request's end-user IP in a trusted_client_headers header. Per-IP limits then apply to that user, still with the trusted limits; without the header all its users share one bucket. The header is ignored from anyone else, so it can't be spoofed to escape a limit.

Integrations

Works with

Extension How they fit
Multisite The firewall is a host gate: it checks the peer, then admits each request before the runtime or the forward hook.
Pocker, Docker Either provider; the firewall runs in front of both.
Edge The entry edge applies the limits before forwarding. The owner edge's rate-limit headers replace the entry edge's, since the owner counted every request for the instance.
Multitenant (planned) Will write each instance's extensions.firewall settings, such as its owner's trusted IPs.

Building on the firewall

  • Per instance. Write extensions.firewall.rate_limits and extensions.firewall.trusted_ips into an instance's settings (from the instances file or your own instance source).
  • Go API. firewall.Firewall{Config, RateLimits} is a multisite.Gate you can add to Host.Gates yourself; NewFirewall(cfg) builds one ready to use. ClientIP(r) gives a request's client. firewall.SectionAdjust changes the settings read from pocketone.yaml before the firewall is built.

Example

The firewall in front of a toy host: only 127.0.0.1 may connect, at 100 requests an hour per IP. Run it from this folder with go run ./example --instances instances.yaml.

// The firewall in front of a host: every request is checked (allowed peers, rate limits) before a toy runtime
// answers. In a PocketOne binary the firewall turns on from pocketone.yaml's firewall / rate_limits sections; here
// it is built in Go.
//
//	go run ./example --instances instances.yaml
package main

import (
	"context"
	"flag"
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"syscall"

	"github.com/pockethost/pocketone/ext/multisite"
	"github.com/pockethost/pocketone/ext/multisite-firewall"
)

type okRuntime struct{}

func (okRuntime) Name() string                        { return "ok" }
func (okRuntime) Serves(inst multisite.Instance) bool { return true }
func (okRuntime) Ready() bool                         { return true }
func (okRuntime) ServeInstance(w http.ResponseWriter, _ *http.Request, inst multisite.Instance) {
	fmt.Fprintf(w, "hello from %s\n", inst.ID)
}

func main() {
	instances := flag.String("instances", "instances.yaml", "instances file")
	listen := flag.String("listen", "127.0.0.1:8090", "listen address")
	flag.Parse()
	fw := &firewall.Firewall{
		Config:     firewall.Config{AllowCIDRs: []string{"127.0.0.1"}},
		RateLimits: &firewall.RateLimits{IPHourly: 100},
	}
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	h := &multisite.Host{Source: &multisite.YAMLSource{Path: *instances}, Runtime: okRuntime{}, Gates: []multisite.Gate{fw}, Listeners: multisite.Listeners{Listen: *listen}}
	if err := h.Run(ctx); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

License

MIT