Skip to content

Vercel region silently collides with unified region; 8 legacy fields leak invalid Cloudflare wirefilter #273

Description

@gfargo

What's wrong

Found while auditing skills/doorman/references/rules.md for accuracy against the real FieldType/translator code (that doc fix landed separately, already on main). Confirmed empirically by running the real vercelToUnified + unifiedToCloudflare functions directly against test fixtures with npx tsx, not just reading the code — reproduced independently a second time while writing this issue up.

Bug 1 (more severe): Vercel's region silently collides with the unified region field

Vercel's native region condition type means the edge/deployment region a request is served from (e.g. "sfo1", "iad1") — the same category of concept as the neighboring environment field, nothing to do with the client. mapVercelTypeToUnified (src/lib/providers/vercel/translator.ts) has no entry for it, so it falls through mapping[type] || type and becomes a unified condition with field: "region".

The problem: the unified schema already has a field literally named region, meaning something else entirely — the client's geographic subdivision (FieldType in src/lib/types/common.ts:38, "Geographic region"; mapped in Cloudflare's mapUnifiedFieldToCloudflare, src/lib/translators/ExpressionBuilder.ts:264, to ip.geoip.subdivision_1). The two concepts collide on the string "region".

Reproduced directly against the real translators:

vercelToUnified({ type: 'region', op: 'eq', value: 'sfo1' })
  -> unified condition: { field: 'region', operator: 'eq', value: 'sfo1' }   (0 warnings)
unifiedToCloudflare(...)
  -> expression: 'ip.geoip.subdivision_1 eq "sfo1"'                          (0 warnings)

This is syntactically valid wirefilter — it will sync to Cloudflare with no error and no warning — but it compares a Vercel edge-region slug against a client ISO subdivision code, which can never match. A rule keyed off Vercel's edge region (e.g. region-conditional deny/challenge/bypass logic) silently becomes a Cloudflare rule whose condition can never fire once migrated: a deny/challenge rule gives false confidence, and a bypass/allow rule silently blocks everyone it was meant to exempt.

For contrast, a genuine unified region condition (client geo, e.g. "CA") produces an indistinguishable-looking expression:

unifiedToCloudflare({ conditions: [{ field: 'region', operator: 'eq', value: 'CA' }], ... })
  -> expression: 'ip.geoip.subdivision_1 eq "CA"'

There's no way, once a condition reaches Cloudflare's translator, to tell "real client-region condition" from "corrupted Vercel edge-region pass-through" apart — the corruption already happened one step earlier, silently, inside mapVercelTypeToUnified.

This is Cloudflare-specific: mapUnifiedFieldToFastly (src/lib/providers/fastly/translator.ts) already drops all region conditions (real or corrupted) with a warning — Fastly has no subdivision-level geo field at all (see fastly.md) — so it's accidentally immune to this particular collision.

Bug 2 (separate, less severe): 8 other legacy-only Vercel fields leak invalid wirefilter syntax

geo_continent, geo_country_region, protocol, target_path, environment, ja3_digest, ja4_digest, rate_limit_api_id all fall through the same mapVercelTypeToUnified pass-through as Bug 1, and then fall through mapUnifiedFieldToCloudflare's mapping[field] || field fallback (ExpressionBuilder.ts:279) with no entry, leaking the bare unmapped name into the generated expression:

geo_continent eq "NA"

geo_continent isn't a real wirefilter field — again with zero warnings. Unlike Bug 1 this probably fails loudly (Cloudflare's Rulesets API likely rejects the unknown field outright), but that's unverified against a live zone (same caveat as #269).

skills/doorman/references/cloudflare.md:115 currently claims environment/ja3_digest/ja4_digest specifically "have no unified-format equivalent at all, so they're not reachable through a provider/providers-tagged config for any provider" and that "every other unified field maps to something on Cloudflare." Both halves are wrong: UnifiedCondition.field is schema-validated as fieldTypeSchema.or(z.string()) (src/lib/schemas/unifiedSchemas.ts:31, "Allow custom fields") — arbitrary strings pass through, confirmed reachable by the repro above — and region/the other 7 don't map to anything valid either. Needs a doc fix alongside the code fix.

Every other unsupported-field case in this codebase drops the condition with a TranslationWarning instead of leaking a raw name: mapUnifiedTypeToVercel (src/lib/providers/vercel/translator.ts:375) returns null and its caller drops the condition with a warning; mapUnifiedFieldToFastly (src/lib/providers/fastly/translator.ts:87) does the same. mapUnifiedFieldToCloudflare is the only one of the three that silently falls through instead of returning null.

Context

  • An orphaned pre-Unified-model implementation, FieldMapper.ts (vercelToCloudflare table, lines 13-35), has plausible real Cloudflare names for 4 of these 8 (geo_continentip.geoip.continent, geo_country_regionip.geoip.subdivision_1, protocolssl, target_pathhttp.request.uri.path) — never ported into the live path. Confirmed dead: its only caller is ExpressionBuilder.fromVercelCondition/fromVercelConditionGroups, which have no production callers of their own (only their own tests exercise them). Don't trust its mappings blindly — verify against Cloudflare's real wirefilter docs or a live zone before reusing (Cloudflare keyed header/cookie conditions likely emit wirefilter the API rejects (Array-vs-String, and indexing a String field) #269 found FieldMapper's header/cookie mappings were wrong in a different way). Critically, FieldMapper's own regionip.geoip.subdivision_1 entry has the exact same collision as Bug 1 — don't carry that one forward as-is.
  • geo_country_region is documented in rules.md as "Region/state code" (e.g. "CA") — i.e. the same real-world concept as the unified region FieldType, not a separate one. It may belong in mapVercelTypeToUnified's explicit table alongside geo_countrycountry/geo_citycity/geo_as_numberasn, rather than in the "drop with warning" bucket — but this needs the same live-Cloudflare verification as the rest of Bug 2 before committing to it.
  • A second orphaned file, src/lib/utils/compatibility.ts (zero importers anywhere in src), independently repeats FieldMapper's stale claims (including Cloudflare region/protocol/target_path/geo_continent "support" that doesn't reflect live behavior). Not user-facing today since nothing imports it, but misleading dead weight.

Suggested fix

  • mapVercelTypeToUnified should not pass Vercel's region through as literal "region" — rename to a collision-free value (e.g. a Vercel-namespaced field the unified vocabulary doesn't otherwise use) and keep mapUnifiedTypeToVercel in sync for the reverse direction so the Vercel↔Unified round trip stays lossless. Add a regression test asserting a Vercel region condition never silently becomes a Cloudflare ip.geoip.subdivision_1 condition.
  • Make mapUnifiedFieldToCloudflare return string | null (matching mapUnifiedTypeToVercel/mapUnifiedFieldToFastly), have its caller push a TranslationWarning and drop the condition on null. Per-field, decide "drop with warning" (safe default; certainly correct for environment/ja3_digest/ja4_digest/rate_limit_api_id, which have no Cloudflare equivalent) vs. "add a real mapping" (geo_continent/geo_country_region/protocol/target_path) — the latter only after verifying against live Cloudflare or authoritative wirefilter docs, not by copying FieldMapper.ts unchecked.
  • Regression tests for both bugs (mutation-verified). Update cloudflare.md's field table and the environment/ja3_digest/ja4_digest reachability claim once fixed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions