xref: /aosp_15_r20/external/perfetto/ui/src/frontend/post_message_handler.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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