Cloudflare WAF rule set for gevell.com (Pro plan)
Status: proposal. Nothing here is deployed. Plan limits below come from Cloudflare’s docs as of 2026-09-23. Check them in the dashboard before you build anything.
0. Decisions already made (inputs)
-
theaccessible.orgis being retired. Its website redirects to gevell.com, and the zone stays on Free for DNS and Google Workspace MX only. -
The API hosts move to gevell.com hostnames, proxied (orange-cloud) through the gevell.com Pro zone:
Today Proposed Origin api.theaccessible.orgapi.gevell.comNode API on AWS EC2 via Cloudflare Tunnel ( server.ts)api-pdf.theaccessible.orgapi-pdf.gevell.comAPI Gateway HTTP API v2 → Lambda ( index-aws.ts)worker.theaccessible.orgworker.gevell.comCF Worker ( workers/api/src/index.ts), which carries the live Stripe/workshop webhooks*.lambda-url.us-east-1.on.aws(NEXT_PUBLIC_STREAM_URL/NEXT_PUBLIC_AI_FIX_URL)stream.gevell.com(new)Lambda Function URL, lambda-stream.handler, 300 s -
AWS WAF can’t be attached to an HTTP API (v2) or a Function URL, so Cloudflare is the WAF.
What we verified today (read-only)
api-pdf.theaccessible.orgis a DNS-only CNAME tod-5exruqu2od.execute-api.us-east-1.amazonaws.com, which is a regional API Gateway custom domain. Responses carryapigw-requestid. No Cloudflare proxy is involved. The custom domain isn’t defined in CDK (api-stack.tshas an explicit “no custom domain here” note), so it was created by hand.api.theaccessible.organdworker.theaccessible.orgresolve to Cloudflare anycast, so they are proxied.- The browser calls the Function URL directly.
apps/web/.env.productionsetsNEXT_PUBLIC_STREAM_URL=https://d6z4…lambda-url.us-east-1.on.aws/. - The Function URL is
authType: NONE. The handler does its own Supabase JWT check (lambda-stream.ts).
1. Security finding: CF-Connecting-IP is client-spoofable on the Lambda API (confirmed)
Code:
workers/api/src/index-aws.ts:404:const clientIp = c.req.header('cf-connecting-ip') ?? (xff || c.req.header('x-real-ip')). This feedsclientIpinapp_logs.workers/api/src/utils/sso-http.ts:11-13:clientIp()returnscf-connecting-ipfirst. The comment calls it “platform-set client IP (unspoofable)”. That value is written to the SSO login-audit records.- The same pattern appears in
routes/md-to-pdf.ts:55,routes/accessible-pdf.ts:64,routes/pdf-handoff.ts:42,routes/org-chart/org-chart-share.ts:611,routes/preferences.ts:158,routes/unsubscribe.ts:99andserver.ts:525.
Why it’s exploitable today: api-pdf.theaccessible.org goes straight to API Gateway, with no Cloudflare in front. API Gateway HTTP APIs pass arbitrary request headers through to Lambda. So anyone can send CF-Connecting-IP: 1.2.3.4 and the code will trust it. We confirmed this from DNS and code. We did not send a forged request to production. The fallback is also weak: on API Gateway the first X-Forwarded-For element is client-supplied, and API Gateway appends the real IP at the end.
Impact:
- Forged IPs in
app_logsand the SSO login audit trail, which weakens incident forensics and the evidence we give customers. - Per-IP rate-limit bypass on unauthenticated endpoints:
/api/md-to-pdf(30/h per IP),/api/accessible-pdf/from-html,/api/pdf-handoffand the org-chart temp-share limiter. An attacker who rotates the header gets unlimited quota on paid conversion paths. - Forged IPs in
user_audit_log(preferences) andprospectsunsubscribe records.
On Node (api.theaccessible.org) the header is set by the Cloudflare edge, so it’s trustworthy for tunnel traffic. One caveat: Traefik publishes 8800:8800 on all interfaces (docker-compose.yml:164), so LAN hosts can reach it directly and forge the header. Bind it to 127.0.0.1, or firewall it, if cloudflared runs on the host (verify).
Fix (two parts, both needed):
- Origin lock (§5). Once API Gateway accepts only Cloudflare mTLS connections, and the Function URL rejects requests without the edge secret, every request that reaches Lambda came through Cloudflare. Cloudflare overwrites any client-supplied
CF-Connecting-IP, so the header becomes trustworthy. - Code (defense in depth): add one shared helper,
trustedClientIp(c), and replace the eight ad-hoc copies with it:- Trust
cf-connecting-iponly when the request is verified as coming from Cloudflare. On Lambda, that means the mTLS custom-domain path, or a valid edge-secret header (x-gevell-edge, §5.2) compared in constant time. On Node, it means the request arrived via the tunnel. - Otherwise, use the platform-observed peer address:
event.requestContext.http.sourceIpon Lambda (exposed through Hono’s aws-lambda adapter; verify the accessor), or the socket address on Node. Never use the first XFF element. - Fix the misleading “unspoofable” comment in
sso-http.ts.
- Trust
2. Managed rulesets
| Ruleset | Pro? | Initial setting | Target setting |
|---|---|---|---|
| Cloudflare Managed Ruleset | Yes | Deployed at default actions, with the §3 skip rules in place first | Default actions |
| Cloudflare OWASP Core Ruleset | Yes | Paranoia level PL1, score threshold “High” (60), action Block | PL2 with threshold “Medium” (40) on app hosts after 2 clean weeks. Keep PL1/High on API hosts, which carry large JSON/HTML bodies. |
| Leaked credentials detection | Yes (username_and_password_leaked needs Pro) | Turn detection on. No mitigation rule yet. | See note |
| Free Managed Ruleset | n/a | Superseded by the Cloudflare Managed Ruleset | n/a |
Log vs block: the WAF Log action is Enterprise-only. Pro custom rules support every action except Log, and Pro can’t override managed rules to Log. So a true “log mode” isn’t available. The substitutes:
- Deploy the OWASP ruleset at the least sensitive setting (PL1/High) so it blocks only high-confidence attacks. Review Security Events, which records the rule ID and path for each block, and tighten from there.
- Check whether Pro exposes Security Analytics / Attack Analysis for traffic that wasn’t mitigated. If it does, that works as a dry run (verify in the dashboard).
- For browser-only hosts, use Managed Challenge as a soft action. Never use it on API hosts, because API clients can’t solve challenges.
- Do the first cutover on a staging hostname (
api-pdf.staging.gevell.com) and replay the E2E and smoke suites through it before moving production.
Leaked credentials: expect little value. Staff and customer passwords and OTPs never post to our zone. Supabase Auth runs at vuvwmfxssjosfphzpzim.supabase.co, SAML goes to the customer’s IdP, and API keys aren’t username/password pairs. Turn detection on, since it’s free, and watch it. Add a mitigation rule only if it ever fires on one of our hosts.
Supabase Auth rate limiting (outside this zone): OTP and magic-link endpoints live on *.supabase.co, so Cloudflare rules can’t touch them. Tighten Supabase Auth’s own rate limits and turn on Supabase CAPTCHA with Turnstile for sign-in and OTP. We found no captchaToken usage in apps/*. This is a separate task.
3. Custom rules (Pro allows 20; 12 used here)
Rules run in order, and the first terminating action wins. Skip rules must come first. $API is shorthand for http.host in {"api.gevell.com" "api-pdf.gevell.com" "worker.gevell.com" "stream.gevell.com"}. Pro has no regex (matches needs Business), so these rules use starts_with, ends_with, wildcard and in.
1. Skip: Stripe webhooks
http.request.method eq "POST" and $API and http.request.uri.path in {"/api/stripe/webhook" "/api/orgchart/stripe/webhook"}Action: Skip → all managed rules, Super Bot Fight Mode, rate limiting. The handler verifies the Stripe signature (routes/stripe.ts:434, routes/org-chart/org-chart-stripe.ts:22). Optional hardening: also require ip.src in $stripe_webhook_ips, an IP list built from Stripe’s published webhook IPs. Check the Pro list quota.
2. Skip: AWS SNS / SES / backup reporters
http.request.method eq "POST" and $API and http.request.uri.path in {"/api/budgets/aws-events" "/api/webhooks/ses-events" "/api/backup-report"}Action: Skip → Super Bot Fight Mode and OWASP. Keep the Cloudflare Managed Ruleset. Each handler authenticates the sender: budgets.ts verifies the SNS signature, ses-events.ts checks x-webhook-secret, and backup-status.ts checks x-backup-token.
3. Skip: service-to-service /internal/*
$API and starts_with(http.request.uri.path, "/internal/")Action: Skip → OWASP and Super Bot Fight Mode. /internal/convert takes raw PDF bytes, which OWASP body inspection would flag. requireInternalOrAdmin (x-proxy-secret or admin JWT) is the real control.
4. Skip OWASP: SAML ACS
http.request.method eq "POST" and $API and http.request.uri.path wildcard "/api/sso/*/acs"Action: Skip → OWASP only. The Cloudflare Managed Ruleset still runs. The base64 XML SAMLResponse triggers OWASP XML and injection rules. The handler validates the signature and replay (routes/sso.ts:150). If wildcard isn’t available on Pro, use starts_with(…,"/api/sso/") and ends_with(…,"/acs").
5. Skip OWASP: LTI 1.3 launch
http.request.method eq "POST" and $API and http.request.uri.path in {"/api/lti/login" "/api/lti/callback"}Action: Skip → OWASP and Super Bot Fight Mode. The LMS posts a form with an id_token JWT and state. The handler validates them (routes/lti.ts).
6. Skip bot checks on API hosts
$APIAction: Skip → Super Bot Fight Mode. This rule matters because Super Bot Fight Mode is zone-wide and can’t be scoped by path on Pro. Machine clients call the APIs by design: the Gevell CLI (Go), MCP (/api/mcp), GPT Actions (/api/gpt), the tagging API (/api/v1/tagging, X-API-Key), the org-chart public API (/api/orgchart/v1), SNS, Stripe and the status monitor. Browser traffic on these hosts is fetch() with a JWT, which is authenticated anyway. Bot protection stays on the marketing and app hosts.
7. Block: unauthenticated conversion gateway
$API and starts_with(http.request.uri.path, "/api/gateway/") and not any(http.request.headers["x-gateway-key"][*] eq "<GATEWAY_EDGE_KEY>")Action: Block. routes/gateway.ts:28 says “No auth required … rate limiting is enforced at the gateway Worker level”, but the Lambda and Node routes are directly reachable. That’s the open issue already described in gateway-security-plan.md. This is an interim edge control. The real fix is an HMAC check in code. Header-map fields in custom rules: verify on Pro.
8. Block: scanner noise
http.request.uri.path contains "/wp-" or http.request.uri.path contains "/.env" or http.request.uri.path contains "/.git/" or http.request.uri.path in {"/xmlrpc.php" "/phpmyadmin" "/server-status"} or starts_with(http.request.uri.path, "/cgi-bin/")Action: Block, zone-wide. Cheap, and it cuts noise in logs and Security Events.
9. Block: unexpected methods on API hosts
$API and not http.request.method in {"GET" "POST" "PUT" "PATCH" "DELETE" "OPTIONS" "HEAD"}Action: Block.
10. Block: stream host restricted to its two routes
http.host eq "stream.gevell.com" and not ((http.request.method eq "POST" and http.request.uri.path eq "/ai-fix") or (http.request.method eq "GET" and starts_with(http.request.uri.path, "/flyer/jobs/") and ends_with(http.request.uri.path, "/flipbook-url")) or http.request.method eq "OPTIONS")Action: Block. lambda-stream.ts routes by method and should only ever see these two paths. OPTIONS is allowed for CORS preflight.
11. Block: admin API from non-Access callers (design choice, see below)
$API and (starts_with(http.request.uri.path, "/api/admin") or starts_with(http.request.uri.path, "/api/orgchart/admin")) and not any(http.request.headers["x-admin-edge"][*] eq "<ADMIN_EDGE_KEY>")Action: Block. Today /api/admin/* is protected only by Supabase JWT plus requireAdmin (routes/admin.ts:117,146). The admin UI (admin.gevell.com, admin-docs.gevell.com, both .pages.dev aliases) is already behind Cloudflare Access: we verified 302s to anglin.cloudflareaccess.com. The admin API isn’t. Recommended design:
- Add a small same-origin proxy (Pages Function) at
admin.gevell.com/api/*. Requests reach it only after Access, and it forwards toapi-pdf.gevell.comwithx-admin-edgeand theCf-Access-Jwt-Assertionheader. - In code,
requireAdminverifies the Access JWT againsthttps://anglin.cloudflareaccess.com/cdn-cgi/access/certsin addition to the Supabase admin check. - The static edge key is the interim control. The Access-JWT check is the durable one.
Alternative: a path-scoped Access application on api-pdf.gevell.com/api/admin. This needs cross-origin Access cookies (SameSite=None plus Access CORS settings), which are fiddly with fetch(). Prototype on staging first.
Note: apps/admin/wrangler.toml says admin.gevell.com is served by Pages, while pdf-architecture.md says the apps/web worker is bound to it. Confirm which is current before building the proxy.
12. Block: oversized JSON bodies on API hosts (optional; needs verification)
$API and http.request.body.size gt 10485760 and not (http.request.uri.path wildcard "/api/files/*/upload-data" or starts_with(http.request.uri.path, "/internal/") or starts_with(http.request.uri.path, "/api/photos/upload-file/"))Action: Block. Check that http.request.body.size is available on Pro. Drop this rule if it isn’t.
Rules 13–20 are spare, kept for emergency blocks (IP or ASN during an incident) and exceptions found during rollout.
Country/ASN: no geo or ASN blocks. Customers are US higher-ed and government, but staff travel, and institutions use cloud egress. There’s no demonstrated abuse source that would justify a block. Use a spare slot reactively during an incident.
4. Rate limiting (Pro: 2 rules, counts by IP only, period ≤ 1 min, mitigation timeout ≤ 1 h, no custom counting expression)
Rule RL-1: public, unauthenticated, abuse-prone POSTs
http.request.method eq "POST" and $API and ( http.request.uri.path in {"/api/md-to-pdf" "/api/accessible-pdf/from-html" "/api/pdf-handoff" "/api/promo/accessu/claim" "/api/contact" "/api/leads/checklist" "/api/feedback" "/api/sso-intake" "/api/errors" "/api/client-logs" "/api/auth-events" "/api/lti/login" "/api/lti/callback"} or http.request.uri.path wildcard "/api/sso/*/acs" or starts_with(http.request.uri.path, "/api/gateway/"))Settings: 20 requests per 1 min per IP, Block for 10 min, with a custom JSON response (429, {"data":null,"error":{"code":"RATE_LIMITED",…}}). /api/promo/accessu/claim sends a sign-in email, so it’s an email-bombing vector. This edge limit replaces the per-IP KV limiters, which can be spoofed today (§1).
Rule RL-2: upload-URL issuance and ingest
http.request.method in {"POST" "PUT"} and $API and ( http.request.uri.path in {"/api/files/upload" "/api/files/from-url" "/api/uploads" "/api/photos/upload" "/api/photos/register"} or http.request.uri.path wildcard "/api/files/*/upload-data")Settings: 120 requests per 1 min per IP, Block for 5 min. This is a generous threshold. A whole campus often sits behind one NAT IP, and on Pro we can only count by IP (per-API-key counting needs Business or higher). So keep this limit coarse and leave fine-grained quotas to the in-app spend controls. Presigned URLs expire after 600 s (files.ts).
With only 2 rules we can’t separately limit /api/api-keys creation or LTI. Those endpoints need auth. If more buckets are needed, move to Business (5 rules, custom counting expression, periods up to 10 min).
5. Origin lock (makes the WAF non-bypassable)
A WAF you can walk around isn’t a control. Today all three non-tunnel origins are reachable directly.
5.1 API Gateway (HTTP API): mTLS + disable the default endpoint
- Bring the custom domain into CDK:
apigateway.DomainNameforapi-pdf.gevell.com, plus anApiMapping. Don’t keep hand-edited resources. - Authenticated Origin Pulls, per-hostname, with our own client certificate. Don’t use Cloudflare’s shared zone-level AOP certificate. It’s signed by a Cloudflare CA that every Cloudflare customer’s zone presents, so trusting it would admit any Cloudflare zone. Instead:
- Generate a private CA and a client certificate. Upload the client cert to the gevell.com zone as a per-hostname AOP certificate for
api-pdf.gevell.com(API/dashboard, available on all plans; verify). - Put the CA certificate in an S3 truststore and set
mtls: { bucket, key }on the API Gateway custom domain. Regional domain, TLS 1.2.
- Generate a private CA and a client certificate. Upload the client cert to the gevell.com zone as a per-hostname AOP certificate for
- Set
disableExecuteApiEndpoint: trueon theHttpApisox2tx47vflk.execute-api…stops answering. The CF Worker’sAWS_CANARY_URLinworkers/api/wrangler.toml:81points at that default endpoint and must move to the custom domain first. - Set the DNS record for
api-pdf.gevell.comto proxied. SSL mode: Full (strict).
5.2 Function URL: shared-secret edge header
The Function URL can’t do mTLS. With authType: NONE, reachability depends on the secret:
- Add a Request Header Transform Rule on
http.host eq "stream.gevell.com"that setsx-gevell-edge: <random 32-byte secret>. Cloudflare overwrites any client-supplied value. - Add an Origin Rule for
stream.gevell.com: DNS/resolve override to<id>.lambda-url.us-east-1.on.aws, plus Host header override to the same value. Check the Pro plan limits for these override types. - In
lambda-stream.ts, reject (403) any request without a constant-time match onx-gevell-edge, before loading SSM secrets or checking the JWT. Store the secret in SSM (/accessible-pdf/production/EDGE_SHARED_SECRET) and rotate it with the existing secret-rotation workflow. - Update the CORS
allowedOriginson the Function URL andNEXT_PUBLIC_STREAM_URL/NEXT_PUBLIC_AI_FIX_URLtohttps://stream.gevell.com/. - Stronger long-term option: switch the Function URL to
AWS_IAMand front it with a tiny Worker that SigV4-signs requests. This removes the static secret.
The main Lambda can use the same header check as a second factor next to mTLS. The shared trustedClientIp helper (§1) reads the same verification flag.
5.3 Node (.4) via Cloudflare Tunnel
This origin is already locked: it has no inbound ports, and cloudflared dials out. Only the LAN exposure of Traefik on :8800 remains (§1).
5.4 Worker host
worker.gevell.com is a Workers route on the zone, so it has no separate origin to lock. WAF custom and managed rules run before the Worker.
6. Platform limits and affected endpoints
| Limit (Pro) | Value | Affected | Verdict |
|---|---|---|---|
| Max request body | 100 MB | Not affected: browser uploads use presigned PUTs straight to S3 staging (malware-scan gate) or R2 (routes/files.ts /upload → presignForScan / getSignedUrl). Affected: PUT /api/files/:id/upload-data (only the non-S3 fallback, i.e. the CF Worker composition), PUT /api/photos/upload-file/:r2Key (Worker, streams through), POST /internal/convert (raw bytes from the convert-by-email worker), POST /api/pdf-handoff (relays bytes) | Low risk: API Gateway already caps payloads at 10 MB (comment in files.ts), which is stricter. Confirm the Worker-path uploads are capped below 100 MB in code. |
| Proxy read timeout | 125 s in the current docs (older docs said 100 s; verify), then 524 | stream.gevell.com flipbook builds for large catalogs: the handler writes the whole response only at the end (lambda-stream.ts, a single stream.write), so a render over 125 s gets a 524. /ai-fix (about 60 s) is fine. API Gateway routes are capped at 29 s anyway. Node long-polls/SSE (verify any over 125 s). | Blocker for putting the flipbook behind Cloudflare. Either send a heartbeat byte/early headers (verify that streaming bytes reset the read timer) or make the flipbook async (enqueue, then poll). Until then, keep flipbook on the unproxied Function URL with the edge-secret check off for that path. |
| Proxy write timeout | 30 s | Slow client uploads through the proxy | Presigned direct uploads avoid it |
7. Bot settings
- Super Bot Fight Mode (Pro): Definitely automated → Block on marketing and app hosts. Verified bots → Allow, so search engines can index gevell.com. Static resource protection → Off. JavaScript detections → On for app hosts only. Custom rule 6 exempts all API hosts. Per Cloudflare’s guidance for Tunnel users, confirm Tunnel health after turning it on (the edge→tunnel path shouldn’t be affected, but check).
- Don’t use “I’m Under Attack” or Managed Challenge on API hosts.
- The status worker (
status.gevell.com) and Uptime Kuma call/health. Add/healthto rule 6’s scope if they ever hit a non-API host.
8. Rollout sequence
- Code first (no behavior change):
trustedClientIphelper; edge-secret check inlambda-stream.tsbehind a flag; gateway HMAC; admin Access-JWT verification behind a flag. - Staging: create
api-pdf.staging.gevell.com(proxied, mTLS). Deploy rules 1–12 and RL-1/RL-2. Run the E2E, smoke and pen-test runbook suites through it. Fix false positives with skip rules or rule-level overrides, not by turning rulesets off. - Production, soft: proxy
api-pdf.gevell.comandapi.gevell.com. OWASP stays at PL1/High (the least-sensitive block setting, since Pro has no Log action) and rate limits at 2× the proposed thresholds. Keep the oldtheaccessible.orgAPI hostnames working in parallel. Stripe webhooks: register the new endpoint and keep the old one until traffic drains (pergevell-primary-domain-migration.md). - Review (2 weeks): go through Security Events daily for the first 3 days, then weekly. Check for 4xx spikes in
app_logs. - Enforce: tighten OWASP on app hosts to PL2/Medium, set rate limits to target, turn on origin lock (mTLS truststore,
disableExecuteApiEndpoint, edge-secret enforcement), and flip the code totrustedClientIpenforcing mode. - Retire the direct
execute-apiendpoint andlambda-urlhostnames from all frontends. Remove the old API hostnames after 30 days.
Cost: Cloudflare Pro is about $20/mo billed annually, or $25/mo monthly (estimate, verify). The AWS side (S3 truststore, custom domain) costs about $0. A Business upgrade (about $200/mo, estimate) would be justified only by regex rules, per-key rate limiting, the 200 MB body limit or more rate-limit rules.