1// Copyright (C) 2019 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import m from 'mithril'; 16 17import {Time} from '../base/time'; 18import {Actions, PostedScrollToRange, PostedTrace} from '../common/actions'; 19import {showModal} from '../widgets/modal'; 20 21import {initCssConstants} from './css_constants'; 22import {globals} from './globals'; 23import {toggleHelp} from './help_modal'; 24import {focusHorizontalRange} from './scroll_helper'; 25 26const TRUSTED_ORIGINS_KEY = 'trustedOrigins'; 27 28interface PostedTraceWrapped { 29 perfetto: PostedTrace; 30} 31 32interface PostedScrollToRangeWrapped { 33 perfetto: PostedScrollToRange; 34} 35 36// Returns whether incoming traces should be opened automatically or should 37// instead require a user interaction. 38export function isTrustedOrigin(origin: string): boolean { 39 const TRUSTED_ORIGINS = [ 40 'https://chrometto.googleplex.com', 41 'https://uma.googleplex.com', 42 'https://android-build.googleplex.com', 43 ]; 44 if (origin === window.origin) return true; 45 if (origin === 'null') return false; 46 if (TRUSTED_ORIGINS.includes(origin)) return true; 47 if (isUserTrustedOrigin(origin)) return true; 48 49 const hostname = new URL(origin).hostname; 50 if (hostname.endsWith('.corp.google.com')) return true; 51 if (hostname.endsWith('.c.googlers.com')) return true; 52 if ( 53 hostname === 'localhost' || 54 hostname === '127.0.0.1' || 55 hostname === '[::1]' 56 ) { 57 return true; 58 } 59 return false; 60} 61 62// Returns whether the user saved this as an always-trusted origin. 63function isUserTrustedOrigin(hostname: string): boolean { 64 const trustedOrigins = window.localStorage.getItem(TRUSTED_ORIGINS_KEY); 65 if (trustedOrigins === null) return false; 66 try { 67 return JSON.parse(trustedOrigins).includes(hostname); 68 } catch { 69 return false; 70 } 71} 72 73// Saves the given hostname as a trusted origin. 74// This is used for user convenience: if it fails for any reason, it's not a 75// big deal. 76function saveUserTrustedOrigin(hostname: string) { 77 const s = window.localStorage.getItem(TRUSTED_ORIGINS_KEY); 78 let origins: string[]; 79 try { 80 origins = JSON.parse(s || '[]'); 81 if (origins.includes(hostname)) return; 82 origins.push(hostname); 83 window.localStorage.setItem(TRUSTED_ORIGINS_KEY, JSON.stringify(origins)); 84 } catch (e) { 85 console.warn('unable to save trusted origins to localStorage', e); 86 } 87} 88 89// Returns whether we should ignore a given message based on the value of 90// the 'perfettoIgnore' field in the event data. 91function shouldGracefullyIgnoreMessage(messageEvent: MessageEvent) { 92 return messageEvent.data.perfettoIgnore === true; 93} 94 95// The message handler supports loading traces from an ArrayBuffer. 96// There is no other requirement than sending the ArrayBuffer as the |data| 97// property. However, since this will happen across different origins, it is not 98// possible for the source website to inspect whether the message handler is 99// ready, so the message handler always replies to a 'PING' message with 'PONG', 100// which indicates it is ready to receive a trace. 101export function postMessageHandler(messageEvent: MessageEvent) { 102 if (shouldGracefullyIgnoreMessage(messageEvent)) { 103 // This message should not be handled in this handler, 104 // because it will be handled elsewhere. 105 return; 106 } 107 108 if (messageEvent.origin === 'https://tagassistant.google.com') { 109 // The GA debugger, does a window.open() and sends messages to the GA 110 // script. Ignore them. 111 return; 112 } 113 114 if (document.readyState !== 'complete') { 115 console.error('Ignoring message - document not ready yet.'); 116 return; 117 } 118 119 const fromOpener = messageEvent.source === window.opener; 120 const fromIframeHost = messageEvent.source === window.parent; 121 // This adds support for the folowing flow: 122 // * A (page that whats to open a trace in perfetto) opens B 123 // * B (does something to get the traceBuffer) 124 // * A is navigated to Perfetto UI 125 // * B sends the traceBuffer to A 126 // * closes itself 127 const fromOpenee = (messageEvent.source as WindowProxy).opener === window; 128 129 if ( 130 messageEvent.source === null || 131 !(fromOpener || fromIframeHost || fromOpenee) 132 ) { 133 // This can happen if an extension tries to postMessage. 134 return; 135 } 136 137 if (!('data' in messageEvent)) { 138 throw new Error('Incoming message has no data property'); 139 } 140 141 if (messageEvent.data === 'PING') { 142 // Cross-origin messaging means we can't read |messageEvent.source|, but 143 // it still needs to be of the correct type to be able to invoke the 144 // correct version of postMessage(...). 145 const windowSource = messageEvent.source as Window; 146 147 // Use '*' for the reply because in cases of cross-domain isolation, we 148 // see the messageEvent.origin as 'null'. PONG doen't disclose any 149 // interesting information, so there is no harm sending that to the wrong 150 // origin in the worst case. 151 windowSource.postMessage('PONG', '*'); 152 return; 153 } 154 155 if (messageEvent.data === 'SHOW-HELP') { 156 toggleHelp(); 157 return; 158 } 159 160 if (messageEvent.data === 'RELOAD-CSS-CONSTANTS') { 161 initCssConstants(); 162 return; 163 } 164 165 let postedScrollToRange: PostedScrollToRange; 166 if (isPostedScrollToRange(messageEvent.data)) { 167 postedScrollToRange = messageEvent.data.perfetto; 168 scrollToTimeRange(postedScrollToRange); 169 return; 170 } 171 172 let postedTrace: PostedTrace; 173 let keepApiOpen = false; 174 if (isPostedTraceWrapped(messageEvent.data)) { 175 postedTrace = sanitizePostedTrace(messageEvent.data.perfetto); 176 if (postedTrace.keepApiOpen) { 177 keepApiOpen = true; 178 } 179 } else if (messageEvent.data instanceof ArrayBuffer) { 180 postedTrace = {title: 'External trace', buffer: messageEvent.data}; 181 } else { 182 console.warn( 183 'Unknown postMessage() event received. If you are trying to open a ' + 184 'trace via postMessage(), this is a bug in your code. If not, this ' + 185 'could be due to some Chrome extension.', 186 ); 187 console.log('origin:', messageEvent.origin, 'data:', messageEvent.data); 188 return; 189 } 190 191 if (postedTrace.buffer.byteLength === 0) { 192 throw new Error('Incoming message trace buffer is empty'); 193 } 194 195 if (!keepApiOpen) { 196 /* Removing this event listener to avoid callers posting the trace multiple 197 * times. If the callers add an event listener which upon receiving 'PONG' 198 * posts the trace to ui.perfetto.dev, the callers can receive multiple 199 * 'PONG' messages and accidentally post the trace multiple times. This was 200 * part of the cause of b/182502595. 201 */ 202 window.removeEventListener('message', postMessageHandler); 203 } 204 205 const openTrace = () => { 206 // For external traces, we need to disable other features such as 207 // downloading and sharing a trace. 208 postedTrace.localOnly = true; 209 globals.dispatch(Actions.openTraceFromBuffer(postedTrace)); 210 }; 211 212 const trustAndOpenTrace = () => { 213 saveUserTrustedOrigin(messageEvent.origin); 214 openTrace(); 215 }; 216 217 // If the origin is trusted open the trace directly. 218 if (isTrustedOrigin(messageEvent.origin)) { 219 openTrace(); 220 return; 221 } 222 223 // If not ask the user if they expect this and trust the origin. 224 let originTxt = messageEvent.origin; 225 let originUnknown = false; 226 if (originTxt === 'null') { 227 originTxt = 'An unknown origin'; 228 originUnknown = true; 229 } 230 showModal({ 231 title: 'Open trace?', 232 content: m( 233 'div', 234 m('div', `${originTxt} is trying to open a trace file.`), 235 m('div', 'Do you trust the origin and want to proceed?'), 236 ), 237 buttons: [ 238 {text: 'No', primary: true}, 239 {text: 'Yes', primary: false, action: openTrace}, 240 ].concat( 241 originUnknown 242 ? [] 243 : {text: 'Always trust', primary: false, action: trustAndOpenTrace}, 244 ), 245 }); 246} 247 248function sanitizePostedTrace(postedTrace: PostedTrace): PostedTrace { 249 const result: PostedTrace = { 250 title: sanitizeString(postedTrace.title), 251 buffer: postedTrace.buffer, 252 keepApiOpen: postedTrace.keepApiOpen, 253 }; 254 if (postedTrace.url !== undefined) { 255 result.url = sanitizeString(postedTrace.url); 256 } 257 result.pluginArgs = postedTrace.pluginArgs; 258 return result; 259} 260 261function sanitizeString(str: string): string { 262 return str.replace(/[^A-Za-z0-9.\-_#:/?=&;%+$ ]/g, ' '); 263} 264 265function isTraceViewerReady(): boolean { 266 return !!globals.getCurrentEngine()?.ready; 267} 268 269const _maxScrollToRangeAttempts = 20; 270async function scrollToTimeRange( 271 postedScrollToRange: PostedScrollToRange, 272 maxAttempts?: number, 273) { 274 const ready = isTraceViewerReady(); 275 if (!ready) { 276 if (maxAttempts === undefined) { 277 maxAttempts = 0; 278 } 279 if (maxAttempts > _maxScrollToRangeAttempts) { 280 console.warn('Could not scroll to time range. Trace viewer not ready.'); 281 return; 282 } 283 setTimeout(scrollToTimeRange, 200, postedScrollToRange, maxAttempts + 1); 284 } else { 285 const start = Time.fromSeconds(postedScrollToRange.timeStart); 286 const end = Time.fromSeconds(postedScrollToRange.timeEnd); 287 focusHorizontalRange(start, end, postedScrollToRange.viewPercentage); 288 } 289} 290 291function isPostedScrollToRange( 292 obj: unknown, 293): obj is PostedScrollToRangeWrapped { 294 const wrapped = obj as PostedScrollToRangeWrapped; 295 if (wrapped.perfetto === undefined) { 296 return false; 297 } 298 return ( 299 wrapped.perfetto.timeStart !== undefined || 300 wrapped.perfetto.timeEnd !== undefined 301 ); 302} 303 304// eslint-disable-next-line @typescript-eslint/no-explicit-any 305function isPostedTraceWrapped(obj: any): obj is PostedTraceWrapped { 306 const wrapped = obj as PostedTraceWrapped; 307 if (wrapped.perfetto === undefined) { 308 return false; 309 } 310 return ( 311 wrapped.perfetto.buffer !== undefined && 312 wrapped.perfetto.title !== undefined 313 ); 314} 315