Skip to content

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.org is 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:

    TodayProposedOrigin
    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.org is a DNS-only CNAME to d-5exruqu2od.execute-api.us-east-1.amazonaws.com, which is a regional API Gateway custom domain. Responses carry apigw-requestid. No Cloudflare proxy is involved. The custom domain isn’t defined in CDK (api-stack.ts has an explicit “no custom domain here” note), so it was created by hand.
  • api.theaccessible.org and worker.theaccessible.org resolve to Cloudflare anycast, so they are proxied.
  • The browser calls the Function URL directly. apps/web/.env.production sets NEXT_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 feeds clientIp in app_logs.
  • workers/api/src/utils/sso-http.ts:11-13: clientIp() returns cf-connecting-ip first. 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:99 and server.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:

  1. Forged IPs in app_logs and the SSO login audit trail, which weakens incident forensics and the evidence we give customers.
  2. Per-IP rate-limit bypass on unauthenticated endpoints: /api/md-to-pdf (30/h per IP), /api/accessible-pdf/from-html, /api/pdf-handoff and the org-chart temp-share limiter. An attacker who rotates the header gets unlimited quota on paid conversion paths.
  3. Forged IPs in user_audit_log (preferences) and prospects unsubscribe 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):

  1. 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.
  2. Code (defense in depth): add one shared helper, trustedClientIp(c), and replace the eight ad-hoc copies with it:
    • Trust cf-connecting-ip only 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.sourceIp on 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.

2. Managed rulesets

RulesetPro?Initial settingTarget setting
Cloudflare Managed RulesetYesDeployed at default actions, with the §3 skip rules in place firstDefault actions
Cloudflare OWASP Core RulesetYesParanoia level PL1, score threshold “High” (60), action BlockPL2 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 detectionYes (username_and_password_leaked needs Pro)Turn detection on. No mitigation rule yet.See note
Free Managed Rulesetn/aSuperseded by the Cloudflare Managed Rulesetn/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

$API

Action: 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 to api-pdf.gevell.com with x-admin-edge and the Cf-Access-Jwt-Assertion header.
  • In code, requireAdmin verifies the Access JWT against https://anglin.cloudflareaccess.com/cdn-cgi/access/certs in 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

  1. Bring the custom domain into CDK: apigateway.DomainName for api-pdf.gevell.com, plus an ApiMapping. Don’t keep hand-edited resources.
  2. 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.
  3. Set disableExecuteApiEndpoint: true on the HttpApi so x2tx47vflk.execute-api… stops answering. The CF Worker’s AWS_CANARY_URL in workers/api/wrangler.toml:81 points at that default endpoint and must move to the custom domain first.
  4. Set the DNS record for api-pdf.gevell.com to 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:

  1. Add a Request Header Transform Rule on http.host eq "stream.gevell.com" that sets x-gevell-edge: <random 32-byte secret>. Cloudflare overwrites any client-supplied value.
  2. 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.
  3. In lambda-stream.ts, reject (403) any request without a constant-time match on x-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.
  4. Update the CORS allowedOrigins on the Function URL and NEXT_PUBLIC_STREAM_URL / NEXT_PUBLIC_AI_FIX_URL to https://stream.gevell.com/.
  5. Stronger long-term option: switch the Function URL to AWS_IAM and 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)ValueAffectedVerdict
Max request body100 MBNot 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 timeout125 s in the current docs (older docs said 100 s; verify), then 524stream.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 timeout30 sSlow client uploads through the proxyPresigned 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 /health to rule 6’s scope if they ever hit a non-API host.

8. Rollout sequence

  1. Code first (no behavior change): trustedClientIp helper; edge-secret check in lambda-stream.ts behind a flag; gateway HMAC; admin Access-JWT verification behind a flag.
  2. 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.
  3. Production, soft: proxy api-pdf.gevell.com and api.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 old theaccessible.org API hostnames working in parallel. Stripe webhooks: register the new endpoint and keep the old one until traffic drains (per gevell-primary-domain-migration.md).
  4. Review (2 weeks): go through Security Events daily for the first 3 days, then weekly. Check for 4xx spikes in app_logs.
  5. 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 to trustedClientIp enforcing mode.
  6. Retire the direct execute-api endpoint and lambda-url hostnames 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.