• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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