// Copyright (C) 2019 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import m from 'mithril'; import {Time} from '../base/time'; import {PostedTrace} from '../core/trace_source'; import {showModal} from '../widgets/modal'; import {initCssConstants} from './css_constants'; import {toggleHelp} from './help_modal'; import {scrollTo} from '../public/scroll_helper'; import {AppImpl} from '../core/app_impl'; const TRUSTED_ORIGINS_KEY = 'trustedOrigins'; interface PostedTraceWrapped { perfetto: PostedTrace; } interface PostedScrollToRangeWrapped { perfetto: PostedScrollToRange; } interface PostedScrollToRange { timeStart: number; timeEnd: number; viewPercentage?: number; } // Returns whether incoming traces should be opened automatically or should // instead require a user interaction. export function isTrustedOrigin(origin: string): boolean { const TRUSTED_ORIGINS = [ 'https://chrometto.googleplex.com', 'https://uma.googleplex.com', 'https://android-build.googleplex.com', ]; if (origin === window.origin) return true; if (origin === 'null') return false; if (TRUSTED_ORIGINS.includes(origin)) return true; if (isUserTrustedOrigin(origin)) return true; const hostname = new URL(origin).hostname; if (hostname.endsWith('.corp.google.com')) return true; if (hostname.endsWith('.c.googlers.com')) return true; if ( hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' ) { return true; } return false; } // Returns whether the user saved this as an always-trusted origin. function isUserTrustedOrigin(hostname: string): boolean { const trustedOrigins = window.localStorage.getItem(TRUSTED_ORIGINS_KEY); if (trustedOrigins === null) return false; try { return JSON.parse(trustedOrigins).includes(hostname); } catch { return false; } } // Saves the given hostname as a trusted origin. // This is used for user convenience: if it fails for any reason, it's not a // big deal. function saveUserTrustedOrigin(hostname: string) { const s = window.localStorage.getItem(TRUSTED_ORIGINS_KEY); let origins: string[]; try { origins = JSON.parse(s ?? '[]'); if (origins.includes(hostname)) return; origins.push(hostname); window.localStorage.setItem(TRUSTED_ORIGINS_KEY, JSON.stringify(origins)); } catch (e) { console.warn('unable to save trusted origins to localStorage', e); } } // Returns whether we should ignore a given message based on the value of // the 'perfettoIgnore' field in the event data. function shouldGracefullyIgnoreMessage(messageEvent: MessageEvent) { return messageEvent.data.perfettoIgnore === true; } // The message handler supports loading traces from an ArrayBuffer. // There is no other requirement than sending the ArrayBuffer as the |data| // property. However, since this will happen across different origins, it is not // possible for the source website to inspect whether the message handler is // ready, so the message handler always replies to a 'PING' message with 'PONG', // which indicates it is ready to receive a trace. export function postMessageHandler(messageEvent: MessageEvent) { if (shouldGracefullyIgnoreMessage(messageEvent)) { // This message should not be handled in this handler, // because it will be handled elsewhere. return; } if (messageEvent.origin === 'https://tagassistant.google.com') { // The GA debugger, does a window.open() and sends messages to the GA // script. Ignore them. return; } if (document.readyState !== 'complete') { console.error('Ignoring message - document not ready yet.'); return; } const fromOpener = messageEvent.source === window.opener; const fromIframeHost = messageEvent.source === window.parent; // This adds support for the folowing flow: // * A (page that whats to open a trace in perfetto) opens B // * B (does something to get the traceBuffer) // * A is navigated to Perfetto UI // * B sends the traceBuffer to A // * closes itself const fromOpenee = (messageEvent.source as WindowProxy).opener === window; if ( messageEvent.source === null || !(fromOpener || fromIframeHost || fromOpenee) ) { // This can happen if an extension tries to postMessage. return; } if (!('data' in messageEvent)) { throw new Error('Incoming message has no data property'); } if (messageEvent.data === 'PING') { // Cross-origin messaging means we can't read |messageEvent.source|, but // it still needs to be of the correct type to be able to invoke the // correct version of postMessage(...). const windowSource = messageEvent.source as Window; // Use '*' for the reply because in cases of cross-domain isolation, we // see the messageEvent.origin as 'null'. PONG doen't disclose any // interesting information, so there is no harm sending that to the wrong // origin in the worst case. windowSource.postMessage('PONG', '*'); return; } if (messageEvent.data === 'SHOW-HELP') { toggleHelp(); return; } if (messageEvent.data === 'RELOAD-CSS-CONSTANTS') { initCssConstants(); return; } let postedScrollToRange: PostedScrollToRange; if (isPostedScrollToRange(messageEvent.data)) { postedScrollToRange = messageEvent.data.perfetto; scrollToTimeRange(postedScrollToRange); return; } let postedTrace: PostedTrace; let keepApiOpen = false; if (isPostedTraceWrapped(messageEvent.data)) { postedTrace = sanitizePostedTrace(messageEvent.data.perfetto); if (postedTrace.keepApiOpen) { keepApiOpen = true; } } else if (messageEvent.data instanceof ArrayBuffer) { postedTrace = {title: 'External trace', buffer: messageEvent.data}; } else { console.warn( 'Unknown postMessage() event received. If you are trying to open a ' + 'trace via postMessage(), this is a bug in your code. If not, this ' + 'could be due to some Chrome extension.', ); console.log('origin:', messageEvent.origin, 'data:', messageEvent.data); return; } if (postedTrace.buffer.byteLength === 0) { throw new Error('Incoming message trace buffer is empty'); } if (!keepApiOpen) { /* Removing this event listener to avoid callers posting the trace multiple * times. If the callers add an event listener which upon receiving 'PONG' * posts the trace to ui.perfetto.dev, the callers can receive multiple * 'PONG' messages and accidentally post the trace multiple times. This was * part of the cause of b/182502595. */ window.removeEventListener('message', postMessageHandler); } const openTrace = () => { // For external traces, we need to disable other features such as // downloading and sharing a trace. postedTrace.localOnly = true; AppImpl.instance.openTraceFromBuffer(postedTrace); }; const trustAndOpenTrace = () => { saveUserTrustedOrigin(messageEvent.origin); openTrace(); }; // If the origin is trusted open the trace directly. if (isTrustedOrigin(messageEvent.origin)) { openTrace(); return; } // If not ask the user if they expect this and trust the origin. let originTxt = messageEvent.origin; let originUnknown = false; if (originTxt === 'null') { originTxt = 'An unknown origin'; originUnknown = true; } showModal({ title: 'Open trace?', content: m( 'div', m('div', `${originTxt} is trying to open a trace file.`), m('div', 'Do you trust the origin and want to proceed?'), ), buttons: [ {text: 'No', primary: true}, {text: 'Yes', primary: false, action: openTrace}, ].concat( originUnknown ? [] : {text: 'Always trust', primary: false, action: trustAndOpenTrace}, ), }); } function sanitizePostedTrace(postedTrace: PostedTrace): PostedTrace { const result: PostedTrace = { title: sanitizeString(postedTrace.title), buffer: postedTrace.buffer, keepApiOpen: postedTrace.keepApiOpen, }; if (postedTrace.url !== undefined) { result.url = sanitizeString(postedTrace.url); } result.pluginArgs = postedTrace.pluginArgs; return result; } function sanitizeString(str: string): string { return str.replace(/[^A-Za-z0-9.\-_#:/?=&;%+$ ]/g, ' '); } const _maxScrollToRangeAttempts = 20; async function scrollToTimeRange( postedScrollToRange: PostedScrollToRange, maxAttempts?: number, ) { const ready = AppImpl.instance.trace && !AppImpl.instance.isLoadingTrace; if (!ready) { if (maxAttempts === undefined) { maxAttempts = 0; } if (maxAttempts > _maxScrollToRangeAttempts) { console.warn('Could not scroll to time range. Trace viewer not ready.'); return; } setTimeout(scrollToTimeRange, 200, postedScrollToRange, maxAttempts + 1); } else { const start = Time.fromSeconds(postedScrollToRange.timeStart); const end = Time.fromSeconds(postedScrollToRange.timeEnd); scrollTo({ time: {start, end, viewPercentage: postedScrollToRange.viewPercentage}, }); } } function isPostedScrollToRange( obj: unknown, ): obj is PostedScrollToRangeWrapped { const wrapped = obj as PostedScrollToRangeWrapped; if (wrapped.perfetto === undefined) { return false; } return ( wrapped.perfetto.timeStart !== undefined || wrapped.perfetto.timeEnd !== undefined ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any function isPostedTraceWrapped(obj: any): obj is PostedTraceWrapped { const wrapped = obj as PostedTraceWrapped; if (wrapped.perfetto === undefined) { return false; } return ( wrapped.perfetto.buffer !== undefined && wrapped.perfetto.title !== undefined ); }