| 1 | import * as contentScanning from "@distilled.cloud/cloudflare/content-scanning"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as Predicate from "effect/Predicate"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | |
| 8 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 9 | import { isResolved } from "../../Diff.ts"; |
| 10 | import * as Provider from "../../Provider.ts"; |
| 11 | import { Resource } from "../../Resource.ts"; |
| 12 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 13 | import type { Providers } from "../Providers.ts"; |
| 14 | import { listAllZones } from "../Zone/lookup.ts"; |
| 15 | |
| 16 | const TypeId = "Cloudflare.ContentScanning.Expression" as const; |
| 17 | type TypeId = typeof TypeId; |
| 18 | |
| 19 | export interface ExpressionProps { |
| 20 | /** |
| 21 | * Zone the custom scan expression belongs to. Stable — moving an |
| 22 | * expression between zones triggers a replacement. |
| 23 | */ |
| 24 | zoneId: string; |
| 25 | /** |
| 26 | * Ruleset-language expression that locates the content to scan inside |
| 27 | * the request — e.g. |
| 28 | * `lookup_json_string(http.request.body.raw, "file")`. |
| 29 | * |
| 30 | * Immutable — the API has no update endpoint for expressions, so |
| 31 | * changing the payload triggers a replacement (create new, delete old). |
| 32 | */ |
| 33 | payload: string; |
| 34 | } |
| 35 | |
| 36 | export interface ExpressionAttributes { |
| 37 | /** Cloudflare-assigned identifier of the custom scan expression. */ |
| 38 | expressionId: string; |
| 39 | /** Zone the expression belongs to. */ |
| 40 | zoneId: string; |
| 41 | /** The ruleset-language expression locating the content to scan. */ |
| 42 | payload: string; |
| 43 | } |
| 44 | |
| 45 | export type Expression = Resource< |
| 46 | TypeId, |
| 47 | ExpressionProps, |
| 48 | ExpressionAttributes, |
| 49 | never, |
| 50 | Providers |
| 51 | >; |
| 52 | |
| 53 | /** |
| 54 | * A custom scan expression ("payload") for WAF Content Scanning — tells the |
| 55 | * malicious-uploads scanner where to find encoded or nested content in the |
| 56 | * request body (`/zones/{zone_id}/content-upload-scan/payloads`). |
| 57 | * |
| 58 | * An expression's identity is its `payload` text within the zone: the API |
| 59 | * offers create/list/delete only (no update), so changing `payload` |
| 60 | * triggers a replacement. The zone must have Content Scanning enabled (see |
| 61 | * `Cloudflare.ContentScanning.ContentScanning`) — payload calls on a zone where scanning is |
| 62 | * disabled fail with the typed `ContentScanningNotEnabled` error. |
| 63 | * |
| 64 | * Safety: expressions carry no ownership markers. When there is no prior |
| 65 | * state, `read` scans the zone for an expression with the same payload text |
| 66 | * and reports it as `Unowned`, so the engine refuses to take it over unless |
| 67 | * `--adopt` (or `adopt(true)`) is set. |
| 68 | * ### Creating expressions |
| 69 | * **Example:** Scan a JSON-embedded file field |
| 70 | * ```typescript |
| 71 | * const scanning = yield* Cloudflare.ContentScanning.ContentScanning("UploadScanning", { |
| 72 | * zoneId: zone.zoneId, |
| 73 | * }); |
| 74 | * |
| 75 | * yield* Cloudflare.ContentScanning.Expression("ScanJsonFile", { |
| 76 | * zoneId: scanning.zoneId, |
| 77 | * payload: 'lookup_json_string(http.request.body.raw, "file")', |
| 78 | * }); |
| 79 | * ``` |
| 80 | * |
| 81 | * **Example:** Scan a base64-encoded form field |
| 82 | * ```typescript |
| 83 | * yield* Cloudflare.ContentScanning.Expression("ScanBase64Document", { |
| 84 | * zoneId: scanning.zoneId, |
| 85 | * payload: 'base64_decode(http.request.body.form["document"][0])', |
| 86 | * }); |
| 87 | * ``` |
| 88 | * |
| 89 | * @see https://developers.cloudflare.com/waf/detections/malicious-uploads/#add-custom-scan-expressions |
| 90 | * |
| 91 | * @resource |
| 92 | * @product Content Scanning |
| 93 | * @category Application Security |
| 94 | */ |
| 95 | export const Expression = Resource<Expression>(TypeId); |
| 96 | |
| 97 | /** |
| 98 | * Returns true if the given value is a Expression resource. |
| 99 | */ |
| 100 | export const isExpression = (value: unknown): value is Expression => |
| 101 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 102 | |
| 103 | /** |
| 104 | * Cloudflare accepted the create call but the expression did not appear in |
| 105 | * the returned list — an API anomaly that should never happen in practice. |
| 106 | */ |
| 107 | export class ExpressionCreateAnomaly extends Data.TaggedError( |
| 108 | "ExpressionCreateAnomaly", |
| 109 | )<{ |
| 110 | readonly zoneId: string; |
| 111 | readonly payload: string; |
| 112 | }> {} |
| 113 | |
| 114 | export const ExpressionProvider = () => |
| 115 | Provider.succeed(Expression, { |
| 116 | stables: ["expressionId", "zoneId", "payload"], |
| 117 | |
| 118 | // Custom scan expressions are a zone-scoped Content Scanning feature. Fan |
| 119 | // out over every zone in the account, exhaustively paginate each zone's |
| 120 | // payloads, and hydrate each into the same Attributes shape `read` |
| 121 | // produces. Zones without Content Scanning enabled (or not entitled) reject |
| 122 | // payload calls with the typed `ContentScanningNotEnabled`/`Forbidden` |
| 123 | // tags, and a deleted zone with `InvalidRoute` — skip all three. |
| 124 | list: Effect.fn(function* () { |
| 125 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 126 | const allZones = yield* listAllZones(accountId); |
| 127 | const rows = yield* Effect.forEach( |
| 128 | allZones, |
| 129 | (zone) => |
| 130 | contentScanning.listPayloads.pages({ zoneId: zone.id }).pipe( |
| 131 | Stream.runCollect, |
| 132 | Effect.map((chunk) => |
| 133 | Array.from(chunk).flatMap((page) => |
| 134 | (page.result ?? []).map((expression) => |
| 135 | toAttributes(zone.id, expression), |
| 136 | ), |
| 137 | ), |
| 138 | ), |
| 139 | Effect.catchTag("ContentScanningNotEnabled", () => |
| 140 | Effect.succeed([]), |
| 141 | ), |
| 142 | Effect.catchTag("Forbidden", () => Effect.succeed([])), |
| 143 | Effect.catchTag("InvalidRoute", () => Effect.succeed([])), |
| 144 | ), |
| 145 | { concurrency: 10 }, |
| 146 | ); |
| 147 | return rows.flat(); |
| 148 | }), |
| 149 | |
| 150 | diff: Effect.fn(function* ({ olds, news, output }) { |
| 151 | // zoneId is Input<string>; compare only once both sides are concrete. |
| 152 | if (!isResolved(news)) return undefined; |
| 153 | const old = olds !== undefined && isResolved(olds) ? olds : undefined; |
| 154 | // The payload is the expression's identity — no update endpoint. |
| 155 | const oldPayload = output?.payload ?? old?.payload; |
| 156 | if (oldPayload !== undefined && oldPayload !== news.payload) { |
| 157 | return { action: "replace" } as const; |
| 158 | } |
| 159 | const oldZoneId = output?.zoneId ?? old?.zoneId; |
| 160 | if (oldZoneId !== undefined && oldZoneId !== news.zoneId) { |
| 161 | return { action: "replace" } as const; |
| 162 | } |
| 163 | return undefined; |
| 164 | }), |
| 165 | |
| 166 | read: Effect.fn(function* ({ output, olds }) { |
| 167 | const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined); |
| 168 | if (!zoneId) return undefined; |
| 169 | |
| 170 | const expressions = yield* listExpressions(zoneId); |
| 171 | // Scanning disabled or zone gone — no expressions are observable. |
| 172 | if (expressions === undefined) return undefined; |
| 173 | |
| 174 | // Owned path: refresh by our persisted expression id. |
| 175 | if (output?.expressionId) { |
| 176 | const observed = expressions.find((e) => e.id === output.expressionId); |
| 177 | if (observed) return toAttributes(zoneId, observed); |
| 178 | } |
| 179 | |
| 180 | // Adoption path: an expression with this payload text may already |
| 181 | // exist on the zone. Expressions carry no ownership markers, so we |
| 182 | // cannot prove we created it — brand it `Unowned` so the engine |
| 183 | // refuses to take over unless `adopt` is set. |
| 184 | const payload = output?.payload ?? olds?.payload; |
| 185 | if (payload !== undefined) { |
| 186 | const observed = expressions.find((e) => e.payload === payload); |
| 187 | if (observed) return Unowned(toAttributes(zoneId, observed)); |
| 188 | } |
| 189 | return undefined; |
| 190 | }), |
| 191 | |
| 192 | reconcile: Effect.fn(function* ({ news, output }) { |
| 193 | // Inputs have been resolved to concrete strings by Plan. |
| 194 | const zoneId = news.zoneId as string; |
| 195 | |
| 196 | // 1. Observe — the expression id cached on `output` is a hint, not |
| 197 | // a guarantee: a missing expression falls through to the payload |
| 198 | // scan and then to create. (Scanning must be enabled here — a |
| 199 | // disabled zone fails with the typed ContentScanningNotEnabled.) |
| 200 | const expressionId = output?.expressionId; |
| 201 | let observed: ObservedExpression | undefined = expressionId |
| 202 | ? yield* findExpression(zoneId, (e) => e.id === expressionId) |
| 203 | : undefined; |
| 204 | |
| 205 | // 2. Fall back to matching by payload text. Ownership has already |
| 206 | // been verified upstream — `read` reports existing expressions as |
| 207 | // `Unowned` and the engine gates takeover behind the adopt policy |
| 208 | // before reconcile ever runs. |
| 209 | if (!observed) { |
| 210 | observed = yield* findExpression( |
| 211 | zoneId, |
| 212 | (e) => e.payload === news.payload, |
| 213 | ); |
| 214 | } |
| 215 | |
| 216 | // 3. Ensure — create when missing. The create call returns the full |
| 217 | // list of expressions; find ours by payload text. |
| 218 | if (!observed) { |
| 219 | const created = yield* contentScanning.createPayload({ |
| 220 | zoneId, |
| 221 | body: [{ payload: news.payload }], |
| 222 | }); |
| 223 | observed = created.result.find((e) => e.payload === news.payload); |
| 224 | if (!observed) { |
| 225 | return yield* Effect.fail( |
| 226 | new ExpressionCreateAnomaly({ |
| 227 | zoneId, |
| 228 | payload: news.payload, |
| 229 | }), |
| 230 | ); |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | // 4. No sync step — expressions have nothing mutable beyond identity. |
| 235 | return toAttributes(zoneId, observed); |
| 236 | }), |
| 237 | |
| 238 | delete: Effect.fn(function* ({ output }) { |
| 239 | const { zoneId, expressionId } = output; |
| 240 | // Observe first — scanning disabled / zone gone / already deleted |
| 241 | // all mean there is nothing left to remove. |
| 242 | const expressions = yield* listExpressions(zoneId); |
| 243 | if (expressions === undefined) return; |
| 244 | if (!expressions.some((e) => e.id === expressionId)) return; |
| 245 | yield* contentScanning.deletePayload({ zoneId, expressionId }).pipe( |
| 246 | // A concurrent disable between observe and delete is "gone". |
| 247 | Effect.catchTag("ContentScanningNotEnabled", () => Effect.void), |
| 248 | Effect.catchTag("InvalidRoute", () => Effect.void), |
| 249 | ); |
| 250 | }), |
| 251 | }); |
| 252 | |
| 253 | type ObservedExpression = { id?: string | null; payload?: string | null }; |
| 254 | |
| 255 | /** |
| 256 | * Find the first expression on the zone matching `predicate`, terminating |
| 257 | * pagination as soon as a match is seen. Unlike {@link listExpressions}, |
| 258 | * "not observable" errors propagate (reconcile requires scanning enabled). |
| 259 | */ |
| 260 | const findExpression = ( |
| 261 | zoneId: string, |
| 262 | predicate: (e: ObservedExpression) => boolean, |
| 263 | ) => |
| 264 | contentScanning.listPayloads |
| 265 | .items({ zoneId }) |
| 266 | .pipe( |
| 267 | Stream.filter(predicate), |
| 268 | Stream.runHead, |
| 269 | Effect.map(Option.getOrUndefined), |
| 270 | ); |
| 271 | |
| 272 | /** |
| 273 | * List the zone's custom scan expressions, mapping "not observable" |
| 274 | * (scanning disabled on the zone, or the zone itself gone) to `undefined`. |
| 275 | */ |
| 276 | const listExpressions = (zoneId: string) => |
| 277 | contentScanning.listPayloads.items({ zoneId }).pipe( |
| 278 | Stream.runCollect, |
| 279 | Effect.map((chunk): readonly ObservedExpression[] | undefined => |
| 280 | Array.from(chunk), |
| 281 | ), |
| 282 | Effect.catchTag("ContentScanningNotEnabled", () => |
| 283 | Effect.succeed(undefined), |
| 284 | ), |
| 285 | Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)), |
| 286 | ); |
| 287 | |
| 288 | const toAttributes = ( |
| 289 | zoneId: string, |
| 290 | expression: ObservedExpression, |
| 291 | ): ExpressionAttributes => ({ |
| 292 | // Cloudflare always echoes both fields for a persisted expression. |
| 293 | expressionId: expression.id ?? "", |
| 294 | zoneId, |
| 295 | payload: expression.payload ?? "", |
| 296 | }); |