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 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) }