tor-browser

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

ResponsePanel.js (15668B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 const {
      7  Component,
      8  createFactory,
      9 } = require("resource://devtools/client/shared/vendor/react.mjs");
     10 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
     11 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs");
     12 const {
     13  L10N,
     14 } = require("resource://devtools/client/netmonitor/src/utils/l10n.js");
     15 const {
     16  decodeUnicodeBase64,
     17  fetchNetworkUpdatePacket,
     18  parseJSON,
     19 } = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");
     20 const {
     21  getCORSErrorURL,
     22 } = require("resource://devtools/client/netmonitor/src/utils/doc-utils.js");
     23 const {
     24  Filters,
     25 } = require("resource://devtools/client/netmonitor/src/utils/filter-predicates.js");
     26 const {
     27  FILTER_SEARCH_DELAY,
     28 } = require("resource://devtools/client/netmonitor/src/constants.js");
     29 const {
     30  BLOCKED_REASON_MESSAGES,
     31 } = require("resource://devtools/client/netmonitor/src/constants.js");
     32 
     33 // Components
     34 const PropertiesView = createFactory(
     35  require("resource://devtools/client/netmonitor/src/components/request-details/PropertiesView.js")
     36 );
     37 const ImagePreview = createFactory(
     38  require("resource://devtools/client/netmonitor/src/components/previews/ImagePreview.js")
     39 );
     40 const FontPreview = createFactory(
     41  require("resource://devtools/client/netmonitor/src/components/previews/FontPreview.js")
     42 );
     43 const SourcePreview = createFactory(
     44  require("resource://devtools/client/netmonitor/src/components/previews/SourcePreview.js")
     45 );
     46 const HtmlPreview = createFactory(
     47  require("resource://devtools/client/netmonitor/src/components/previews/HtmlPreview.js")
     48 );
     49 let {
     50  NotificationBox,
     51  PriorityLevels,
     52 } = require("resource://devtools/client/shared/components/NotificationBox.js");
     53 NotificationBox = createFactory(NotificationBox);
     54 const MessagesView = createFactory(
     55  require("resource://devtools/client/netmonitor/src/components/messages/MessagesView.js")
     56 );
     57 const SearchBox = createFactory(
     58  require("resource://devtools/client/shared/components/SearchBox.js")
     59 );
     60 
     61 loader.lazyGetter(this, "MODE", function () {
     62  return ChromeUtils.importESModule(
     63    "resource://devtools/client/shared/components/reps/index.mjs"
     64  ).MODE;
     65 });
     66 
     67 const { div, input, label, span, h2 } = dom;
     68 const JSON_SCOPE_NAME = L10N.getStr("jsonScopeName");
     69 const JSON_FILTER_TEXT = L10N.getStr("jsonFilterText");
     70 const RESPONSE_PAYLOAD = L10N.getStr("responsePayload");
     71 const RAW_RESPONSE_PAYLOAD = L10N.getStr("netmonitor.response.raw");
     72 const HTML_RESPONSE = L10N.getStr("netmonitor.response.html");
     73 const RESPONSE_EMPTY_TEXT = L10N.getStr("responseEmptyText");
     74 const RESPONSE_TRUNCATED = L10N.getStr("responseTruncated");
     75 
     76 const JSON_VIEW_MIME_TYPE = "application/vnd.mozilla.json.view";
     77 
     78 /**
     79 * Response panel component
     80 * Displays the GET parameters and POST data of a request
     81 */
     82 class ResponsePanel extends Component {
     83  static get propTypes() {
     84    return {
     85      request: PropTypes.object.isRequired,
     86      openLink: PropTypes.func,
     87      targetSearchResult: PropTypes.object,
     88      connector: PropTypes.object.isRequired,
     89      showMessagesView: PropTypes.bool,
     90      defaultRawResponse: PropTypes.bool,
     91      setDefaultRawResponse: PropTypes.func,
     92    };
     93  }
     94 
     95  constructor(props) {
     96    super(props);
     97 
     98    this.state = {
     99      filterText: "",
    100      rawResponsePayloadDisplayed:
    101        !!props.targetSearchResult || !!props.defaultRawResponse,
    102    };
    103 
    104    this.toggleRawResponsePayload = this.toggleRawResponsePayload.bind(this);
    105    this.renderCORSBlockedReason = this.renderCORSBlockedReason.bind(this);
    106    this.renderRawResponsePayloadBtn =
    107      this.renderRawResponsePayloadBtn.bind(this);
    108    this.renderJsonHtmlAndSource = this.renderJsonHtmlAndSource.bind(this);
    109    this.handleJSONResponse = this.handleJSONResponse.bind(this);
    110  }
    111 
    112  componentDidMount() {
    113    const { request, connector } = this.props;
    114    fetchNetworkUpdatePacket(connector.requestData, request, [
    115      "responseContent",
    116      "responseHeaders",
    117    ]);
    118  }
    119 
    120  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
    121  UNSAFE_componentWillReceiveProps(nextProps) {
    122    const { request, connector } = nextProps;
    123    fetchNetworkUpdatePacket(connector.requestData, request, [
    124      "responseContent",
    125      "responseHeaders",
    126    ]);
    127 
    128    // If the response contains XSSI stripped chars default to raw view
    129    const text = nextProps.request?.responseContent?.content?.text;
    130    const xssiStrippedChars = text && parseJSON(text)?.strippedChars;
    131    if (xssiStrippedChars && !this.state.rawResponsePayloadDisplayed) {
    132      this.toggleRawResponsePayload();
    133    }
    134 
    135    if (nextProps.targetSearchResult !== null) {
    136      this.setState({
    137        rawResponsePayloadDisplayed: !!nextProps.targetSearchResult,
    138      });
    139    }
    140  }
    141 
    142  /**
    143   * Update only if:
    144   * 1) The rendered object has changed
    145   * 2) The user selected another search result target.
    146   * 3) Internal state changes
    147   */
    148  shouldComponentUpdate(nextProps, nextState) {
    149    return (
    150      this.state !== nextState ||
    151      this.props.request !== nextProps.request ||
    152      nextProps.targetSearchResult !== null
    153    );
    154  }
    155 
    156  /**
    157   * Handle json, which we tentatively identify by checking the
    158   * MIME type for "json" after any word boundary. This works
    159   * for the standard "application/json", and also for custom
    160   * types like "x-bigcorp-json". Additionally, we also
    161   * directly parse the response text content to verify whether
    162   * it's json or not, to handle responses incorrectly labeled
    163   * as text/plain instead.
    164   */
    165  handleJSONResponse(mimeType, response) {
    166    const limit = Services.prefs.getIntPref(
    167      "devtools.netmonitor.responseBodyLimit"
    168    );
    169    const { request } = this.props;
    170 
    171    // Check if the response has been truncated, in which case no parse should
    172    // be attempted.
    173    if (limit > 0 && limit <= request.responseContent.content.size) {
    174      const result = {};
    175      result.error = RESPONSE_TRUNCATED;
    176      return result;
    177    }
    178 
    179    const { json, error, jsonpCallback, strippedChars } = parseJSON(response);
    180 
    181    if (/\bjson/.test(mimeType) || json) {
    182      const result = {};
    183      // Make sure this is a valid JSON object first. If so, nicely display
    184      // the parsing results in a tree view.
    185 
    186      // Valid JSON
    187      if (json) {
    188        result.json = json;
    189      }
    190      // Valid JSONP
    191      if (jsonpCallback) {
    192        result.jsonpCallback = jsonpCallback;
    193      }
    194      // Malformed JSON
    195      if (error) {
    196        result.error = "" + error;
    197      }
    198      // XSSI protection sequence
    199      if (strippedChars) {
    200        result.strippedChars = strippedChars;
    201      }
    202 
    203      return result;
    204    }
    205 
    206    return null;
    207  }
    208 
    209  renderCORSBlockedReason(blockedReason) {
    210    // ensure that the blocked reason is in the CORS range
    211    if (
    212      typeof blockedReason != "number" ||
    213      blockedReason < 1000 ||
    214      blockedReason > 1015
    215    ) {
    216      return null;
    217    }
    218 
    219    const blockedMessage = BLOCKED_REASON_MESSAGES[blockedReason];
    220    const messageText = L10N.getFormatStr(
    221      "netmonitor.headers.blockedByCORS",
    222      blockedMessage
    223    );
    224 
    225    const learnMoreTooltip = L10N.getStr(
    226      "netmonitor.headers.blockedByCORSTooltip"
    227    );
    228 
    229    // Create a notifications map with the CORS error notification
    230    const notifications = new Map();
    231    notifications.set("CORS-error", {
    232      label: messageText,
    233      value: "CORS-error",
    234      image: "",
    235      priority: PriorityLevels.PRIORITY_INFO_HIGH,
    236      type: "info",
    237      eventCallback: () => {},
    238      buttons: [
    239        {
    240          mdnUrl: getCORSErrorURL(blockedReason),
    241          label: learnMoreTooltip,
    242        },
    243      ],
    244    });
    245 
    246    return NotificationBox({
    247      notifications,
    248      displayBorderTop: false,
    249      displayBorderBottom: true,
    250      displayCloseButton: false,
    251    });
    252  }
    253 
    254  toggleRawResponsePayload() {
    255    this.setState({
    256      rawResponsePayloadDisplayed: !this.state.rawResponsePayloadDisplayed,
    257    });
    258  }
    259 
    260  /**
    261   * Pick correct component, componentprops, and other needed data to render
    262   * the given response
    263   *
    264   * @returns {object} shape:
    265   *  {component}: React component used to render response
    266   *  {Object} componetProps: Props passed to component
    267   *  {Error} error: JSON parsing error
    268   *  {Object} json: parsed JSON payload
    269   *  {bool} hasFormattedDisplay: whether the given payload has a formatted
    270   *         display or if it should be rendered raw
    271   *  {string} responsePayloadLabel: describes type in response panel
    272   *  {component} xssiStrippedCharsInfoBox: React component to notifiy users
    273   *              that XSSI characters were stripped from the response
    274   */
    275  renderJsonHtmlAndSource() {
    276    const { request, targetSearchResult } = this.props;
    277    const { responseContent, responseHeaders, url } = request;
    278    let { encoding, mimeType, text } = responseContent.content;
    279    const { filterText, rawResponsePayloadDisplayed } = this.state;
    280 
    281    // Decode response if it's coming from JSONView.
    282    if (mimeType?.includes(JSON_VIEW_MIME_TYPE) && encoding === "base64") {
    283      text = decodeUnicodeBase64(text);
    284    }
    285    const { json, jsonpCallback, error, strippedChars } =
    286      this.handleJSONResponse(mimeType, text) || {};
    287 
    288    let component;
    289    let componentProps;
    290    let xssiStrippedCharsInfoBox;
    291    let responsePayloadLabel = RESPONSE_PAYLOAD;
    292    let hasFormattedDisplay = false;
    293 
    294    if (json) {
    295      if (jsonpCallback) {
    296        responsePayloadLabel = L10N.getFormatStr(
    297          "jsonpScopeName",
    298          jsonpCallback
    299        );
    300      } else {
    301        responsePayloadLabel = JSON_SCOPE_NAME;
    302      }
    303 
    304      // If raw response payload is not displayed render xssi info box if
    305      // there are stripped chars
    306      if (!rawResponsePayloadDisplayed) {
    307        xssiStrippedCharsInfoBox =
    308          this.renderXssiStrippedCharsInfoBox(strippedChars);
    309      } else {
    310        xssiStrippedCharsInfoBox = null;
    311      }
    312 
    313      component = PropertiesView;
    314      componentProps = {
    315        object: json,
    316        useQuotes: true,
    317        filterText,
    318        targetSearchResult,
    319        defaultSelectFirstNode: false,
    320        mode: MODE.LONG,
    321        useBaseTreeViewExpand: true,
    322        url,
    323      };
    324      hasFormattedDisplay = true;
    325    } else if (Filters.html(this.props.request)) {
    326      // Display HTML
    327      responsePayloadLabel = HTML_RESPONSE;
    328      component = HtmlPreview;
    329      componentProps = { responseContent, responseHeaders, url };
    330      hasFormattedDisplay = true;
    331    }
    332    if (!hasFormattedDisplay || rawResponsePayloadDisplayed) {
    333      component = SourcePreview;
    334      componentProps = {
    335        text,
    336        mimeType: json ? "application/json" : mimeType.replace(/;.+/, ""),
    337        targetSearchResult,
    338        url,
    339      };
    340    }
    341    return {
    342      component,
    343      componentProps,
    344      error,
    345      hasFormattedDisplay,
    346      json,
    347      responsePayloadLabel,
    348      xssiStrippedCharsInfoBox,
    349      url,
    350    };
    351  }
    352 
    353  renderRawResponsePayloadBtn(key, checked) {
    354    return [
    355      label(
    356        {
    357          key: `${key}RawResponsePayloadBtn`,
    358          className: "raw-data-toggle",
    359          onClick: event => {
    360            // stop the header click event
    361            event.stopPropagation();
    362          },
    363        },
    364        span({ className: "raw-data-toggle-label" }, RAW_RESPONSE_PAYLOAD),
    365        span(
    366          { className: "raw-data-toggle-input" },
    367          input({
    368            id: `raw-${key}-checkbox`,
    369            checked,
    370            className: "devtools-checkbox-toggle",
    371            onChange: event => {
    372              if (this.props.setDefaultRawResponse) {
    373                this.props.setDefaultRawResponse(event.target.checked);
    374              }
    375              this.toggleRawResponsePayload();
    376            },
    377            type: "checkbox",
    378          })
    379        )
    380      ),
    381    ];
    382  }
    383 
    384  renderResponsePayload(component, componentProps) {
    385    return component(componentProps);
    386  }
    387 
    388  /**
    389   * This function takes a string of the XSSI protection characters
    390   * removed from a JSON payload and produces a notification component
    391   * letting the user know that they were removed
    392   *
    393   * @param {string} strippedChars: string of XSSI protection characters
    394   *                                removed from JSON payload
    395   * @returns {component} NotificationBox component
    396   */
    397  renderXssiStrippedCharsInfoBox(strippedChars) {
    398    if (!strippedChars || this.state.rawRequestPayloadDisplayed) {
    399      return null;
    400    }
    401    const message = L10N.getFormatStr("jsonXssiStripped", strippedChars);
    402 
    403    const notifications = new Map();
    404    notifications.set("xssi-string-removed-info-box", {
    405      label: message,
    406      value: "xssi-string-removed-info-box",
    407      image: "",
    408      priority: PriorityLevels.PRIORITY_INFO_MEDIUM,
    409      type: "info",
    410      eventCallback: () => {},
    411      buttons: [],
    412    });
    413 
    414    return NotificationBox({
    415      notifications,
    416      displayBorderTop: false,
    417      displayBorderBottom: true,
    418      displayCloseButton: false,
    419    });
    420  }
    421 
    422  render() {
    423    const { connector, showMessagesView, request } = this.props;
    424    const { blockedReason, responseContent, url } = request;
    425    const { filterText, rawResponsePayloadDisplayed } = this.state;
    426 
    427    // Display CORS blocked Reason info box
    428    const CORSBlockedReasonDetails =
    429      this.renderCORSBlockedReason(blockedReason);
    430 
    431    if (showMessagesView) {
    432      return MessagesView({ connector });
    433    }
    434 
    435    if (
    436      !responseContent ||
    437      typeof responseContent.content.text !== "string" ||
    438      !responseContent.content.text
    439    ) {
    440      return div(
    441        { className: "panel-container" },
    442        CORSBlockedReasonDetails,
    443        div({ className: "empty-notice" }, RESPONSE_EMPTY_TEXT)
    444      );
    445    }
    446 
    447    const { encoding, mimeType, text } = responseContent.content;
    448 
    449    if (Filters.images({ mimeType })) {
    450      return ImagePreview({ encoding, mimeType, text, url });
    451    }
    452 
    453    if (Filters.fonts({ url, mimeType })) {
    454      return FontPreview({ connector, mimeType, url });
    455    }
    456 
    457    // Get Data needed for formatted display
    458    const {
    459      component,
    460      componentProps,
    461      error,
    462      hasFormattedDisplay,
    463      json,
    464      responsePayloadLabel,
    465      xssiStrippedCharsInfoBox,
    466    } = this.renderJsonHtmlAndSource();
    467 
    468    const classList = ["panel-container"];
    469    if (Filters.html(this.props.request)) {
    470      classList.push("contains-html-preview");
    471    }
    472 
    473    return div(
    474      { className: classList.join(" ") },
    475      error && div({ className: "response-error-header", title: error }, error),
    476      json &&
    477        div(
    478          { className: "devtools-toolbar devtools-input-toolbar" },
    479          SearchBox({
    480            delay: FILTER_SEARCH_DELAY,
    481            type: "filter",
    482            onChange: filter => this.setState({ filterText: filter }),
    483            placeholder: JSON_FILTER_TEXT,
    484            initialValue: filterText,
    485          })
    486        ),
    487      div({ tabIndex: "0" }, CORSBlockedReasonDetails),
    488      h2({ className: "data-header", role: "heading" }, [
    489        span(
    490          {
    491            key: "data-label",
    492            className: "data-label",
    493          },
    494          responsePayloadLabel
    495        ),
    496        hasFormattedDisplay &&
    497          this.renderRawResponsePayloadBtn(
    498            "response",
    499            rawResponsePayloadDisplayed
    500          ),
    501      ]),
    502      xssiStrippedCharsInfoBox,
    503      this.renderResponsePayload(component, componentProps)
    504    );
    505  }
    506 }
    507 
    508 module.exports = ResponsePanel;