Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
10 changes: 10 additions & 0 deletions e2e/helpers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ export const emptyConfig: AppConfig = {
servers: [],
};

// Single configured server, used by the local-network policy spec to point at a loopback
// test server so no live Mattermost is required.
export function localNetworkConfig(serverUrl: string): AppConfig {
return {
...baseConfig,
servers: [{name: 'local', url: serverUrl, order: 0}],
lastActiveServer: 0,
};
}

// ---- File writer ----

/**
Expand Down
109 changes: 109 additions & 0 deletions e2e/helpers/localNetworkServers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import http from 'http';
import type {AddressInfo} from 'net';

/**
* Loopback servers for the local-network policy spec: fakeServer is the configured
* (trusted) Mattermost server; secretService is an unrelated internal service the policy
* must keep server content from reaching, and counts every request it receives.
*/
export type LocalNetworkServers = {
fakeServerOrigin: string;
fakeServerUrl: string;
secretUrl: string;
getSecretHitCount: () => number;
close: () => Promise<void>;
};

const FAKE_SERVER_HTML =
'<!doctype html><html><head><title>mm-e2e-fake-server</title></head>' +
'<body><div id="mm-e2e-fake-server">mm-e2e-fake-server</div></body></html>';

// On message, fetches the given URL from the worker context (which may lack an owning
// webContents) and reports the outcome back over the provided MessagePort.
const SERVICE_WORKER_JS = `
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
self.addEventListener('message', (event) => {
const url = event.data && event.data.url;
const port = event.ports && event.ports[0];
fetch(url, {cache: 'no-store'})
.then((response) => response.text())
.then((body) => { if (port) { port.postMessage({ok: true, body: body}); } })
.catch((error) => { if (port) { port.postMessage({ok: false, error: String(error)}); } });
});
`;

function listen(server: http.Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
resolve((server.address() as AddressInfo).port);
});
});
}

function closeServer(server: http.Server): Promise<void> {
return new Promise((resolve) => server.close(() => resolve()));
}

export async function startLocalNetworkServers(): Promise<LocalNetworkServers> {
let secretHitCount = 0;

// Permissive CORS mirrors the worst case: a local service that would allow its body to be read.
const secretService = http.createServer((req, res) => {
secretHitCount++;
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('top-secret');
});
let secretUrl = '';

const fakeServer = http.createServer((req, res) => {
const url = req.url ?? '/';

// Same-origin redirect to the internal service (redirect-bypass test).
if (url.startsWith('/redirect')) {
res.writeHead(302, {Location: secretUrl});
res.end();
return;
}

if (url.startsWith('/sw.js')) {
res.writeHead(200, {'Content-Type': 'text/javascript', 'Cache-Control': 'no-store'});
res.end(SERVICE_WORKER_JS);
return;
}

// CORS so an allow-path fetch succeeds regardless of requesting origin.
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
res.writeHead(200, {'Content-Type': 'text/html'});
res.end(FAKE_SERVER_HTML);
});

// Close any already-bound server if a later listen() fails, so a partial startup
// never leaks a loopback listener into other tests.
try {
const secretPort = await listen(secretService);
secretUrl = `http://127.0.0.1:${secretPort}/secret`;
const fakePort = await listen(fakeServer);
const fakeServerOrigin = `http://127.0.0.1:${fakePort}`;

return {
fakeServerOrigin,
fakeServerUrl: `${fakeServerOrigin}/`,
secretUrl,
getSecretHitCount: () => secretHitCount,
close: async () => {
await Promise.all([closeServer(fakeServer), closeServer(secretService)]);
},
};
} catch (error) {
await Promise.allSettled([closeServer(fakeServer), closeServer(secretService)]);
throw error;
}
}
210 changes: 210 additions & 0 deletions e2e/specs/policy/local_network.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import type {ElectronApplication} from 'playwright';

import {test as base, expect, type AppConfig} from '../../fixtures/index';
import {localNetworkConfig} from '../../helpers/config';
import {startLocalNetworkServers, type LocalNetworkServers} from '../../helpers/localNetworkServers';

/**
* MM-69241 — server content must not reach the local/private network, while the configured
* (possibly self-hosted/local) server stays reachable. These specs exercise the real
* Electron onBeforeRequest filter end-to-end, which the unit tests can only mock.
*/
const test = base.extend<{localServers: LocalNetworkServers}>({
localServers: async ({}, use) => {
const servers = await startLocalNetworkServers();
await use(servers);
await servers.close();
},

// Depends on localServers so the dynamic fakeServer port is up before the app launches.
appConfig: async ({localServers}, use) => {
const config: AppConfig = localNetworkConfig(localServers.fakeServerUrl);
await use(config);
},
});

// executeJavaScript directly (top-level promise is awaited), unlike ServerView.runInRenderer.
async function fetchFromView(
app: ElectronApplication,
webContentsId: number,
url: string,
): Promise<{ok: boolean; body?: string; error?: string}> {
return app.evaluate(async ({webContents}, payload) => {
const wc = webContents.fromId(payload.id);
if (!wc || wc.isDestroyed()) {
throw new Error(`webContents ${payload.id} is not available`);
}
return wc.executeJavaScript(
`fetch(${JSON.stringify(payload.url)}, {cache: 'no-store'})
.then((response) => response.text())
.then((body) => ({ok: true, body}))
.catch((error) => ({ok: false, error: String(error)}))`,
true,
);
}, {id: webContentsId, url}) as Promise<{ok: boolean; body?: string; error?: string}>;
}

// Injects a hidden iframe. The `load` event fires even for blocked/cross-origin frames, so
// `event` is informational; judge success by `bodyText` (same-origin commit) or a hit counter.
async function embedIframe(
app: ElectronApplication,
webContentsId: number,
url: string,
timeoutMs = 8000,
): Promise<{event: 'load' | 'error' | 'timeout'; bodyText: string | null}> {
return app.evaluate(async ({webContents}, payload) => {
const wc = webContents.fromId(payload.id);
if (!wc || wc.isDestroyed()) {
throw new Error(`webContents ${payload.id} is not available`);
}
return wc.executeJavaScript(
`new Promise((resolve) => {
const frame = document.createElement('iframe');
let settled = false;
const finish = (event) => {
if (settled) {
return;
}
settled = true;
let bodyText = null;
try {
bodyText = frame.contentDocument && frame.contentDocument.body ?
frame.contentDocument.body.textContent : null;
} catch (e) {
bodyText = null;
}
resolve({event, bodyText});
};
frame.addEventListener('load', () => finish('load'));
frame.addEventListener('error', () => finish('error'));
setTimeout(() => finish('timeout'), ${payload.timeoutMs});
frame.style.display = 'none';
frame.src = ${JSON.stringify(payload.url)};
document.body.appendChild(frame);
})`,
true,
);
}, {id: webContentsId, url, timeoutMs}) as Promise<{event: 'load' | 'error' | 'timeout'; bodyText: string | null}>;
}

// Registers a service worker, then has it fetch `targetUrl` and report back.
async function serviceWorkerFetch(
app: ElectronApplication,
webContentsId: number,
swUrl: string,
targetUrl: string,
): Promise<{ok: boolean; body?: string; error?: string}> {
return app.evaluate(async ({webContents}, payload) => {
const wc = webContents.fromId(payload.id);
if (!wc || wc.isDestroyed()) {
throw new Error(`webContents ${payload.id} is not available`);
}
return wc.executeJavaScript(
`new Promise((resolve) => {
(async () => {
try {
if (!('serviceWorker' in navigator)) {
resolve({ok: false, error: 'no-serviceWorker'});
return;
}
const registration = await navigator.serviceWorker.register(${JSON.stringify(payload.swUrl)});
await navigator.serviceWorker.ready;
const worker = registration.active || navigator.serviceWorker.controller;
if (!worker) {
resolve({ok: false, error: 'no-active-worker'});
return;
}
const channel = new MessageChannel();
const reply = new Promise((res) => {
channel.port1.onmessage = (event) => res(event.data);
});
worker.postMessage({url: ${JSON.stringify(payload.targetUrl)}}, [channel.port2]);
const timeout = new Promise((res) => setTimeout(() => res({ok: false, error: 'timeout'}), 6000));
resolve(await Promise.race([reply, timeout]));
} catch (error) {
resolve({ok: false, error: String(error)});
}
})();
})`,
true,
);
}, {id: webContentsId, swUrl, targetUrl}) as Promise<{ok: boolean; body?: string; error?: string}>;
}

test.describe('local network access policy (MM-69241)', {tag: ['@P0', '@all']}, () => {
test('blocks server content from reaching an unrelated local service', async ({electronApp, serverMap, localServers}) => {
const entry = serverMap.local?.[0];
expect(entry, 'configured server view should exist').toBeTruthy();

const before = localServers.getSecretHitCount();
const result = await fetchFromView(electronApp, entry!.webContentsId, localServers.secretUrl);

expect(result.ok, 'fetch to a non-configured local service must be blocked').toBe(false);
expect(
localServers.getSecretHitCount(),
'a blocked request must never reach the internal service',
).toBe(before);
});

test('allows requests to the configured server origin', async ({electronApp, serverMap, localServers}) => {
const entry = serverMap.local?.[0];
expect(entry, 'configured server view should exist').toBeTruthy();

const result = await fetchFromView(electronApp, entry!.webContentsId, `${localServers.fakeServerOrigin}/ping`);

expect(result.ok, 'requests to the configured server must still be allowed').toBe(true);
});

test('blocks local subframe navigation but allows ordinary http subframes', async ({electronApp, serverMap, localServers}) => {
const entry = serverMap.local?.[0];
expect(entry, 'configured server view should exist').toBeTruthy();

// Hit counter is the source of truth: an iframe's load event fires even when blocked.
const before = localServers.getSecretHitCount();
await embedIframe(electronApp, entry!.webContentsId, localServers.secretUrl);
expect(
localServers.getSecretHitCount(),
'a blocked subframe must never reach the internal service',
).toBe(before);

// The guard must not preventDefault http(s) subframes — that previously broke embeds
// like YouTube. Cross-origin acceptance is covered by the isAllowedSubframeNavigation unit test.
const allowed = await embedIframe(electronApp, entry!.webContentsId, `${localServers.fakeServerOrigin}/embed`);
expect(allowed.bodyText, 'ordinary http subframes must still load and commit').toContain('mm-e2e-fake-server');
});

test('blocks a redirect from the configured server to a local service', async ({electronApp, serverMap, localServers}) => {
const entry = serverMap.local?.[0];
expect(entry, 'configured server view should exist').toBeTruthy();

const before = localServers.getSecretHitCount();
await fetchFromView(electronApp, entry!.webContentsId, `${localServers.fakeServerOrigin}/redirect`);

expect(
localServers.getSecretHitCount(),
'a redirect to a local service must be blocked (request never reaches it)',
).toBe(before);
});

test('blocks a service worker from reaching a local service', async ({electronApp, serverMap, localServers}) => {
const entry = serverMap.local?.[0];
expect(entry, 'configured server view should exist').toBeTruthy();

const before = localServers.getSecretHitCount();
const result = await serviceWorkerFetch(
electronApp,
entry!.webContentsId,
`${localServers.fakeServerOrigin}/sw.js`,
localServers.secretUrl,
);

expect(
localServers.getSecretHitCount(),
'a service worker request to a local service must be blocked',
).toBe(before);
expect(result.ok, 'service worker must not read the local service response').toBe(false);
});
});
Loading
Loading