All files / src/core/services CloudflareRequestMetadata.ts

100% Statements 6/6
88.88% Branches 16/18
100% Functions 2/2
100% Lines 6/6

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 7628x                                                                                                             28x               47x 47x 185x   47x                
import { RequestCorrelation } from '@/core/observability/RequestCorrelation'
 
import type { RequestFacts, RequestMetadata } from '@bayudwiyansatria/core'
 
/**
 * Reads Cloudflare's account of a request.
 *
 * @remarks
 * The single place in this codebase that knows what `cf-connecting-ip`,
 * `cf-ray`, and `request.cf` are. Before 1.2.0 those names were read inline by
 * the security middleware and the request logger, which is what kept two
 * otherwise platform-agnostic pieces of HTTP plumbing tied to one vendor.
 *
 * Three of the choices here are security decisions rather than parsing details:
 *
 * - **`CF-Connecting-IP`, never `X-Forwarded-For`.** Cloudflare sets the former
 *   at the edge and overwrites it on every request; the latter is whatever the
 *   caller sent, because Cloudflare *appends* to it rather than replacing it.
 *   Anything trusting `X-Forwarded-For` can be told any address it likes —
 *   including by whatever rate limiting or audit trail reads the value back.
 * - **`CF-Connecting-IPv6` ahead of it.** A zone with Pseudo-IPv4 set to
 *   overwrite headers replaces `CF-Connecting-IP` with a synthetic address in
 *   `240.0.0.0/4` for every IPv6 client. It looks like an ordinary address, so
 *   nothing downstream notices — but it is reserved space that routes nowhere,
 *   geolocates to nothing, and need not be stable between requests from one
 *   client. That is worse than logging no address at all, because it reads as a
 *   real answer: an abuse investigation follows it and finds nobody. Reading
 *   the IPv6 header first means the true address wins wherever the zone offers
 *   it, whatever the Pseudo-IPv4 setting happens to be.
 * - **`CF-Ray` as the request id.** It is the id Cloudflare's own logs use, so a
 *   line here and a line in the dashboard can be joined. When it is absent — a
 *   local `wrangler dev` run, a test — a UUID stands in, so correlation degrades
 *   to per-process rather than disappearing.
 *
 * ## Why an inbound header wins over `CF-Ray`
 *
 * A Service Binding hop is a subrequest the edge never sees, so the callee gets
 * no `CF-Ray` of its own and would otherwise mint a UUID — one request, three
 * Workers, three unrelated ids, and no way to walk a failure back to its cause.
 * {@link RequestCorrelation.HEADER} is what the caller sends to prevent that,
 * and it is read first precisely so the *upstream* id is the one the whole
 * fan-out shares.
 *
 * The cost is that the value is attacker-controlled at the edge: a public
 * caller may send any `X-Request-Id` it likes. That is tolerable because the id
 * does nothing but join log lines — the worst a forged one achieves is two
 * unrelated requests appearing under one id in a query. It is not a credential,
 * nothing authorises against it, and nothing here may start.
 *
 * @class
 *
 * @author Bayu Dwiyan Satria
 * @version 1.2.1
 * @since 1.0.0
 */
export class CloudflareRequestMetadata implements RequestMetadata<Request> {
  /**
   * Reads what Cloudflare knows about a request.
   *
   * @param request The incoming request.
   * @returns The facts, with `colo` and `country` absent outside the edge.
   */
  public read(request: Request): RequestFacts {
    const cf = request ? (request.cf as IncomingRequestCfProperties | undefined) : undefined
    const header = (name: string): string | undefined =>
      request && request.headers ? request.headers.get(name) || undefined : undefined
 
    return {
      requestId: header(RequestCorrelation.HEADER) || header('cf-ray') || crypto.randomUUID(),
      clientIp: header('cf-connecting-ipv6') || header('cf-connecting-ip') || 'unknown',
      colo: cf ? cf.colo : undefined,
      country: cf ? (cf.country as string) : undefined
    }
  }
}