Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/cloudflare

src/rulesets.ts

325 lines · 10.8 KB · TypeScript
import { findZone } from './analytics.ts'
import {
  cloudflareAuthHeaders,
  pickAuthOptions,
  type CloudflareAuthOptions,
} from './auth.ts'

const BASE_URL = 'https://api.cloudflare.com/client/v4'
const DEFAULT_PHASE = 'http_request_firewall_custom'
const IDENTIFIER = /^[A-Za-z0-9_-]+$/
const ACTIONS = new Set(['block', 'challenge', 'js_challenge', 'managed_challenge', 'log'])

function normalizeArray(value, fallback = []) {
  if (value === undefined || value === null) return fallback
  return Array.isArray(value) ? value : [value]
}

function quoteExpressionString(value) {
  return '"' + String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
}

function assertIdentifier(value, label) {
  const identifier = String(value || '').trim()
  if (!IDENTIFIER.test(identifier)) {
    throw new Error(label + ' must contain only letters, numbers, underscores, or hyphens.')
  }
  return identifier
}

function normalizeAction(action = 'block') {
  const value = String(action || '').trim()
  if (!ACTIONS.has(value)) {
    throw new Error('action must be one of ' + Array.from(ACTIONS).join(', ') + '.')
  }
  return value
}

function normalizeBlockResponse(response = {}) {
  const statusCode = Number(response.status_code ?? response.statusCode ?? 404)
  if (!Number.isInteger(statusCode) || statusCode < 100 || statusCode > 599) {
    throw new Error('response status code must be an HTTP status code.')
  }
  return {
    status_code: statusCode,
    content: String(response.content ?? 'Not found'),
    content_type: String(response.content_type ?? response.contentType ?? 'text/plain'),
  }
}

function buildBlockActionParameters(params) {
  if (params.response === false) return undefined
  const response = params.response && typeof params.response === 'object' ? params.response : {}
  return {
    response: normalizeBlockResponse({
      ...response,
      status_code: params.responseStatusCode ?? params.statusCode ?? response.status_code ?? response.statusCode,
      content: params.responseContent ?? response.content,
      content_type: params.responseContentType ?? params.contentType ?? response.content_type ?? response.contentType,
    }),
  }
}

function hasBlockResponseParams(params) {
  return Boolean(
    params.response ||
      params.responseStatusCode ||
      params.statusCode ||
      params.responseContent ||
      params.responseContentType ||
      params.contentType,
  )
}

function summarizeErrors(errors) {
  return normalizeArray(errors).map((error) => ({
    code: error?.code,
    message: error?.message || String(error),
  }))
}

async function cloudflareRequest(path, init = {}, auth = {}) {
  const response = await fetch(BASE_URL + path, {
    ...init,
    headers: {
      ...cloudflareAuthHeaders(pickAuthOptions(auth)),
      ...(init.headers || {}),
    },
  })
  const body = await response.json().catch(() => null)
  if (!body) {
    throw new Error('Cloudflare API returned non-JSON (' + response.status + ').')
  }
  return {
    status: response.status,
    success: body.success === true,
    result: body.result,
    errors: body.errors || [],
    messages: body.messages || [],
  }
}

async function resolveZoneId(params) {
  if (params.zoneId) return String(params.zoneId)
  const zone = await findZone(params)
  if (!zone.zone?.id) {
    throw new Error('No Cloudflare zone found for ' + (params.name || params.zoneName || 'input') + '.')
  }
  return zone.zone.id
}

function normalizeRule(rule) {
  if (!rule || typeof rule !== 'object') throw new Error('rule is required.')
  const expression = String(rule.expression || '').trim()
  if (!expression) throw new Error('rule.expression is required.')
  const normalized = {
    action: normalizeAction(rule.action),
    expression,
    description: String(rule.description || 'Kody-managed Cloudflare rule'),
    enabled: rule.enabled !== false,
  }
  if (rule.ref) normalized.ref = assertIdentifier(rule.ref, 'rule.ref')
  const actionParameters = rule.action_parameters || rule.actionParameters
  if (actionParameters && typeof actionParameters === 'object') {
    normalized.action_parameters = actionParameters
  }
  return normalized
}

/**
 * Get the zone phase entry point ruleset.
 */
export async function getZoneEntrypointRuleset(params = {}) {
  const zoneId = await resolveZoneId(params)
  const phase = String(params.phase || DEFAULT_PHASE)
  const result = await cloudflareRequest(
    '/zones/' + encodeURIComponent(zoneId) + '/rulesets/phases/' + encodeURIComponent(phase) + '/entrypoint',
    {},
    params,
  )
  return { ...result, zoneId, phase }
}

/**
 * Build a Cloudflare Rules language expression for bogus crawler paths.
 */
export function buildBogusRouteExpression(params = {}) {
  const contains = normalizeArray(params.contains, ['/node_modules/'])
  const prefixes = normalizeArray(params.prefixes, ['/calls/'])
  const suffixes = normalizeArray(params.suffixes, [
    '/Express.js',
    '/Next.js',
    '/React.js',
    '/index.js',
    '/meta.json',
    '/u003e',
  ])
  const exactPaths = normalizeArray(params.exactPaths, [])

  const clauses = [
    ...contains.map((value) => 'http.request.uri.path contains ' + quoteExpressionString(value)),
    ...exactPaths.map((value) => 'http.request.uri.path eq ' + quoteExpressionString(value)),
  ]
  if (prefixes.length && suffixes.length) {
    const prefixClause = prefixes
      .map((value) => 'starts_with(http.request.uri.path, ' + quoteExpressionString(value) + ')')
      .join(' or ')
    const suffixClause = suffixes
      .map((value) => 'ends_with(http.request.uri.path, ' + quoteExpressionString(value) + ')')
      .join(' or ')
    clauses.push('((' + prefixClause + ') and (' + suffixClause + '))')
  }
  if (!clauses.length) throw new Error('At least one route matcher is required.')
  return clauses.length === 1 ? clauses[0] : '(' + clauses.join(' or ') + ')'
}

/**
 * Build a reusable custom ruleset rule for bogus crawler paths.
 */
export function buildBogusRouteRule(params = {}) {
  const action = params.action || 'block'
  const actionParameters = params.action_parameters || params.actionParameters
  const rule = {
    ref: params.ref || 'kody_bogus_crawler_routes',
    description: params.description || 'Block bogus crawler routes before they reach origin',
    action,
    enabled: params.enabled,
    expression: params.expression || buildBogusRouteExpression(params),
  }
  if (actionParameters) {
    rule.action_parameters = actionParameters
  } else if (action === 'block' && hasBlockResponseParams(params)) {
    rule.action_parameters = buildBlockActionParameters(params)
  }
  return normalizeRule({
    ...rule,
  })
}

function upsertRule(rules, rule) {
  const ref = rule.ref
  if (!ref) return [...rules, rule]
  const index = rules.findIndex((existing) => existing.ref === ref)
  if (index === -1) return [...rules, rule]
  return rules.map((existing, itemIndex) => (itemIndex === index ? { ...existing, ...rule } : existing))
}

/**
 * Preview or update a zone custom firewall entry point ruleset.
 *
 * Defaults to dry-run. Pass `apply: true` or `dryRun: false` to write the rule.
 * Requires a Cloudflare token with zone Rulesets/WAF write permission when
 * applying.
 */
export async function upsertZoneCustomFirewallRule(params = {}) {
  const zoneId = await resolveZoneId(params)
  const phase = String(params.phase || DEFAULT_PHASE)
  const rule = normalizeRule(params.rule || buildBogusRouteRule(params))
  const dryRun = params.apply === true ? false : params.dryRun !== false
  const current = await getZoneEntrypointRuleset({ ...pickAuthOptions(params), zoneId, phase }).catch((error) => ({
    status: 0,
    success: false,
    result: null,
    errors: [{ message: error instanceof Error ? error.message : String(error) }],
    messages: [],
  }))
  const canCreateEntrypoint = current.status === 404
  if (!dryRun && !current.success && !canCreateEntrypoint) {
    return {
      status: 'failed',
      reason: 'could_not_read_entrypoint',
      zoneId,
      phase,
      rule,
      currentStatus: current.status,
      currentErrors: summarizeErrors(current.errors),
      notes: [
        'No Cloudflare rule was changed.',
        'Applying a phase entry point ruleset replaces the complete rules list.',
        'Grant the token Zone WAF read/write permission, then retry apply: true.',
      ],
    }
  }
  const existingRules = normalizeArray(current.result?.rules)
  const nextRules = upsertRule(existingRules, rule)
  const body = {
    rules: nextRules,
  }
  const endpoint =
    '/zones/' + encodeURIComponent(zoneId) + '/rulesets/phases/' + encodeURIComponent(phase) + '/entrypoint'

  if (dryRun) {
    return {
      status: 'dry_run',
      zoneId,
      phase,
      endpoint,
      method: 'PUT',
      rule,
      currentStatus: current.status,
      currentSuccess: current.success,
      currentRules: existingRules.length,
      nextRules: nextRules.length,
      body,
      currentErrors: summarizeErrors(current.errors),
      notes: [
        'No Cloudflare rule was changed.',
        'Pass apply: true to write this entry point ruleset.',
        'Applying replaces the complete phase entry point ruleset with the returned body.',
      ],
    }
  }

  const result = await cloudflareRequest(
    endpoint,
    {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    },
    params,
  )
  return {
    status: result.success ? 'applied' : 'failed',
    zoneId,
    phase,
    endpoint,
    rule,
    result: result.result,
    errors: result.errors,
    messages: result.messages,
  }
}

export type CloudflareRulesetsInput = CloudflareAuthOptions & {
  mode?: 'get-entrypoint' | 'expression' | 'rule'
  zoneId?: string
  zoneName?: string
  name?: string
  phase?: string
  apply?: boolean
  dryRun?: boolean
  rule?: Record<string, unknown>
  ref?: string
  action?: string
  expression?: string
  [key: string]: unknown
}

/**
 * Preview or apply a zone WAF custom firewall rule (defaults to dry-run).
 * @param params.zoneName - Zone hostname to target.
 * @param params.account - Token alias (`default`, `kody`, `pages`); defaults to `default`.
 * @param params.apiTokenSecret - Explicit Kody secret name when no alias fits (not a raw token).
 * @returns Dry-run preview with the full entrypoint body, or apply result when `apply: true`.
 * @example
 * import rulesets from 'kody:@kentcdodds/cloudflare/rulesets'
 * const result = await rulesets({ zoneName: 'example.com' })
 * // => { status: 'dry_run', rule: { ... }, body: { rules: [...] }, ... }
 */
export default async function cloudflareRulesets(params: CloudflareRulesetsInput = {}) {
  if (params.mode === 'get-entrypoint') return await getZoneEntrypointRuleset(params)
  if (params.mode === 'expression') return { expression: buildBogusRouteExpression(params) }
  if (params.mode === 'rule') return { rule: buildBogusRouteRule(params) }
  return await upsertZoneCustomFirewallRule(params)
}