Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## Unreleased

### Fixed

- **Map preview and style comparison views ignore `postMessage` events that don't come from the host window.** Before, another frame on the page could send a fake tool result to change what the preview shows or the "Open in browser" link. (#147)

## 0.9.0-dev.1 - 2026-10-30

### Fixed
Expand Down
1 change: 1 addition & 0 deletions src/resources/ui-apps/MapPreviewUIResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export class MapPreviewUIResource extends BaseResource {
});

window.addEventListener('message', function(event) {
if (event.source !== window.parent) return;
var message = event.data;
if (!message || typeof message !== 'object') return;

Expand Down
1 change: 1 addition & 0 deletions src/resources/ui-apps/StyleComparisonUIResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export class StyleComparisonUIResource extends BaseResource {
});

window.addEventListener('message', function(event) {
if (event.source !== window.parent) return;
var message = event.data;
if (!message || typeof message !== 'object') return;

Expand Down
82 changes: 70 additions & 12 deletions test/resources/ui-apps/MapPreviewUIResource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,12 @@ async function renderHtml(accessToken?: string): Promise<string> {

function runScriptSandbox(
scriptSource: string,
options: { mapboxglAvailable: boolean }
options: { mapboxglAvailable: boolean; answerInitialize?: boolean }
) {
const postMessageCalls: Array<Record<string, unknown>> = [];
let messageListener: ((event: { data: unknown }) => void) | undefined;
let messageListener:
| ((event: { data: unknown; source: unknown }) => void)
| undefined;

function fakeElement() {
return {
Expand Down Expand Up @@ -230,16 +232,18 @@ function runScriptSandbox(
}
: undefined;

const parentWindow = {
postMessage: (message: Record<string, unknown>) => {
postMessageCalls.push(message);
}
};

const sandbox: Record<string, unknown> = {
window: {
addEventListener: (event: string, cb: typeof messageListener) => {
if (event === 'message') messageListener = cb;
},
parent: {
postMessage: (message: Record<string, unknown>) => {
postMessageCalls.push(message);
}
}
parent: parentWindow
},
document: {
getElementById: getElementById,
Expand All @@ -256,26 +260,42 @@ function runScriptSandbox(

const initId = postMessageCalls.find((m) => m.method === 'ui/initialize')
?.id as number | undefined;
if (initId !== undefined && messageListener) {
messageListener({ data: { jsonrpc: '2.0', id: initId, result: {} } });
if (
initId !== undefined &&
messageListener &&
options.answerInitialize !== false
) {
messageListener({
data: { jsonrpc: '2.0', id: initId, result: {} },
source: parentWindow
});
}

return {
sendToolResult: (url: string) => {
answerInitialize: (source: unknown = parentWindow) => {
messageListener?.({
data: { jsonrpc: '2.0', id: initId, result: {} },
source
});
},
postMessageCalls,
sendToolResult: (url: string, source: unknown = parentWindow) => {
messageListener?.({
data: {
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
params: { content: [{ type: 'text', text: url }] }
}
},
source
});
},
mapboxglMock,
map: fakeMapInstance,
addSourceSpy,
addLayerSpy,
setStyleSpy,
errorEl: elementsById.error
errorEl: elementsById.error,
openBtn: elementsById['open-btn']
};
}

Expand Down Expand Up @@ -370,4 +390,42 @@ describe('MapPreviewUIResource — merged tool-result dispatch', () => {

expect(errorEl?.textContent).toContain('Could not parse GeoJSON');
});

it('ignores a tool-result posted by a window other than the host', async () => {
const html = await renderHtml(
'pk.' +
Buffer.from(JSON.stringify({ u: 'acct' })).toString('base64') +
'.sig'
);
const script = extractScript(html);
const { sendToolResult, setStyleSpy, addSourceSpy, openBtn } =
runScriptSandbox(script, { mapboxglAvailable: true });

sendToolResult(
'https://api.mapbox.com/styles/v1/attacker/style.html?access_token=pk.attacker.sig',
{ postMessage: () => {} }
);

expect(setStyleSpy).not.toHaveBeenCalled();
expect(addSourceSpy).not.toHaveBeenCalled();
expect(openBtn?.style.display).toBeUndefined();
});

it('ignores a reply to a pending request posted by a window other than the host', async () => {
const html = await renderHtml();
const { answerInitialize, postMessageCalls } = runScriptSandbox(
extractScript(html),
{ mapboxglAvailable: true, answerInitialize: false }
);
const initialized = () =>
postMessageCalls.some((m) => m.method === 'ui/notifications/initialized');

answerInitialize({ postMessage: () => {} });
await Promise.resolve();
expect(initialized()).toBe(false);

answerInitialize();
await Promise.resolve();
expect(initialized()).toBe(true);
});
});
114 changes: 114 additions & 0 deletions test/resources/ui-apps/StyleComparisonUIResource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import { describe, it, expect } from 'vitest';
import * as vm from 'node:vm';
import { StyleComparisonUIResource } from '../../../src/resources/ui-apps/StyleComparisonUIResource.js';
import { resourceTextOf } from '../../utils/resultTestUtils.js';

const uri = new URL('ui://mapbox/style-comparison/index.html');

async function inlineScript(): Promise<string> {
const resource = new StyleComparisonUIResource();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const html = resourceTextOf(await resource.readCallback(uri, {} as any));
const match = html.match(/<script>([\s\S]*?)<\/script>/);
if (!match) throw new Error('Could not find inline <script> block');
return match[1];
}

// Runs the inline script without mapboxgl, so a handled tool-result only
// shows the "Open in browser" button.
async function runScript() {
let listener: ((event: { data: unknown; source: unknown }) => void) | null =
null;
const elements: Record<string, { style: Record<string, string> }> = {};
const element = (id: string) =>
(elements[id] ??= {
style: {},
textContent: '',
classList: { add: () => {} },
addEventListener: () => {}
} as { style: Record<string, string> });
const posted: Array<{ id?: number; method?: string }> = [];
const parentWindow = {
postMessage: (message: { id?: number; method?: string }) => {
posted.push(message);
}
};

vm.runInContext(
await inlineScript(),
vm.createContext({
window: {
addEventListener: (type: string, cb: typeof listener) => {
if (type === 'message') listener = cb;
},
parent: parentWindow
},
document: { getElementById: element, createElement: () => element('') },
console,
setTimeout,
URL,
Map
})
);

const sendToolResult = (source: unknown) =>
listener?.({
data: {
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
params: {
content: [{ type: 'text', text: 'https://attacker.example/' }]
}
},
source
});

const answerInitialize = (source: unknown) =>
listener?.({
data: {
jsonrpc: '2.0',
id: posted.find((m) => m.method === 'ui/initialize')?.id,
result: {}
},
source
});
const initialized = () =>
posted.some((m) => m.method === 'ui/notifications/initialized');

return {
sendToolResult,
answerInitialize,
initialized,
parentWindow,
openBtn: element('open-btn')
};
}

describe('StyleComparisonUIResource message handling', () => {
it('handles a tool-result from the host window', async () => {
const { sendToolResult, parentWindow, openBtn } = await runScript();
sendToolResult(parentWindow);
expect(openBtn.style.display).toBe('block');
});

it('ignores a tool-result posted by a window other than the host', async () => {
const { sendToolResult, openBtn } = await runScript();
sendToolResult({ postMessage: () => {} });
expect(openBtn.style.display).toBeUndefined();
});

it('ignores a reply to a pending request posted by a window other than the host', async () => {
const { answerInitialize, initialized, parentWindow } = await runScript();

answerInitialize({ postMessage: () => {} });
await Promise.resolve();
expect(initialized()).toBe(false);

answerInitialize(parentWindow);
await Promise.resolve();
expect(initialized()).toBe(true);
});
});
Loading