tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

HTTPRequest.ts (20478B)


      1 /**
      2 * @license
      3 * Copyright 2020 Google Inc.
      4 * SPDX-License-Identifier: Apache-2.0
      5 */
      6 import type {Protocol} from 'devtools-protocol';
      7 
      8 import type {ProtocolError} from '../common/Errors.js';
      9 import {debugError, isString} from '../common/util.js';
     10 import {assert} from '../util/assert.js';
     11 import {typedArrayToBase64} from '../util/encoding.js';
     12 
     13 import type {CDPSession} from './CDPSession.js';
     14 import type {Frame} from './Frame.js';
     15 import type {HTTPResponse} from './HTTPResponse.js';
     16 
     17 /**
     18 * @public
     19 */
     20 export interface ContinueRequestOverrides {
     21  /**
     22   * If set, the request URL will change. This is not a redirect.
     23   */
     24  url?: string;
     25  method?: string;
     26  postData?: string;
     27  headers?: Record<string, string>;
     28 }
     29 
     30 /**
     31 * @public
     32 */
     33 export interface InterceptResolutionState {
     34  action: InterceptResolutionAction;
     35  priority?: number;
     36 }
     37 
     38 /**
     39 * Required response data to fulfill a request with.
     40 *
     41 * @public
     42 */
     43 export interface ResponseForRequest {
     44  status: number;
     45  /**
     46   * Optional response headers.
     47   *
     48   * The record values will be converted to string following:
     49   * Arrays' values will be mapped to String
     50   * (Used when you need multiple headers with the same name).
     51   * Non-arrays will be converted to String.
     52   */
     53  headers: Record<string, string | string[] | unknown>;
     54  contentType: string;
     55  body: string | Uint8Array;
     56 }
     57 
     58 /**
     59 * Resource types for HTTPRequests as perceived by the rendering engine.
     60 *
     61 * @public
     62 */
     63 export type ResourceType = Lowercase<Protocol.Network.ResourceType>;
     64 
     65 /**
     66 * The default cooperative request interception resolution priority
     67 *
     68 * @public
     69 */
     70 export const DEFAULT_INTERCEPT_RESOLUTION_PRIORITY = 0;
     71 
     72 /**
     73 * Represents an HTTP request sent by a page.
     74 * @remarks
     75 *
     76 * Whenever the page sends a request, such as for a network resource, the
     77 * following events are emitted by Puppeteer's `page`:
     78 *
     79 * - `request`: emitted when the request is issued by the page.
     80 *
     81 * - `requestfinished` - emitted when the response body is downloaded and the
     82 *   request is complete.
     83 *
     84 * If request fails at some point, then instead of `requestfinished` event the
     85 * `requestfailed` event is emitted.
     86 *
     87 * All of these events provide an instance of `HTTPRequest` representing the
     88 * request that occurred:
     89 *
     90 * ```
     91 * page.on('request', request => ...)
     92 * ```
     93 *
     94 * NOTE: HTTP Error responses, such as 404 or 503, are still successful
     95 * responses from HTTP standpoint, so request will complete with
     96 * `requestfinished` event.
     97 *
     98 * If request gets a 'redirect' response, the request is successfully finished
     99 * with the `requestfinished` event, and a new request is issued to a
    100 * redirected url.
    101 *
    102 * @public
    103 */
    104 export abstract class HTTPRequest {
    105  /**
    106   * @internal
    107   */
    108  abstract get id(): string;
    109 
    110  /**
    111   * @internal
    112   */
    113  _interceptionId: string | undefined;
    114  /**
    115   * @internal
    116   */
    117  _failureText: string | null = null;
    118  /**
    119   * @internal
    120   */
    121  _response: HTTPResponse | null = null;
    122  /**
    123   * @internal
    124   */
    125  _fromMemoryCache = false;
    126  /**
    127   * @internal
    128   */
    129  _redirectChain: HTTPRequest[] = [];
    130 
    131  /**
    132   * @internal
    133   */
    134  protected interception: {
    135    enabled: boolean;
    136    handled: boolean;
    137    handlers: Array<() => void | PromiseLike<any>>;
    138    resolutionState: InterceptResolutionState;
    139    requestOverrides: ContinueRequestOverrides;
    140    response: Partial<ResponseForRequest> | null;
    141    abortReason: Protocol.Network.ErrorReason | null;
    142  } = {
    143    enabled: false,
    144    handled: false,
    145    handlers: [],
    146    resolutionState: {
    147      action: InterceptResolutionAction.None,
    148    },
    149    requestOverrides: {},
    150    response: null,
    151    abortReason: null,
    152  };
    153 
    154  /**
    155   * Warning! Using this client can break Puppeteer. Use with caution.
    156   *
    157   * @experimental
    158   */
    159  abstract get client(): CDPSession;
    160 
    161  /**
    162   * @internal
    163   */
    164  constructor() {}
    165 
    166  /**
    167   * The URL of the request
    168   */
    169  abstract url(): string;
    170 
    171  /**
    172   * The `ContinueRequestOverrides` that will be used
    173   * if the interception is allowed to continue (ie, `abort()` and
    174   * `respond()` aren't called).
    175   */
    176  continueRequestOverrides(): ContinueRequestOverrides {
    177    assert(this.interception.enabled, 'Request Interception is not enabled!');
    178    return this.interception.requestOverrides;
    179  }
    180 
    181  /**
    182   * The `ResponseForRequest` that gets used if the
    183   * interception is allowed to respond (ie, `abort()` is not called).
    184   */
    185  responseForRequest(): Partial<ResponseForRequest> | null {
    186    assert(this.interception.enabled, 'Request Interception is not enabled!');
    187    return this.interception.response;
    188  }
    189 
    190  /**
    191   * The most recent reason for aborting the request
    192   */
    193  abortErrorReason(): Protocol.Network.ErrorReason | null {
    194    assert(this.interception.enabled, 'Request Interception is not enabled!');
    195    return this.interception.abortReason;
    196  }
    197 
    198  /**
    199   * An InterceptResolutionState object describing the current resolution
    200   * action and priority.
    201   *
    202   * InterceptResolutionState contains:
    203   * action: InterceptResolutionAction
    204   * priority?: number
    205   *
    206   * InterceptResolutionAction is one of: `abort`, `respond`, `continue`,
    207   * `disabled`, `none`, or `already-handled`.
    208   */
    209  interceptResolutionState(): InterceptResolutionState {
    210    if (!this.interception.enabled) {
    211      return {action: InterceptResolutionAction.Disabled};
    212    }
    213    if (this.interception.handled) {
    214      return {action: InterceptResolutionAction.AlreadyHandled};
    215    }
    216    return {...this.interception.resolutionState};
    217  }
    218 
    219  /**
    220   * Is `true` if the intercept resolution has already been handled,
    221   * `false` otherwise.
    222   */
    223  isInterceptResolutionHandled(): boolean {
    224    return this.interception.handled;
    225  }
    226 
    227  /**
    228   * Adds an async request handler to the processing queue.
    229   * Deferred handlers are not guaranteed to execute in any particular order,
    230   * but they are guaranteed to resolve before the request interception
    231   * is finalized.
    232   */
    233  enqueueInterceptAction(
    234    pendingHandler: () => void | PromiseLike<unknown>,
    235  ): void {
    236    this.interception.handlers.push(pendingHandler);
    237  }
    238 
    239  /**
    240   * @internal
    241   */
    242  abstract _abort(
    243    errorReason: Protocol.Network.ErrorReason | null,
    244  ): Promise<void>;
    245 
    246  /**
    247   * @internal
    248   */
    249  abstract _respond(response: Partial<ResponseForRequest>): Promise<void>;
    250 
    251  /**
    252   * @internal
    253   */
    254  abstract _continue(overrides: ContinueRequestOverrides): Promise<void>;
    255 
    256  /**
    257   * Awaits pending interception handlers and then decides how to fulfill
    258   * the request interception.
    259   */
    260  async finalizeInterceptions(): Promise<void> {
    261    await this.interception.handlers.reduce((promiseChain, interceptAction) => {
    262      return promiseChain.then(interceptAction);
    263    }, Promise.resolve());
    264    this.interception.handlers = [];
    265    const {action} = this.interceptResolutionState();
    266    switch (action) {
    267      case 'abort':
    268        return await this._abort(this.interception.abortReason);
    269      case 'respond':
    270        if (this.interception.response === null) {
    271          throw new Error('Response is missing for the interception');
    272        }
    273        return await this._respond(this.interception.response);
    274      case 'continue':
    275        return await this._continue(this.interception.requestOverrides);
    276    }
    277  }
    278 
    279  /**
    280   * Contains the request's resource type as it was perceived by the rendering
    281   * engine.
    282   */
    283  abstract resourceType(): ResourceType;
    284 
    285  /**
    286   * The method used (`GET`, `POST`, etc.)
    287   */
    288  abstract method(): string;
    289 
    290  /**
    291   * The request's post body, if any.
    292   */
    293  abstract postData(): string | undefined;
    294 
    295  /**
    296   * True when the request has POST data. Note that {@link HTTPRequest.postData}
    297   * might still be undefined when this flag is true when the data is too long
    298   * or not readily available in the decoded form. In that case, use
    299   * {@link HTTPRequest.fetchPostData}.
    300   */
    301  abstract hasPostData(): boolean;
    302 
    303  /**
    304   * Fetches the POST data for the request from the browser.
    305   */
    306  abstract fetchPostData(): Promise<string | undefined>;
    307 
    308  /**
    309   * An object with HTTP headers associated with the request. All
    310   * header names are lower-case.
    311   */
    312  abstract headers(): Record<string, string>;
    313 
    314  /**
    315   * A matching `HTTPResponse` object, or null if the response has not
    316   * been received yet.
    317   */
    318  abstract response(): HTTPResponse | null;
    319 
    320  /**
    321   * The frame that initiated the request, or null if navigating to
    322   * error pages.
    323   */
    324  abstract frame(): Frame | null;
    325 
    326  /**
    327   * True if the request is the driver of the current frame's navigation.
    328   */
    329  abstract isNavigationRequest(): boolean;
    330 
    331  /**
    332   * The initiator of the request.
    333   */
    334  abstract initiator(): Protocol.Network.Initiator | undefined;
    335 
    336  /**
    337   * A `redirectChain` is a chain of requests initiated to fetch a resource.
    338   * @remarks
    339   *
    340   * `redirectChain` is shared between all the requests of the same chain.
    341   *
    342   * For example, if the website `http://example.com` has a single redirect to
    343   * `https://example.com`, then the chain will contain one request:
    344   *
    345   * ```ts
    346   * const response = await page.goto('http://example.com');
    347   * const chain = response.request().redirectChain();
    348   * console.log(chain.length); // 1
    349   * console.log(chain[0].url()); // 'http://example.com'
    350   * ```
    351   *
    352   * If the website `https://google.com` has no redirects, then the chain will be empty:
    353   *
    354   * ```ts
    355   * const response = await page.goto('https://google.com');
    356   * const chain = response.request().redirectChain();
    357   * console.log(chain.length); // 0
    358   * ```
    359   *
    360   * @returns the chain of requests - if a server responds with at least a
    361   * single redirect, this chain will contain all requests that were redirected.
    362   */
    363  abstract redirectChain(): HTTPRequest[];
    364 
    365  /**
    366   * Access information about the request's failure.
    367   *
    368   * @remarks
    369   *
    370   * @example
    371   *
    372   * Example of logging all failed requests:
    373   *
    374   * ```ts
    375   * page.on('requestfailed', request => {
    376   *   console.log(request.url() + ' ' + request.failure().errorText);
    377   * });
    378   * ```
    379   *
    380   * @returns `null` unless the request failed. If the request fails this can
    381   * return an object with `errorText` containing a human-readable error
    382   * message, e.g. `net::ERR_FAILED`. It is not guaranteed that there will be
    383   * failure text if the request fails.
    384   */
    385  abstract failure(): {errorText: string} | null;
    386 
    387  #canBeIntercepted(): boolean {
    388    return !this.url().startsWith('data:') && !this._fromMemoryCache;
    389  }
    390 
    391  /**
    392   * Continues request with optional request overrides.
    393   *
    394   * @example
    395   *
    396   * ```ts
    397   * await page.setRequestInterception(true);
    398   * page.on('request', request => {
    399   *   // Override headers
    400   *   const headers = Object.assign({}, request.headers(), {
    401   *     foo: 'bar', // set "foo" header
    402   *     origin: undefined, // remove "origin" header
    403   *   });
    404   *   request.continue({headers});
    405   * });
    406   * ```
    407   *
    408   * @param overrides - optional overrides to apply to the request.
    409   * @param priority - If provided, intercept is resolved using cooperative
    410   * handling rules. Otherwise, intercept is resolved immediately.
    411   *
    412   * @remarks
    413   *
    414   * To use this, request interception should be enabled with
    415   * {@link Page.setRequestInterception}.
    416   *
    417   * Exception is immediately thrown if the request interception is not enabled.
    418   */
    419  async continue(
    420    overrides: ContinueRequestOverrides = {},
    421    priority?: number,
    422  ): Promise<void> {
    423    if (!this.#canBeIntercepted()) {
    424      return;
    425    }
    426    assert(this.interception.enabled, 'Request Interception is not enabled!');
    427    assert(!this.interception.handled, 'Request is already handled!');
    428    if (priority === undefined) {
    429      return await this._continue(overrides);
    430    }
    431    this.interception.requestOverrides = overrides;
    432    if (
    433      this.interception.resolutionState.priority === undefined ||
    434      priority > this.interception.resolutionState.priority
    435    ) {
    436      this.interception.resolutionState = {
    437        action: InterceptResolutionAction.Continue,
    438        priority,
    439      };
    440      return;
    441    }
    442    if (priority === this.interception.resolutionState.priority) {
    443      if (
    444        this.interception.resolutionState.action === 'abort' ||
    445        this.interception.resolutionState.action === 'respond'
    446      ) {
    447        return;
    448      }
    449      this.interception.resolutionState.action =
    450        InterceptResolutionAction.Continue;
    451    }
    452    return;
    453  }
    454 
    455  /**
    456   * Fulfills a request with the given response.
    457   *
    458   * @example
    459   * An example of fulfilling all requests with 404 responses:
    460   *
    461   * ```ts
    462   * await page.setRequestInterception(true);
    463   * page.on('request', request => {
    464   *   request.respond({
    465   *     status: 404,
    466   *     contentType: 'text/plain',
    467   *     body: 'Not Found!',
    468   *   });
    469   * });
    470   * ```
    471   *
    472   * NOTE: Mocking responses for dataURL requests is not supported.
    473   * Calling `request.respond` for a dataURL request is a noop.
    474   *
    475   * @param response - the response to fulfill the request with.
    476   * @param priority - If provided, intercept is resolved using
    477   * cooperative handling rules. Otherwise, intercept is resolved
    478   * immediately.
    479   *
    480   * @remarks
    481   *
    482   * To use this, request
    483   * interception should be enabled with {@link Page.setRequestInterception}.
    484   *
    485   * Exception is immediately thrown if the request interception is not enabled.
    486   */
    487  async respond(
    488    response: Partial<ResponseForRequest>,
    489    priority?: number,
    490  ): Promise<void> {
    491    if (!this.#canBeIntercepted()) {
    492      return;
    493    }
    494    assert(this.interception.enabled, 'Request Interception is not enabled!');
    495    assert(!this.interception.handled, 'Request is already handled!');
    496    if (priority === undefined) {
    497      return await this._respond(response);
    498    }
    499    this.interception.response = response;
    500    if (
    501      this.interception.resolutionState.priority === undefined ||
    502      priority > this.interception.resolutionState.priority
    503    ) {
    504      this.interception.resolutionState = {
    505        action: InterceptResolutionAction.Respond,
    506        priority,
    507      };
    508      return;
    509    }
    510    if (priority === this.interception.resolutionState.priority) {
    511      if (this.interception.resolutionState.action === 'abort') {
    512        return;
    513      }
    514      this.interception.resolutionState.action =
    515        InterceptResolutionAction.Respond;
    516    }
    517  }
    518 
    519  /**
    520   * Aborts a request.
    521   *
    522   * @param errorCode - optional error code to provide.
    523   * @param priority - If provided, intercept is resolved using
    524   * cooperative handling rules. Otherwise, intercept is resolved
    525   * immediately.
    526   *
    527   * @remarks
    528   *
    529   * To use this, request interception should be enabled with
    530   * {@link Page.setRequestInterception}. If it is not enabled, this method will
    531   * throw an exception immediately.
    532   */
    533  async abort(
    534    errorCode: ErrorCode = 'failed',
    535    priority?: number,
    536  ): Promise<void> {
    537    if (!this.#canBeIntercepted()) {
    538      return;
    539    }
    540    const errorReason = errorReasons[errorCode];
    541    assert(errorReason, 'Unknown error code: ' + errorCode);
    542    assert(this.interception.enabled, 'Request Interception is not enabled!');
    543    assert(!this.interception.handled, 'Request is already handled!');
    544    if (priority === undefined) {
    545      return await this._abort(errorReason);
    546    }
    547    this.interception.abortReason = errorReason;
    548    if (
    549      this.interception.resolutionState.priority === undefined ||
    550      priority >= this.interception.resolutionState.priority
    551    ) {
    552      this.interception.resolutionState = {
    553        action: InterceptResolutionAction.Abort,
    554        priority,
    555      };
    556      return;
    557    }
    558  }
    559 
    560  /**
    561   * @internal
    562   */
    563  static getResponse(body: string | Uint8Array): {
    564    contentLength: number;
    565    base64: string;
    566  } {
    567    // Needed to get the correct byteLength
    568    const byteBody: Uint8Array = isString(body)
    569      ? new TextEncoder().encode(body)
    570      : body;
    571 
    572    return {
    573      contentLength: byteBody.byteLength,
    574      base64: typedArrayToBase64(byteBody),
    575    };
    576  }
    577 }
    578 
    579 /**
    580 * @public
    581 */
    582 export enum InterceptResolutionAction {
    583  Abort = 'abort',
    584  Respond = 'respond',
    585  Continue = 'continue',
    586  Disabled = 'disabled',
    587  None = 'none',
    588  AlreadyHandled = 'already-handled',
    589 }
    590 
    591 /**
    592 * @public
    593 */
    594 export type ErrorCode =
    595  | 'aborted'
    596  | 'accessdenied'
    597  | 'addressunreachable'
    598  | 'blockedbyclient'
    599  | 'blockedbyresponse'
    600  | 'connectionaborted'
    601  | 'connectionclosed'
    602  | 'connectionfailed'
    603  | 'connectionrefused'
    604  | 'connectionreset'
    605  | 'internetdisconnected'
    606  | 'namenotresolved'
    607  | 'timedout'
    608  | 'failed';
    609 
    610 /**
    611 * @public
    612 */
    613 export type ActionResult = 'continue' | 'abort' | 'respond';
    614 
    615 /**
    616 * @internal
    617 */
    618 export function headersArray(
    619  headers: Record<string, string | string[]>,
    620 ): Array<{name: string; value: string}> {
    621  const result = [];
    622  for (const name in headers) {
    623    const value = headers[name];
    624 
    625    if (!Object.is(value, undefined)) {
    626      const values = Array.isArray(value) ? value : [value];
    627 
    628      result.push(
    629        ...values.map(value => {
    630          return {name, value: value + ''};
    631        }),
    632      );
    633    }
    634  }
    635  return result;
    636 }
    637 
    638 /**
    639 * @internal
    640 *
    641 * @remarks
    642 * List taken from {@link https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml}
    643 * with extra 306 and 418 codes.
    644 */
    645 export const STATUS_TEXTS: Record<string, string> = {
    646  '100': 'Continue',
    647  '101': 'Switching Protocols',
    648  '102': 'Processing',
    649  '103': 'Early Hints',
    650  '200': 'OK',
    651  '201': 'Created',
    652  '202': 'Accepted',
    653  '203': 'Non-Authoritative Information',
    654  '204': 'No Content',
    655  '205': 'Reset Content',
    656  '206': 'Partial Content',
    657  '207': 'Multi-Status',
    658  '208': 'Already Reported',
    659  '226': 'IM Used',
    660  '300': 'Multiple Choices',
    661  '301': 'Moved Permanently',
    662  '302': 'Found',
    663  '303': 'See Other',
    664  '304': 'Not Modified',
    665  '305': 'Use Proxy',
    666  '306': 'Switch Proxy',
    667  '307': 'Temporary Redirect',
    668  '308': 'Permanent Redirect',
    669  '400': 'Bad Request',
    670  '401': 'Unauthorized',
    671  '402': 'Payment Required',
    672  '403': 'Forbidden',
    673  '404': 'Not Found',
    674  '405': 'Method Not Allowed',
    675  '406': 'Not Acceptable',
    676  '407': 'Proxy Authentication Required',
    677  '408': 'Request Timeout',
    678  '409': 'Conflict',
    679  '410': 'Gone',
    680  '411': 'Length Required',
    681  '412': 'Precondition Failed',
    682  '413': 'Payload Too Large',
    683  '414': 'URI Too Long',
    684  '415': 'Unsupported Media Type',
    685  '416': 'Range Not Satisfiable',
    686  '417': 'Expectation Failed',
    687  '418': "I'm a teapot",
    688  '421': 'Misdirected Request',
    689  '422': 'Unprocessable Entity',
    690  '423': 'Locked',
    691  '424': 'Failed Dependency',
    692  '425': 'Too Early',
    693  '426': 'Upgrade Required',
    694  '428': 'Precondition Required',
    695  '429': 'Too Many Requests',
    696  '431': 'Request Header Fields Too Large',
    697  '451': 'Unavailable For Legal Reasons',
    698  '500': 'Internal Server Error',
    699  '501': 'Not Implemented',
    700  '502': 'Bad Gateway',
    701  '503': 'Service Unavailable',
    702  '504': 'Gateway Timeout',
    703  '505': 'HTTP Version Not Supported',
    704  '506': 'Variant Also Negotiates',
    705  '507': 'Insufficient Storage',
    706  '508': 'Loop Detected',
    707  '510': 'Not Extended',
    708  '511': 'Network Authentication Required',
    709 } as const;
    710 
    711 const errorReasons: Record<ErrorCode, Protocol.Network.ErrorReason> = {
    712  aborted: 'Aborted',
    713  accessdenied: 'AccessDenied',
    714  addressunreachable: 'AddressUnreachable',
    715  blockedbyclient: 'BlockedByClient',
    716  blockedbyresponse: 'BlockedByResponse',
    717  connectionaborted: 'ConnectionAborted',
    718  connectionclosed: 'ConnectionClosed',
    719  connectionfailed: 'ConnectionFailed',
    720  connectionrefused: 'ConnectionRefused',
    721  connectionreset: 'ConnectionReset',
    722  internetdisconnected: 'InternetDisconnected',
    723  namenotresolved: 'NameNotResolved',
    724  timedout: 'TimedOut',
    725  failed: 'Failed',
    726 } as const;
    727 
    728 /**
    729 * @internal
    730 */
    731 export function handleError(error: ProtocolError): void {
    732  // Firefox throws an invalid argument error with a message starting with
    733  // 'Expected "header" [...]'.
    734  if (
    735    error.originalMessage.includes('Invalid header') ||
    736    error.originalMessage.includes('Unsafe header') ||
    737    error.originalMessage.includes('Expected "header"') ||
    738    // WebDriver BiDi error for invalid values, for example, headers.
    739    error.originalMessage.includes('invalid argument')
    740  ) {
    741    throw error;
    742  }
    743  // In certain cases, protocol will return error if the request was
    744  // already canceled or the page was closed. We should tolerate these
    745  // errors.
    746  debugError(error);
    747 }