All files / worker/handlers handleProtectedApi.ts

100% Statements 43/43
94.11% Branches 16/17
100% Functions 5/5
100% Lines 43/43

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 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192                                                                36x                 36x           2x 2x     34x                                                       36x 36x 27x 27x 27x 27x   9x           9x     27x 27x   15x 15x     15x           15x   15x         12x 1x   11x 7x   4x 4x       12x     12x 10x     12x     12x                                         10x 10x 10x   10x     1x     10x 10x 10x 19x                             24x 8x     16x    
import { AGENT_DATA_HEADER } from '../../shared/const'
import { IdentificationClient, SendResult } from '../fingerprint/identificationClient'
import { processRuleset } from '../fingerprint/ruleset'
import { hasContentType, isDocumentDestination } from '../utils/headers'
import { injectAgentProcessorScript } from '../scripts'
import { fetchOrigin } from '../utils/origin'
import { TypedEnv } from '../types'
import { getFallbackRuleAction, getRoutePrefix, isMonitorMode } from '../env'
import { setCorsHeadersForInstrumentation } from '../utils/request'
import { copyResponseWithNewHeaders } from '../utils/response'
 
/**
 * Parameters required for handling a protected API call.
 */
export type HandleProtectedApiCallParams = {
  /** The incoming HTTP request to be processed */
  request: Request
  /** Client for sending fingerprinting data to the ingress service */
  identificationClient: IdentificationClient
  /** The environment for the request*/
  env: TypedEnv
}
 
/**
 * Handles a protected API call by validating signals and processing the request.
 * For HTML responses, injects the agent processor script into the <head> element to process the agent data.
 */
export async function handleProtectedApiCall({
  request,
  identificationClient,
  env,
}: HandleProtectedApiCallParams): Promise<Response> {
  const [response, agentData] = await getResponseForProtectedCall({
    request,
    identificationClient,
    env,
  })
 
  /**
   * For HTML responses, inject the agent processor script into the <head> element to process the agent data.
   * */
  if (
    agentData &&
    hasContentType(response.headers, 'text/html') &&
    // This check protects against false-positive HTML requests triggered by a "fetch" call. (e.g. by htmx)
    isDocumentDestination(request.headers)
  ) {
    console.info('Injecting agent processor script into HTML response.')
    return injectAgentProcessorScript(response, agentData, getRoutePrefix(env))
  }
 
  return response
}
 
/**
 * Handles a protected API call by validating signals and processing the request.
 *
 * This function performs the following operations:
 * 1. Validates that the request contains required fingerprinting signals
 * 2. Sends the request to both ingress and origin services
 * 3. Merges headers from the ingress response into the origin response
 * 4. Returns the combined response with updated headers
 *
 * @param params - Configuration object containing request, ingress client, and error response
 *
 * @returns the `Response` and optional data that needs to be sent back to the agent. The `Headers`
 *          on the `Response` are mutable.
 */
async function getResponseForProtectedCall({
  request,
  identificationClient,
  env,
}: HandleProtectedApiCallParams): Promise<[response: Response, agentData: string | null]> {
  let ingressResponse: SendResult
  let originRequest: Request
  let signals: string
  let clientCookie: string | undefined
  let removeCookies: boolean
 
  try {
    const result = await IdentificationClient.parseIncomingRequest(request)
    signals = result.signals
    originRequest = result.originRequest
    clientCookie = result.clientCookie
    removeCookies = result.removeCookies
  } catch (e) {
    console.error('Failed to parse incoming request:', e)
 
    // Importantly, the request, not the originRequest is used here because
    // the unmodified request must be sent to the origin if the request
    // was not a valid instrumented request and the environment configuration
    // indicates the request should be forwarded on error
    return [await handleFallbackRule(request, env), null]
  }
 
  try {
    ingressResponse = await identificationClient.send(originRequest, signals, clientCookie)
  } catch (error) {
    console.error('Error sending request to ingress service:', error)
    const response = await handleFallbackRule(originRequest, env)
 
    // Make a copy of the headers because they are immutable
    const originResponseHeaders = new Headers(response.headers)
 
    // The identification could not be completed but the request was a
    // valid instrumented request. As a result, the CORS headers
    // need to be set appropriately in the response so the browser
    // will not fail the request.
    setCorsHeadersForInstrumentation(request, originResponseHeaders)
 
    return [copyResponseWithNewHeaders(response, originResponseHeaders), null]
  }
 
  let originResponse: Response
 
  if (isMonitorMode(env)) {
    originResponse = await fetchOrigin(originRequest)
  } else {
    if (ingressResponse.ruleAction) {
      originResponse = await processRuleset(ingressResponse.ruleAction, originRequest, env)
    } else {
      console.warn('No ruleset processor found for ingress response, using fallback rule.')
      originResponse = await processRuleset(getFallbackRuleAction(env), originRequest, env)
    }
  }
 
  const originResponseHeaders = new Headers(originResponse.headers)
  // For requests whose destination is a document (these are typically triggered by submitting a form or clicking a link)
  // it doesn't make sense to set headers from ingress, because the browser will discard them anyway
  if (!isDocumentDestination(request.headers)) {
    setHeadersFromIngressToOrigin(ingressResponse, originResponseHeaders, removeCookies)
  }
 
  setCorsHeadersForInstrumentation(request, originResponseHeaders)
 
  // Re-create the response, because by default its headers are immutable, even if we were to use `originResponse.clone()`
  return [copyResponseWithNewHeaders(originResponse, originResponseHeaders), ingressResponse.agentData]
}
 
/**
 * Merges headers from the ingress response into the origin response headers.
 *
 * This function extracts agent data and set-cookie headers from the ingress response
 * and adds them to the origin response headers. The agent data is set as a custom
 * header, while cookie headers are appended to preserve existing cookies.
 *
 * @param ingressResponse - Result from the ingress service containing agent data and cookies
 * @param originResponseHeaders - Mutable headers object from the origin response to be modified
 * @param includedCrossOriginCredentials - true if the instrumented request is cross-origin and included credentials (i.e., cookies) for identification purposes
 * @param removeCookies - true if Set-Cookie header fields need to be removed from the response
 *
 */
function setHeadersFromIngressToOrigin(
  ingressResponse: SendResult,
  originResponseHeaders: Headers,
  removeCookies: boolean
) {
  const { agentData, setCookieHeaders } = ingressResponse
  console.debug('Adding agent data header', agentData)
  originResponseHeaders.set(AGENT_DATA_HEADER, agentData)
 
  if (removeCookies) {
    // Delete any cookies set by the origin, they would have been ignored
    // by the browser if the request was not instrumented.
    originResponseHeaders.delete('Set-Cookie')
  }
 
  Eif (setCookieHeaders?.length) {
    console.debug('Adding set-cookie headers from ingress response', setCookieHeaders)
    setCookieHeaders.forEach((cookie) => {
      originResponseHeaders.append('Set-Cookie', cookie)
    })
  }
}
 
/**
 * Handles the fallback rule for the given request based on the mode.
 * If `isMonitorMode` is true, the function fetches data from the origin.
 * Otherwise, it processes the ruleset using the provided fallback rule.
 *
 * @param {Request} request - The incoming request object to be processed.
 * @param {TypedEnv} env - The environment for the request
 * @return {Promise<Response>} A promise that resolves to the response after processing the request.
 */
function handleFallbackRule(request: Request, env: TypedEnv): Promise<Response> {
  if (isMonitorMode(env)) {
    return fetchOrigin(request)
  }
 
  return processRuleset(getFallbackRuleAction(env), request, env)
}