DOCS · TRAFFIC POLICY

Rules at the edge.

Block a path or an IP range. Inject a header. Cap requests per minute. Validate a JWT. Screen for attacks with Shield. Per-tunnel rules, applied before traffic ever touches your local service — denied requests don't burn your bandwidth, injected headers reach the upstream exactly like a regular client would set them.

1 Concept

A policy is JSON. It lives on the tunnel row in our database and applies the next time a public request lands. The hot path runs deny first (denied paths cost nothing), then rate_limit, then header_set. Seven more actions — IP allow/deny, method and content-type allow-lists, body-size limits, JWT validation, and the Shield WAF — run in the same edge pipeline (see below).

Shape

{
  "actions": [
    { "kind": "deny",        "path_prefix": "/admin" },
    { "kind": "rate_limit",  "requests_per_minute": 60 },
    { "kind": "header_set",  "name": "X-Forwarded-For",
                             "value": "203.0.113.42" }
  ]
}

Where it runs

At the public HTTP edge — before the request opens an agent stream. That means:

  • Denied requests get a 403 from us, no traffic to your laptop.
  • Rate-limited requests get a 429 from us, no traffic to your laptop.
  • Header injections happen as we replay the buffered prefix to the agent — your local service reads the new header from the standard HTTP/1.1 request.

2 deny — block paths

Reject any request whose URI path starts with path_prefix. Returns 403 with a fixed body. Useful for putting /admin, /internal, or /.git behind your firewall while still exposing the rest of the app.

Example: hide /admin from the world

curl -X PUT -H "Authorization: Bearer $JWT" \
    -H "Content-Type: application/json" \
    -d '{"actions":[{"kind":"deny","path_prefix":"/admin"}]}' \
    https://login.21tunnel.com/api/tunnels/$TID/policy

What public requests see:

$ curl -i https://myapp.21tunnel.com/admin/users
HTTP/1.1 403 Forbidden
Content-Type: text/plain

forbidden by traffic policy

Prefix matching, not glob. /admin denies /admin, /admin/users, and /administrator. If you want stricter, use a more specific prefix (/admin/ with the trailing slash).

Why denies don't consume rate-limit budget

A path you explicitly denied is not "legitimate traffic" — it's noise. Counting it against the per-minute budget would mean an attacker hammering /admin could exhaust the budget and rate-limit your real users. The evaluator short-circuits deny before rate_limit so this can't happen.

3 header_set — inject a header

Add (or overwrite) a header on the request forwarded to your local service. Inject API keys, mark traffic as edge-trusted, or stamp a tenant tag without modifying your backend.

Example: inject a shared secret

curl -X PUT -H "Authorization: Bearer $JWT" \
    -H "Content-Type: application/json" \
    -d '{"actions":[
        {"kind":"header_set","name":"X-Edge-Auth","value":"shh-its-me"}
    ]}' \
    https://login.21tunnel.com/api/tunnels/$TID/policy

What your local service sees:

GET /api/whatever HTTP/1.1
Host: myapp.21tunnel.com
X-Edge-Auth: shh-its-me
... rest of headers ...

The header is overwritten, not appended — if the public client tries to set X-Edge-Auth themselves, the policy's value wins. That prevents the "smuggled header" footgun where the upstream sees two values and picks unpredictably.

Restrictions

  • Name: ASCII alphanumeric + - + _, max 64 chars.
  • Value: max 1024 chars; CR / LF rejected at write time (prevents header smuggling).
  • Idempotent: repeated header_set for the same name acts as the final write.

4 rate_limit — cap requests / minute

Fixed 60-second window. Once requests_per_minute legitimate requests have been served in the current window, further requests get 429 with Retry-After: 60 until the window rolls. At most one rate_limit action per policy.

Example: 60 / minute

curl -X PUT -H "Authorization: Bearer $JWT" \
    -H "Content-Type: application/json" \
    -d '{"actions":[{"kind":"rate_limit","requests_per_minute":60}]}' \
    https://login.21tunnel.com/api/tunnels/$TID/policy

What public clients see at the cap:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: text/plain

rate limit exceeded by traffic policy

Per-tunnel, not per-IP

The bucket is keyed on tunnel ID. 60 / min means 60 requests across all public clients hitting that tunnel — not 60 per source IP. For per-IP rate-limiting inside your app, your own framework still has to do that work; the edge policy is for catching runaway scripts and obvious abuse.

Why fixed-window, not sliding

The window resets at the 60-second boundary. That means a burst of 2× the cap is possible right at the rollover (last request of window N + first of N+1 in < 1 ms). We chose fixed-window because:

  • Trivially correct under concurrent access via CAS — no lock, no clock-coordination on the hot path.
  • Smoother sliding-window is upgradeable later without a wire-format change.
  • The burst-at-boundary case is essentially irrelevant for the rate-limit's purpose (catch a buggy script burning hundreds of requests per second).

Limits

  • Max: requests_per_minute capped at 60,000 (≈ 1,000 req/s).
  • Min: 1.
  • At most one rate_limit action per policy.
  • State is in-memory. If the tunnel server restarts, the bucket resets — accepted because the 60-second window is short.

5 Combine actions

Up to 16 actions per policy, evaluated in the order deny → rate_limit → header_set. Mix to taste.

Example: belt + suspenders staging tunnel

{
  "actions": [
    { "kind": "deny",        "path_prefix": "/admin" },
    { "kind": "deny",        "path_prefix": "/.git"  },
    { "kind": "deny",        "path_prefix": "/.env"  },
    { "kind": "rate_limit",  "requests_per_minute": 120 },
    { "kind": "header_set",  "name": "X-Tunnel-Source",
                             "value": "21tunnel-edge" }
  ]
}

That policy:

  • Returns 403 for /admin, /.git, /.env — common bot-probe paths.
  • Caps the tunnel at 120 req/min so a buggy load test can't burn it.
  • Stamps every request with X-Tunnel-Source: 21tunnel-edge so your local logs can identify proxied traffic.

6 More actions

Beyond deny / rate_limit / header_set, the same policy pipeline ships seven more actions. All are per-tunnel, evaluated at the edge before any traffic reaches your service, and set the same way — one more entry in the actions array.

ip_allowOnly these CIDRs may reach the tunnel — {"kind":"ip_allow","cidrs":["203.0.113.0/24"]}
ip_denyBlock these CIDRs — {"kind":"ip_deny","cidrs":["198.51.100.7/32"]}
method_allowRestrict HTTP methods — {"kind":"method_allow","methods":["GET","POST"]}
content_type_allowOnly accept these request content types — {"kind":"content_type_allow","types":["application/json"]}
body_size_limitReject oversized request bodies — {"kind":"body_size_limit","max_bytes":1048576}
jwt_validationRequire a valid signed JWT (see below).
wafScreen requests through Shield (see below) — {"kind":"waf","mode":"block","profile":"standard"}

jwt_validation — verify a bearer token at the edge

Requests without a valid Authorization: Bearer token get a 401 from us; your service never sees them. Optional issuer and audience allow-lists narrow it further. Expiry is always enforced.

Point it at your issuer's JWKS endpoint and key rotation takes care of itself — this is what you want with Auth0, Cognito, Firebase, or anything else that publishes a JWKS:

{
  "actions": [
    { "kind": "jwt_validation",
      "algorithm": "RS256",
      "jwks_url": "https://your-tenant.auth0.com/.well-known/jwks.json",
      "allowed_issuers":   ["https://your-tenant.auth0.com/"],
      "allowed_audiences": ["my-api"] }
  ]
}

Keys are fetched in the background and cached, never during your request. If your issuer is briefly unreachable the cached keys keep working for a grace period; once that expires requests are rejected rather than let through unverified. Add "jwks_cache_ttl_secs" (60–86400) to control how long keys are trusted before a refresh — a rotation is picked up within about half a minute of that expiring, and sooner if a token arrives signed by a key we have not seen.

A shared secret works too, and so does a pasted public key — though with the latter you have to paste it again on every rotation:

{ "kind": "jwt_validation",
  "algorithm": "HS256",
  "key": "your-shared-secret" }

Set exactly one of key or jwks_url. jwks_url must be https and requires "algorithm": "RS256" — a JWKS publishes public keys, so there is nothing for HS256 to fetch.

7 Shield — the built-in WAF

Add a waf action and every request is screened by Shield before it reaches your service. A built-in signature engine (path traversal, SQL injection, XSS, command injection, scanner and Log4Shell probes) runs on every deployment; the hosted platform backs it with an OWASP Core Rule Set engine.

mode: "monitor"Log hits to the dashboard, don't block — safe to roll out.
mode: "block"Return 403 on a hit and record the event.
profile: "standard"Balanced rule set (default).
profile: "strict"Broader coverage, more false positives.

Blocked and monitored hits appear on the dashboard's Security page with the rule that matched. Start in monitor, watch the events, then flip to block.

8 REST API

Three endpoints. Authenticate with your dashboard JWT or an API key on the owning org.

GET    /tunnels/:id/policy Read the current policy (or null).
PUT    /tunnels/:id/policy Set or replace. Body is the policy JSON above.
DELETE /tunnels/:id/policy Drop the policy. Idempotent.

Read

curl -H "Authorization: Bearer $JWT" \
    https://login.21tunnel.com/api/tunnels/$TID/policy
{
  "id": "3a1b2c3d-...",
  "policy": {
    "actions": [
      { "kind": "deny", "path_prefix": "/admin" }
    ]
  }
}

Bad shapes return 400

Validation runs before persistence. Common errors:

HTTP/1.1 400 Bad Request

{ "error": "bad_policy",
  "message": "action[0] header_set: value must not contain CR or LF" }

Other rejected inputs: too many actions (max 16), bad rate-limit values, missing leading / on path_prefix, second rate_limit action.

9 Limits + caveats

Per-tunnel only

Policies don't share across tunnels (yet). If you have five staging tunnels all needing the same rules, you PUT the policy on each one. Per-org reusable policies are a roadmap item — file a request.

HTTP / HTTPS only

TCP and UDP tunnels are byte-pipes; there's no "path" or "header" to act on. Policies on a TCP tunnel return 200 + are stored but never fire.

Edge state is in-memory

Rate-limit buckets live in the tunnel-server process. On restart (rare), the window resets. The DB still holds the policy itself, so re-registration carries it forward.

16 actions max

Per policy. Bounds the worst-case evaluation cost on the hot path. Real policies are typically 1–3 actions — 16 is generous headroom.

→ Next