Skip to content
This repository has been archived by the owner on Jun 4, 2024. It is now read-only.

Add URL scheme for loading resources from an extension #3413

Closed
wants to merge 4 commits into from
Closed
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
53 changes: 53 additions & 0 deletions desktop/main/extensionResources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/
import { app, protocol } from "electron";
import { join as pathJoin } from "path";

import Logger from "@foxglove/log";

import { getExtensionFile } from "../preload/extensions";

const log = Logger.getLogger(__filename);

// https://source.chromium.org/chromium/chromium/src/+/master:net/base/net_error_list.h
// The error code for registerFileProtocol must be from the net error list
const NET_ERROR_FAILED = -2;

export function registerExtensionProtocolHandlers(): void {
protocol.registerFileProtocol("x-foxglove-extension-rsrc", (request, callback) => {
// Split the URL into an extension ID and the resource path
const { host: extId, pathname: rsrcPath } = new URL(request.url);

const homePath = app.getPath("home");
const userExtensionRoot = pathJoin(homePath, ".foxglove-studio", "extensions");

// Look up the resource path
void getExtensionFile(extId, userExtensionRoot, rsrcPath)
.then((fsPath) => {
if (fsPath === "") {
throw new Error(`Failed to locate extension resource for ${request.url}`);
}

callback({ path: fsPath });
})
.catch((err: Error) => {
log.warn(err.message);
callback({ error: NET_ERROR_FAILED });
});
});
}

export function registerExtensionProtocolSchemes(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: "x-foxglove-extension-rsrc",
privileges: {
// The URL scheme adheres to "generic URI syntax", with a host (i.e.,
// the extension's ID) and a path. This also allows resolving relative
// resources, like a CSS file that uses url("./icon.png")
standard: true,
},
},
]);
}
20 changes: 13 additions & 7 deletions desktop/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import { AppSetting } from "@foxglove/studio-base/src/AppSetting";
import pkgInfo from "../../package.json";
import StudioAppUpdater from "./StudioAppUpdater";
import StudioWindow from "./StudioWindow";
import {
registerExtensionProtocolHandlers,
registerExtensionProtocolSchemes,
} from "./extensionResources";
import getDevModeIcon from "./getDevModeIcon";
import injectFilesToOpen from "./injectFilesToOpen";
import installChromeExtensions from "./installChromeExtensions";
Expand Down Expand Up @@ -193,6 +197,7 @@ function main() {
ipcMain.handle("getHomePath", () => app.getPath("home"));

// Must be called before app.ready event
registerExtensionProtocolSchemes();
registerRosPackageProtocolSchemes();

ipcMain.handle("updateNativeColorScheme", () => {
Expand All @@ -212,6 +217,7 @@ function main() {
log.debug(`Elapsed (ms) until new StudioWindow: ${Date.now() - start}`);
const initialWindow = new StudioWindow([...deepLinks, ...openUrls]);

registerExtensionProtocolHandlers();
registerRosPackageProtocolHandlers();

// Only production builds check for automatic updates
Expand Down Expand Up @@ -239,13 +245,13 @@ function main() {
// Content Security Policy
// See: https://www.electronjs.org/docs/tutorial/security
const contentSecurityPolicy: Record<string, string> = {
"default-src": "'self'",
"script-src": `'self' 'unsafe-inline' 'unsafe-eval'`,
"worker-src": `'self' blob:`,
"style-src": "'self' 'unsafe-inline'",
"connect-src": "'self' ws: wss: http: https: package:",
"font-src": "'self' data:",
"img-src": "'self' data: https: package: x-foxglove-converted-tiff:",
"default-src": `'self' x-foxglove-extension-rsrc:`,
"script-src": `'self' 'unsafe-inline' 'unsafe-eval' x-foxglove-extension-rsrc:`,
"worker-src": `'self' blob: x-foxglove-extension-rsrc:`,
"style-src": `'self' 'unsafe-inline' x-foxglove-extension-rsrc:`,
"connect-src": `'self' ws: wss: http: https: package:`,
"font-src": `'self' data: x-foxglove-extension-rsrc:`,
"img-src": `'self' data: https: package: x-foxglove-converted-tiff: x-foxglove-extension-rsrc:`,
};
const cspHeader = Object.entries(contentSecurityPolicy)
.map(([key, val]) => `${key} ${val}`)
Expand Down
36 changes: 31 additions & 5 deletions desktop/preload/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import { existsSync } from "fs";
import { mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
import { mkdir, readdir, readFile, realpath, rm, writeFile } from "fs/promises";
import JSZip from "jszip";
import { dirname, join as pathJoin } from "path";
import { dirname, relative, join as pathJoin } from "path";

import Logger from "@foxglove/log";

Expand Down Expand Up @@ -107,7 +107,11 @@ export async function getExtensions(rootFolder: string): Promise<DesktopExtensio
return extensions;
}

export async function loadExtension(id: string, rootFolder: string): Promise<string> {
export async function getExtensionFile(
id: string,
rootFolder: string,
file: string,
): Promise<string> {
// Find this extension
const userExtensions = await getExtensions(rootFolder);
const extension = userExtensions.find(
Expand All @@ -118,10 +122,32 @@ export async function loadExtension(id: string, rootFolder: string): Promise<str
return "";
}

const packagePath = pathJoin(extension.directory, "package.json");
// Compute the absolute path to the resource, to avoid symlink attacks
const packagePath = await realpath(pathJoin(extension.directory, file));

// Check that the path is actually still within the extension directory
if (relative(extension.directory, packagePath).startsWith("../")) {
log.error(`Possible path traversal in ${file}`);
return "";
}

return packagePath;
}

export async function loadExtension(id: string, rootFolder: string): Promise<string> {
// Locate the package.json file for this extension
const packagePath = await getExtensionFile(id, rootFolder, "package.json");
if (packagePath === "") {
return "";
}

const packageData = await readFile(packagePath, { encoding: "utf8" });
const packageJson = JSON.parse(packageData) as ExtensionPackageJson;
const sourcePath = pathJoin(extension.directory, packageJson.main);
const sourcePath = await getExtensionFile(id, rootFolder, packageJson.main);
if (sourcePath === "") {
return "";
}

return await readFile(sourcePath, { encoding: "utf-8" });
}

Expand Down
12 changes: 11 additions & 1 deletion packages/studio-base/src/providers/ExtensionRegistryProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import ExtensionRegistryContext, {
ExtensionRegistry,
RegisteredPanel,
} from "@foxglove/studio-base/context/ExtensionRegistryContext";
import isDesktopApp from "@foxglove/studio-base/util/isDesktopApp";

const log = Logger.getLogger(__filename);

Expand Down Expand Up @@ -62,7 +63,16 @@ export default function ExtensionRegistryProvider(props: PropsWithChildren<unkno
};

try {
const unwrappedExtensionSource = await extensionLoader.loadExtension(extension.id);
let pathPrefix: string;
if (isDesktopApp()) {
pathPrefix = `x-foxglove-extension-rsrc://${extension.id}`;
} else {
throw new Error("Extensions are not supported in the browser");
}

const unwrappedExtensionSource = (
await extensionLoader.loadExtension(extension.id)
).replace("@FOXGLOVE_EXTENSION_PATH_PREFIX@", pathPrefix);

// eslint-disable-next-line no-new-func
const fn = new Function("module", "require", unwrappedExtensionSource);
Expand Down