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
3 changes: 2 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,8 @@
"annotatable",
"requestfinished",
"LOCF",
"Unack"
"Unack",
"Tabnabbing"
],
"dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US", "en-gb", "misc"],
"ignorePaths": [
Expand Down
33 changes: 33 additions & 0 deletions e2e/helper/addMaliciousImagery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
document.addEventListener('DOMContentLoaded', () => {
const openmct = window.openmct;

const maliciousTypeConfig = {
key: 'malicious.imagery',
name: 'Malicious Test Imagery',
cssClass: 'icon-image',
creatable: true,
initialize: (object) => {
object.telemetry = {
values: [
{ name: 'Time', key: 'utc', format: 'utc', hints: { domain: 1 } },
{ name: 'Image', key: 'url', format: 'image', hints: { image: 1 } }
]
};
}
};

const mockProvider = {
supportsRequest: (domainObject) => domainObject.type === 'malicious.imagery',
request: (domainObject) => {
return Promise.resolve([
{
utc: Date.now(),
url: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7#javascript:alert(1)'
}
]);
}
};

openmct.types.addType(maliciousTypeConfig.key, maliciousTypeConfig);
openmct.telemetry.addProvider(mockProvider);
});
113 changes: 113 additions & 0 deletions e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ This test suite is dedicated to tests which verify the basic operations surround
but only assume that example imagery is present.
*/

import { fileURLToPath } from 'url';

import {
createDomainObjectWithDefaults,
navigateToObjectWithRealTime,
Expand Down Expand Up @@ -59,6 +61,11 @@ test.describe('Example Imagery Object', () => {

// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();

// This is for malicious url testing when we open an image in a new tab
await page.addInitScript({
path: fileURLToPath(new URL('../../../../helper/addMaliciousImagery.js', import.meta.url))
});
});

test('Can use Mouse Wheel to zoom in and out of latest image', async ({ page }) => {
Expand Down Expand Up @@ -96,6 +103,78 @@ test.describe('Example Imagery Object', () => {
expect(newPage.url()).toContain('.jpg');
});

test('Open Image in New Tab is secure and prevents XSS/Tabnabbing', async ({ page, context }) => {
// try to right click on image
const backgroundImage = page.getByLabel('Focused Image Element');

await backgroundImage.click({
button: 'right',
// Need force option here due to annotation overlay which blocks playwright's click
// eslint-disable-next-line playwright/no-force-option
force: true
});

// click on open image in new tab
const pagePromise = context.waitForEvent('page');
await page.getByRole('menuitem', { name: 'Open Image in New Tab' }).click();
// expect new tab to be in browser
const newPage = await pagePromise;
await newPage.waitForLoadState();

// Ensure it navigated to a valid http/https URL (XSS Protection)
const url = newPage.url();
// check that the tab opened with the right protocol
expect(url).toMatch(/^https?:\/\//);
expect(url).toContain('.jpg');

// Verify the connection back to the parent is severed
const hasOpener = await newPage.evaluate(() => window.opener !== null);
expect(hasOpener).toBe(false);
});

test('Blocks navigation and warns when imagery is configured with a javascript: URL', async ({
page
}) => {
//Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });

// Create a default 'Example Imagery' object
const maliciousImagery = await createDomainObjectWithDefaults(page, {
type: 'Malicious Test Imagery'
});

// Verify that the created object is focused
await expect(page.locator('.l-browse-bar__object-name')).toContainText(maliciousImagery.name);
// await page.getByLabel('Focused Image Element').hover({ trial: true });

// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();

// listen for the console warning
const warningPromise = page.waitForEvent('console', {
predicate: (msg) => {
const text = msg.text();
const isBlocked = msg.type() === 'warning' && text.includes('Blocked unsafe');
return isBlocked;
},
timeout: 5000
});

const backgroundImage = page.getByLabel('Focused Image Element');

await backgroundImage.click({
button: 'right',
// Need force option here due to annotation overlay which blocks playwright's click
// eslint-disable-next-line playwright/no-force-option
force: true
});

await page.getByRole('menuitem', { name: 'Open Image in New Tab' }).click();

const warning = await warningPromise;
expect(warning).toBeDefined();
});

test('Can adjust image brightness/contrast by dragging the sliders', async ({
page,
browserName
Expand Down Expand Up @@ -1001,3 +1080,37 @@ async function waitForZoomAndPanTransitions(page) {
// Wait for image to stabilize
await page.getByLabel('Focused Image Element').hover({ trial: true });
}

// async function addMaliciousTypeAndProvider(page) {
// await page.evaluate(() => {
// const maliciousTypeConfig = {
// key: 'malicious.imagery',
// name: 'Malicious Test Imagery',
// cssClass: 'icon-image',
// creatable: true,
// initialize: (object) => {
// object.telemetry = {
// values: [
// { name: 'Time', key: 'utc', format: 'utc', hints: { domain: 1 } },
// { name: 'Image', key: 'url', format: 'image', hints: { image: 1 } }
// ]
// };
// }
// };

// const mockProvider = {
// supportsRequest: (domainObject) => domainObject.type === 'malicious.imagery',
// request: (domainObject) => {
// return Promise.resolve([
// {
// utc: Date.now(),
// url: 'javascript:alert("XSS")'
// }
// ]);
// }
// };

// window.openmct.types.addType(maliciousTypeConfig.key, maliciousTypeConfig);
// window.openmct.telemetry.addProvider(mockProvider);
// });
// }
34 changes: 32 additions & 2 deletions src/plugins/imagery/actions/OpenImageInNewTabAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,38 @@ class OpenImageInNewTabAction {
}

invoke(objectPath, view) {
const viewContext = (view.getViewContext && view.getViewContext()) || {};
window.open(viewContext.imageUrl, '_blank').focus();
const viewContext = view?.getViewContext?.() || {};
const url = viewContext.imageUrl;

// XSS protection: Prevents "javascript:alert('XSS')" attacks by only allowing certain images to load
// - urls that start with http/https.
// - data:image types that are NOT SVG (since svgs allow script tags)
// - blob: with the same origin
const safeAbsoluteUrl = url && /^(https?:\/\/)/i.test(url);
const safeRelativeUrl = url && /^\/(?!\/)/.test(url);
const isSafeUrl = safeAbsoluteUrl || safeRelativeUrl;
const isSafeData =
/^data:image\/(png|jpeg|jpg|gif|webp|avif|bmp);base64,[A-Za-z0-9+/=]+$/i.test(url);

let isSafeBlob = false;
if (url && /^blob:/i.test(url)) {
try {
const parsedUrl = new URL(url);
// same origin?
if (parsedUrl.origin === window.location.origin) {
isSafeBlob = true;
}
} catch (e) {
isSafeBlob = false;
}
}

if (isSafeUrl || isSafeData || isSafeBlob) {
// Tabnabbing protection: Open with noopener,noreferrer
window.open(url, '_blank', 'noopener,noreferrer')?.focus();
} else {
console.warn('Blocked unsafe or missing URL:', url);
}
}

appliesTo(objectPath, view = {}) {
Expand Down
Loading