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