UNPKG

chrome-devtools-frontend

Version:
675 lines (619 loc) • 28.4 kB
// Copyright 2022 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable rulesdir/no-lit-render-outside-of-view */ import * as Common from '../../../core/common/common.js'; import * as Host from '../../../core/host/host.js'; import * as i18n from '../../../core/i18n/i18n.js'; import * as Platform from '../../../core/platform/platform.js'; import type {NameValue} from '../../../core/sdk/NetworkRequest.js'; import type * as SDK from '../../../core/sdk/sdk.js'; import * as Protocol from '../../../generated/protocol.js'; import * as IssuesManager from '../../../models/issues_manager/issues_manager.js'; import * as Persistence from '../../../models/persistence/persistence.js'; import type * as Workspace from '../../../models/workspace/workspace.js'; import * as NetworkForward from '../../../panels/network/forward/forward.js'; import * as Sources from '../../../panels/sources/sources.js'; import * as Buttons from '../../../ui/components/buttons/buttons.js'; import * as UI from '../../../ui/legacy/legacy.js'; import {html, nothing, render} from '../../../ui/lit/lit.js'; import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; import { compareHeaders, EditingAllowedStatus, type HeaderDescriptor, type HeaderDetailsDescriptor, type HeaderEditedEvent, type HeaderEditorDescriptor, type HeaderRemovedEvent, type HeaderSectionRow, type HeaderSectionRowData, isValidHeaderName, } from './HeaderSectionRow.js'; import responseHeaderSectionStyles from './ResponseHeaderSection.css.js'; const UIStrings = { /** *@description Label for a button which allows adding an HTTP header. */ addHeader: 'Add header', /** *@description Explanation text for which cross-origin policy to set. */ chooseThisOptionIfTheResourceAnd: 'Choose this option if the resource and the document are served from the same site.', /** *@description Explanation text for which cross-origin policy to set. */ onlyChooseThisOptionIfAn: 'Only choose this option if an arbitrary website including this resource does not impose a security risk.', /** *@description Message in the Headers View of the Network panel when a cross-origin opener policy blocked loading a sandbox iframe. */ thisDocumentWasBlockedFrom: 'The document was blocked from loading in a popup opened by a sandboxed iframe because this document specified a cross-origin opener policy.', /** *@description Message in the Headers View of the Network panel when a cross-origin embedder policy header needs to be set. */ toEmbedThisFrameInYourDocument: 'To embed this frame in your document, the response needs to enable the cross-origin embedder policy by specifying the following response header:', /** *@description Message in the Headers View of the Network panel when a cross-origin resource policy header needs to be set. */ toUseThisResourceFromADifferent: 'To use this resource from a different origin, the server needs to specify a cross-origin resource policy in the response headers:', /** *@description Message in the Headers View of the Network panel when the cross-origin resource policy header is too strict. */ toUseThisResourceFromADifferentOrigin: 'To use this resource from a different origin, the server may relax the cross-origin resource policy response header:', /** *@description Message in the Headers View of the Network panel when the cross-origin resource policy header is too strict. */ toUseThisResourceFromADifferentSite: 'To use this resource from a different site, the server may relax the cross-origin resource policy response header:', } as const; const str_ = i18n.i18n.registerUIStrings('panels/network/components/ResponseHeaderSection.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); const i18nLazyString = i18n.i18n.getLazilyComputedLocalizedString.bind(undefined, str_); export const RESPONSE_HEADER_SECTION_DATA_KEY = 'ResponseHeaderSection'; export interface ResponseHeaderSectionData { request: SDK.NetworkRequest.NetworkRequest; toReveal?: {section: NetworkForward.UIRequestLocation.UIHeaderSection, header?: string}; } class ResponseHeaderSectionBase extends HTMLElement { protected readonly shadow = this.attachShadow({mode: 'open'}); protected headerDetails: HeaderDetailsDescriptor[] = []; protected setHeaders(headers: NameValue[]): void { headers.sort(function(a, b) { return Platform.StringUtilities.compare(a.name.toLowerCase(), b.name.toLowerCase()); }); this.headerDetails = headers.map(header => ({ name: Platform.StringUtilities.toLowerCaseString(header.name), value: header.value.replace(/\s/g, ' '), })); } protected highlightHeaders(data: ResponseHeaderSectionData): void { if (data.toReveal?.section === NetworkForward.UIRequestLocation.UIHeaderSection.RESPONSE) { this.headerDetails.filter(header => compareHeaders(header.name, data.toReveal?.header?.toLowerCase())) .forEach(header => { header.highlight = true; }); } } } export class EarlyHintsHeaderSection extends ResponseHeaderSectionBase { #request?: SDK.NetworkRequest.NetworkRequest; set data(data: ResponseHeaderSectionData) { this.#request = data.request; this.setHeaders(this.#request.earlyHintsHeaders); this.highlightHeaders(data); this.#render(); } #render(): void { if (!this.#request) { return; } // Disabled until https://crbug.com/1079231 is fixed. // clang-format off render(html` <style>${responseHeaderSectionStyles}</style> ${this.headerDetails.map(header => html` <devtools-header-section-row .data=${{ header, } as HeaderSectionRowData}></devtools-header-section-row> `)} `, this.shadow, { host: this }); // clang-format on } } customElements.define('devtools-early-hints-header-section', EarlyHintsHeaderSection); export class ResponseHeaderSection extends ResponseHeaderSectionBase { #request?: SDK.NetworkRequest.NetworkRequest; #headerEditors: HeaderEditorDescriptor[] = []; #uiSourceCode: Workspace.UISourceCode.UISourceCode|null = null; #overrides: Persistence.NetworkPersistenceManager.HeaderOverride[] = []; #isEditingAllowed = EditingAllowedStatus.DISABLED; set data(data: ResponseHeaderSectionData) { this.#request = data.request; this.#isEditingAllowed = Persistence.NetworkPersistenceManager.NetworkPersistenceManager.isForbiddenNetworkUrl(this.#request.url()) ? EditingAllowedStatus.FORBIDDEN : EditingAllowedStatus.DISABLED; // If the request has been locally overridden, its 'sortedResponseHeaders' // contains no 'set-cookie' headers, because they have been filtered out by // the Chromium backend. DevTools therefore uses previously stored values. const headers = this.#request.sortedResponseHeaders.concat(this.#request.setCookieHeaders); this.setHeaders(headers); const headersWithIssues = []; if (this.#request.wasBlocked()) { const headerWithIssues = BlockedReasonDetails.get((this.#request.blockedReason() as Protocol.Network.BlockedReason)); if (headerWithIssues) { if (IssuesManager.RelatedIssue.hasIssueOfCategory( this.#request, IssuesManager.Issue.IssueCategory.CROSS_ORIGIN_EMBEDDER_POLICY)) { const followLink = (): void => { Host.userMetrics.issuesPanelOpenedFrom(Host.UserMetrics.IssueOpener.LEARN_MORE_LINK_COEP); if (this.#request) { void IssuesManager.RelatedIssue.reveal( this.#request, IssuesManager.Issue.IssueCategory.CROSS_ORIGIN_EMBEDDER_POLICY); } }; if (headerWithIssues.blockedDetails) { headerWithIssues.blockedDetails.reveal = followLink; } } headersWithIssues.push(headerWithIssues); } } function mergeHeadersWithIssues( headers: HeaderDetailsDescriptor[], headersWithIssues: HeaderDetailsDescriptor[]): HeaderDetailsDescriptor[] { let i = 0, j = 0; const result: HeaderDetailsDescriptor[] = []; while (i < headers.length && j < headersWithIssues.length) { if (headers[i].name < headersWithIssues[j].name) { result.push({...headers[i++], headerNotSet: false}); } else if (headers[i].name > headersWithIssues[j].name) { result.push({...headersWithIssues[j++], headerNotSet: true}); } else { result.push({...headersWithIssues[j++], ...headers[i++], headerNotSet: false}); } } while (i < headers.length) { result.push({...headers[i++], headerNotSet: false}); } while (j < headersWithIssues.length) { result.push({...headersWithIssues[j++], headerNotSet: true}); } return result; } this.headerDetails = mergeHeadersWithIssues(this.headerDetails, headersWithIssues); const blockedResponseCookies = this.#request.blockedResponseCookies(); const blockedCookieLineToReasons = new Map<string, Protocol.Network.SetCookieBlockedReason[]>( blockedResponseCookies?.map(c => [c.cookieLine.replace(/\s/g, ' '), c.blockedReasons])); for (const header of this.headerDetails) { if (header.name === 'set-cookie' && header.value) { const matchingBlockedReasons = blockedCookieLineToReasons.get(header.value); if (matchingBlockedReasons) { header.setCookieBlockedReasons = matchingBlockedReasons; } } } this.highlightHeaders(data); const dataAssociatedWithRequest = this.#request.getAssociatedData(RESPONSE_HEADER_SECTION_DATA_KEY); if (dataAssociatedWithRequest) { this.#headerEditors = dataAssociatedWithRequest as HeaderEditorDescriptor[]; } else { this.#headerEditors = this.headerDetails.map(header => ({ name: header.name, value: header.value, originalValue: header.value, valueEditable: this.#isEditingAllowed, })); this.#markOverrides(); } void this.#loadOverridesFileInfo(); this.#request.setAssociatedData(RESPONSE_HEADER_SECTION_DATA_KEY, this.#headerEditors); this.#render(); } #resetEditorState(): void { if (!this.#request) { return; } this.#isEditingAllowed = Persistence.NetworkPersistenceManager.NetworkPersistenceManager.isForbiddenNetworkUrl(this.#request.url()) ? EditingAllowedStatus.FORBIDDEN : EditingAllowedStatus.DISABLED; this.#headerEditors = this.headerDetails.map(header => ({ name: header.name, value: header.value, originalValue: header.value, valueEditable: this.#isEditingAllowed, })); this.#markOverrides(); this.#request.setAssociatedData(RESPONSE_HEADER_SECTION_DATA_KEY, this.#headerEditors); } async #loadOverridesFileInfo(): Promise<void> { if (!this.#request) { return; } this.#uiSourceCode = Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance().getHeadersUISourceCodeFromUrl( this.#request.url()); if (!this.#uiSourceCode) { this.#resetEditorState(); this.#render(); return; } try { const deferredContent = await this.#uiSourceCode.requestContent(); this.#overrides = JSON.parse(deferredContent.content || '[]') as Persistence.NetworkPersistenceManager.HeaderOverride[]; if (!this.#overrides.every(Persistence.NetworkPersistenceManager.isHeaderOverride)) { throw new Error('Type mismatch after parsing'); } if (Common.Settings.Settings.instance().moduleSetting('persistence-network-overrides-enabled').get() && this.#isEditingAllowed === EditingAllowedStatus.DISABLED) { this.#isEditingAllowed = EditingAllowedStatus.ENABLED; } for (const header of this.#headerEditors) { header.valueEditable = this.#isEditingAllowed; } } catch { console.error( 'Failed to parse', this.#uiSourceCode?.url() || 'source code file', 'for locally overriding headers.'); this.#resetEditorState(); } finally { this.#render(); } } #markOverrides(): void { if (!this.#request || this.#request.originalResponseHeaders.length === 0) { return; } const originalHeaders = this.#request.originalResponseHeaders.map(header => ({ name: Platform.StringUtilities.toLowerCaseString(header.name), value: header.value.replace(/\s/g, ' '), })); originalHeaders.sort(function(a, b) { return Platform.StringUtilities.compare(a.name, b.name); }); // Loop over actual headers and original headers simultaneously and mark each actual header as // overridden if there is no identical original header. // If there are multiple headers with the same name, concatenate their values first before // comparing them. let indexActual = 0; let indexOriginal = 0; while (indexActual < this.headerDetails.length) { const currentName = this.headerDetails[indexActual].name; let actualValue = this.headerDetails[indexActual].value || ''; const headerNotSet = this.headerDetails[indexActual].headerNotSet; while (indexActual < this.headerDetails.length - 1 && this.headerDetails[indexActual + 1].name === currentName) { indexActual++; actualValue += `, ${this.headerDetails[indexActual].value}`; } while (indexOriginal < originalHeaders.length && originalHeaders[indexOriginal].name < currentName) { indexOriginal++; } if (indexOriginal < originalHeaders.length && originalHeaders[indexOriginal].name === currentName) { let originalValue = originalHeaders[indexOriginal].value; while (indexOriginal < originalHeaders.length - 1 && originalHeaders[indexOriginal + 1].name === currentName) { indexOriginal++; originalValue += `, ${originalHeaders[indexOriginal].value}`; } indexOriginal++; if (currentName !== 'set-cookie' && !headerNotSet && !compareHeaders(actualValue, originalValue)) { this.#headerEditors.filter(header => compareHeaders(header.name, currentName)).forEach(header => { header.isOverride = true; }); } } else if (currentName !== 'set-cookie' && !headerNotSet) { this.#headerEditors.filter(header => compareHeaders(header.name, currentName)).forEach(header => { header.isOverride = true; }); } indexActual++; } // Special case for 'set-cookie' headers: compare each header individually // and don't treat all 'set-cookie' headers as a single unit. this.#headerEditors.filter(header => header.name === 'set-cookie').forEach(header => { if (this.#request?.originalResponseHeaders.find( originalHeader => Platform.StringUtilities.toLowerCaseString(originalHeader.name) === 'set-cookie' && compareHeaders(originalHeader.value, header.value)) === undefined) { header.isOverride = true; } }); } #onHeaderEdited(event: HeaderEditedEvent): void { const target = event.target as HTMLElement; if (target.dataset.index === undefined) { return; } const index = Number(target.dataset.index); if (isValidHeaderName(event.headerName)) { this.#updateOverrides(event.headerName, event.headerValue, index); Host.userMetrics.actionTaken(Host.UserMetrics.Action.HeaderOverrideHeaderEdited); } } #fileNameFromUrl(url: Platform.DevToolsPath.UrlString): Platform.DevToolsPath.RawPathString { const rawPath = Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance().rawPathFromUrl(url, true); const lastIndexOfSlash = rawPath.lastIndexOf('/'); return Common.ParsedURL.ParsedURL.substring(rawPath, lastIndexOfSlash + 1); } #commitOverrides(): void { this.#uiSourceCode?.setWorkingCopy(JSON.stringify(this.#overrides, null, 2)); this.#uiSourceCode?.commitWorkingCopy(); } #removeEntryFromOverrides( rawFileName: Platform.DevToolsPath.RawPathString, headerName: Platform.StringUtilities.LowerCaseString, headerValue: string): void { for (let blockIndex = this.#overrides.length - 1; blockIndex >= 0; blockIndex--) { const block = this.#overrides[blockIndex]; if (block.applyTo !== rawFileName) { continue; } const foundIndex = block.headers.findIndex( header => compareHeaders(header.name, headerName) && compareHeaders(header.value, headerValue)); if (foundIndex < 0) { continue; } block.headers.splice(foundIndex, 1); if (block.headers.length === 0) { this.#overrides.splice(blockIndex, 1); } return; } } #onHeaderRemoved(event: HeaderRemovedEvent): void { const target = event.target as HTMLElement; if (target.dataset.index === undefined || !this.#request) { return; } const index = Number(target.dataset.index); const rawFileName = this.#fileNameFromUrl(this.#request.url()); this.#removeEntryFromOverrides(rawFileName, event.headerName, event.headerValue); this.#commitOverrides(); this.#headerEditors[index].isDeleted = true; this.#render(); Host.userMetrics.actionTaken(Host.UserMetrics.Action.HeaderOverrideHeaderRemoved); } #updateOverrides(headerName: Platform.StringUtilities.LowerCaseString, headerValue: string, index: number): void { if (!this.#request) { return; } // If 'originalResponseHeaders' are not populated (because there was no // request interception), fill them with a copy of 'sortedResponseHeaders'. // This ensures we have access to the original values when undoing edits. if (this.#request.originalResponseHeaders.length === 0) { this.#request.originalResponseHeaders = this.#request.sortedResponseHeaders.map(headerEntry => ({...headerEntry})); } const previousName = this.#headerEditors[index].name; const previousValue = this.#headerEditors[index].value; this.#headerEditors[index].name = headerName; this.#headerEditors[index].value = headerValue; let headersToUpdate: HeaderEditorDescriptor[] = []; if (headerName === 'set-cookie') { // Special case for 'set-cookie' headers: each such header is treated // separately without looking at other 'set-cookie' headers. headersToUpdate.push({name: headerName, value: headerValue, valueEditable: this.#isEditingAllowed}); } else { // If multiple headers have the same name 'foo', we treat them as a unit. // If there are overrides for 'foo', all original 'foo' headers are removed // and replaced with the override(s) for 'foo'. headersToUpdate = this.#headerEditors.filter( header => compareHeaders(header.name, headerName) && (!compareHeaders(header.value, header.originalValue) || header.isOverride)); } const rawFileName = this.#fileNameFromUrl(this.#request.url()); // If the last override-block matches 'rawFileName', use this last block. // Otherwise just append a new block at the end. We are not using earlier // blocks, because they could be overruled by later blocks, which contain // wildcards in the filenames they apply to. let block: Persistence.NetworkPersistenceManager.HeaderOverride|null = null; const [lastOverride] = this.#overrides.slice(-1); if (lastOverride?.applyTo === rawFileName) { block = lastOverride; } else { block = { applyTo: rawFileName, headers: [], }; this.#overrides.push(block); } if (headerName === 'set-cookie') { // Special case for 'set-cookie' headers: only remove the one specific // header which is currently being modified, keep all other headers // (including other 'set-cookie' headers). const foundIndex = block.headers.findIndex( header => compareHeaders(header.name, previousName) && compareHeaders(header.value, previousValue)); if (foundIndex >= 0) { block.headers.splice(foundIndex, 1); } } else { // Keep header overrides for all headers with a different name. block.headers = block.headers.filter(header => !compareHeaders(header.name, headerName)); } // If a header name has been edited (only possible when adding headers), // remove the previous override entry. if (!compareHeaders(this.#headerEditors[index].name, previousName)) { for (let i = 0; i < block.headers.length; ++i) { if (compareHeaders(block.headers[i].name, previousName) && compareHeaders(block.headers[i].value, previousValue)) { block.headers.splice(i, 1); break; } } } // Append freshly edited header overrides. for (const header of headersToUpdate) { block.headers.push({name: header.name, value: header.value || ''}); } if (block.headers.length === 0) { this.#overrides.pop(); } this.#commitOverrides(); } #onAddHeaderClick(): void { this.#headerEditors.push({ name: Platform.StringUtilities.toLowerCaseString(i18n.i18n.lockedString('header-name')), value: i18n.i18n.lockedString('header value'), isOverride: true, nameEditable: true, valueEditable: EditingAllowedStatus.ENABLED, }); const index = this.#headerEditors.length - 1; this.#updateOverrides(this.#headerEditors[index].name, this.#headerEditors[index].value || '', index); this.#render(); const rows = this.shadow.querySelectorAll<HeaderSectionRow>('devtools-header-section-row'); const [lastRow] = Array.from(rows).slice(-1); lastRow?.focus(); Host.userMetrics.actionTaken(Host.UserMetrics.Action.HeaderOverrideHeaderAdded); } #render(): void { if (!this.#request) { return; } const headerDescriptors: HeaderDescriptor[] = this.#headerEditors.map( (headerEditor, index) => ({...this.headerDetails[index], ...headerEditor, isResponseHeader: true})); // Disabled until https://crbug.com/1079231 is fixed. // clang-format off render(html` <style>${responseHeaderSectionStyles}</style> ${headerDescriptors.map((header, index) => html` <devtools-header-section-row .data=${{header} as HeaderSectionRowData} @headeredited=${this.#onHeaderEdited} @headerremoved=${this.#onHeaderRemoved} @enableheaderediting=${this.#onEnableHeaderEditingClick} data-index=${index} jslog=${VisualLogging.item('response-header')} ></devtools-header-section-row> `)} ${this.#isEditingAllowed === EditingAllowedStatus.ENABLED ? html` <devtools-button class="add-header-button" .variant=${Buttons.Button.Variant.OUTLINED} .iconName=${'plus'} @click=${this.#onAddHeaderClick} jslog=${VisualLogging.action('add-header').track({click: true})}> ${i18nString(UIStrings.addHeader)} </devtools-button> ` : nothing} `, this.shadow, {host: this}); // clang-format on } async #onEnableHeaderEditingClick(): Promise<void> { if (!this.#request) { return; } Host.userMetrics.actionTaken(Host.UserMetrics.Action.HeaderOverrideEnableEditingClicked); const requestUrl = this.#request.url(); const networkPersistanceManager = Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance(); if (networkPersistanceManager.project()) { Common.Settings.Settings.instance().moduleSetting('persistence-network-overrides-enabled').set(true); await networkPersistanceManager.getOrCreateHeadersUISourceCodeFromUrl(requestUrl); } else { // If folder for local overrides has not been provided yet UI.InspectorView.InspectorView.instance().displaySelectOverrideFolderInfobar(async () => { await Sources.SourcesNavigator.OverridesNavigatorView.instance().setupNewWorkspace(); await networkPersistanceManager.getOrCreateHeadersUISourceCodeFromUrl(requestUrl); }); } } } customElements.define('devtools-response-header-section', ResponseHeaderSection); declare global { interface HTMLElementTagNameMap { 'devtools-response-header-section': ResponseHeaderSection; 'devtools-early-hints-header-section': EarlyHintsHeaderSection; } } const BlockedReasonDetails = new Map<Protocol.Network.BlockedReason, HeaderDetailsDescriptor>([ [ Protocol.Network.BlockedReason.CoepFrameResourceNeedsCoepHeader, { name: Platform.StringUtilities.toLowerCaseString('cross-origin-embedder-policy'), value: null, blockedDetails: { explanation: i18nLazyString(UIStrings.toEmbedThisFrameInYourDocument), examples: [{codeSnippet: 'Cross-Origin-Embedder-Policy: require-corp', comment: undefined}], link: {url: 'https://web.dev/coop-coep/'}, }, }, ], [ Protocol.Network.BlockedReason.CorpNotSameOriginAfterDefaultedToSameOriginByCoep, { name: Platform.StringUtilities.toLowerCaseString('cross-origin-resource-policy'), value: null, blockedDetails: { explanation: i18nLazyString(UIStrings.toUseThisResourceFromADifferent), examples: [ { codeSnippet: 'Cross-Origin-Resource-Policy: same-site', comment: i18nLazyString(UIStrings.chooseThisOptionIfTheResourceAnd), }, { codeSnippet: 'Cross-Origin-Resource-Policy: cross-origin', comment: i18nLazyString(UIStrings.onlyChooseThisOptionIfAn), }, ], link: {url: 'https://web.dev/coop-coep/'}, }, }, ], [ Protocol.Network.BlockedReason.CoopSandboxedIframeCannotNavigateToCoopPage, { name: Platform.StringUtilities.toLowerCaseString('cross-origin-opener-policy'), value: null, headerValueIncorrect: false, blockedDetails: { explanation: i18nLazyString(UIStrings.thisDocumentWasBlockedFrom), examples: [], link: {url: 'https://web.dev/coop-coep/'}, }, }, ], [ Protocol.Network.BlockedReason.CorpNotSameSite, { name: Platform.StringUtilities.toLowerCaseString('cross-origin-resource-policy'), value: null, headerValueIncorrect: true, blockedDetails: { explanation: i18nLazyString(UIStrings.toUseThisResourceFromADifferentSite), examples: [ { codeSnippet: 'Cross-Origin-Resource-Policy: cross-origin', comment: i18nLazyString(UIStrings.onlyChooseThisOptionIfAn), }, ], link: null, }, }, ], [ Protocol.Network.BlockedReason.CorpNotSameOrigin, { name: Platform.StringUtilities.toLowerCaseString('cross-origin-resource-policy'), value: null, headerValueIncorrect: true, blockedDetails: { explanation: i18nLazyString(UIStrings.toUseThisResourceFromADifferentOrigin), examples: [ { codeSnippet: 'Cross-Origin-Resource-Policy: same-site', comment: i18nLazyString(UIStrings.chooseThisOptionIfTheResourceAnd), }, { codeSnippet: 'Cross-Origin-Resource-Policy: cross-origin', comment: i18nLazyString(UIStrings.onlyChooseThisOptionIfAn), }, ], link: null, }, }, ], ]);