Skip to content

Commit 348fa90

Browse files
feat: add support for loading webpack assets via custom protocol
This is more secure than using file:// and aligns us with Electron on the intention to stop making the file:// protocol special and powerful. Fixes #3508
1 parent 7ae554b commit 348fa90

File tree

5 files changed

+178
-3
lines changed

5 files changed

+178
-3
lines changed

packages/plugin/webpack/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@malept/cross-spawn-promise": "^2.0.0",
1616
"@types/node": "^18.0.3",
1717
"chai": "^4.3.3",
18+
"electron": "^29.0.1",
1819
"mocha": "^9.0.1",
1920
"sinon": "^13.0.1",
2021
"which": "^2.0.2",

packages/plugin/webpack/src/Config.ts

+39
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,45 @@ export interface WebpackPluginConfig {
130130
* The webpack config for your main process
131131
*/
132132
mainConfig: WebpackConfiguration | string;
133+
/**
134+
* Configuration for a custom protocol used to load assets from disk in packaged apps.
135+
* This has no effect on development apps.
136+
*/
137+
customProtocolForPackagedAssets?: {
138+
/**
139+
* Whether to use a custom protocol, if false file:// will be used
140+
* file:// is considered unsafe so you should opt in to this if you
141+
* can. It will become the default in an upcoming major version
142+
* of Electron Forge
143+
*/
144+
enabled: boolean;
145+
/**
146+
* Custom protocol name, defaults to "app-internal-static",
147+
*/
148+
protocolName?: string;
149+
/**
150+
* If you are going to register the protocol handler yourself for
151+
* some reason you can set this to false explicitly to avoid forge
152+
* injecting the protocol initialization code.
153+
*/
154+
autoRegisterProtocol?: boolean;
155+
/**
156+
* Protocol privileges, maps to the [`CustomScheme.privileges`](https://www.electronjs.org/docs/latest/api/structures/custom-scheme)
157+
* object from the core Electron API.
158+
*
159+
* Defaults to `standard | secure | allowServiceWorkers | supportFetchAPI | corsEnabled | codeCache`
160+
*/
161+
privileges?: {
162+
standard?: boolean;
163+
secure?: boolean;
164+
bypassCSP?: boolean;
165+
allowServiceWorkers?: boolean;
166+
supportFetchAPI?: boolean;
167+
corsEnabled?: boolean;
168+
stream?: boolean;
169+
codeCache?: boolean;
170+
};
171+
};
133172
/**
134173
* Instructs webpack to emit a JSON file containing statistics about modules, the dependency
135174
* graph, and various other build information for the main process. This file is located in

packages/plugin/webpack/src/WebpackConfig.ts

+43-2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ type WebpackMode = 'production' | 'development';
2121

2222
const d = debug('electron-forge:plugin:webpack:webpackconfig');
2323

24+
const DEFAULT_CUSTOM_PROTOCOL_NAME = 'app-internal-static';
25+
2426
export type ConfigurationFactory = (
2527
env: string | Record<string, string | boolean | number> | unknown,
2628
args: Record<string, unknown>
@@ -112,7 +114,7 @@ export default class WebpackConfigGenerator {
112114

113115
rendererEntryPoint(entryPoint: WebpackPluginEntryPoint, basename: string): string {
114116
if (this.isProd) {
115-
return `\`file://$\{require('path').resolve(__dirname, '..', 'renderer', '${entryPoint.name}', '${basename}')}\``;
117+
return this.getInPackageURLForRenderer(entryPoint, basename);
116118
}
117119
const baseUrl = `http://localhost:${this.port}/${entryPoint.name}`;
118120
if (basename !== 'index.html') {
@@ -165,9 +167,44 @@ export default class WebpackConfigGenerator {
165167
}
166168
}
167169

170+
if (this.isProd && this.pluginConfig.customProtocolForPackagedAssets?.enabled) {
171+
defines['__ELECTRON_FORGE_INTERNAL_PROTOCOL_CONFIGURATION__'] = JSON.stringify({
172+
protocolName: DEFAULT_CUSTOM_PROTOCOL_NAME,
173+
autoRegisterProtocol: true,
174+
privileges: {
175+
standard: true,
176+
secure: true,
177+
allowServiceWorkers: true,
178+
supportFetchAPI: true,
179+
corsEnabled: true,
180+
codeCache: true,
181+
},
182+
...this.pluginConfig.customProtocolForPackagedAssets,
183+
});
184+
}
185+
168186
return defines;
169187
}
170188

189+
private get shouldInjectProtocolLoader() {
190+
return (
191+
this.isProd &&
192+
this.pluginConfig.customProtocolForPackagedAssets?.enabled &&
193+
this.pluginConfig.customProtocolForPackagedAssets?.autoRegisterProtocol !== false
194+
);
195+
}
196+
197+
private getInPackageURLForRenderer(entryPoint: WebpackPluginEntryPoint, basename: string) {
198+
const shouldUseCustomProtocol = this.pluginConfig.customProtocolForPackagedAssets?.enabled;
199+
if (!shouldUseCustomProtocol) {
200+
return `\`file://$\{require('path').resolve(__dirname, '..', 'renderer', '${entryPoint.name}', '${basename}')}\``;
201+
}
202+
let customProtocol =
203+
this.isProd && this.pluginConfig.customProtocolForPackagedAssets?.enabled && this.pluginConfig.customProtocolForPackagedAssets?.protocolName;
204+
if (!customProtocol) customProtocol = DEFAULT_CUSTOM_PROTOCOL_NAME;
205+
return `${customProtocol}://renderer/${entryPoint.name}/${basename}`;
206+
}
207+
171208
async getMainConfig(): Promise<Configuration> {
172209
const mainConfig = await this.resolveConfig(this.pluginConfig.mainConfig);
173210

@@ -177,7 +214,11 @@ export default class WebpackConfigGenerator {
177214
const fix = (item: EntryType): EntryType => {
178215
if (typeof item === 'string') return (fix([item]) as string[])[0];
179216
if (Array.isArray(item)) {
180-
return item.map((val) => (val.startsWith('./') ? path.resolve(this.projectDir, val) : val));
217+
const injectedCode: string[] = [];
218+
if (this.shouldInjectProtocolLoader) {
219+
injectedCode.push(path.resolve(__dirname, 'inject', 'protocol-loader.js'));
220+
}
221+
return injectedCode.concat(item.map((val) => (val.startsWith('./') ? path.resolve(this.projectDir, val) : val)));
181222
}
182223
const ret: Record<string, string | string[]> = {};
183224
for (const key of Object.keys(item)) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as path from 'path';
2+
3+
// eslint-disable-next-line node/no-unpublished-import
4+
import { protocol } from 'electron';
5+
6+
import type { WebpackPluginConfig } from '../Config';
7+
8+
type InternalConfig = Required<Required<WebpackPluginConfig>['customProtocolForPackagedAssets']>;
9+
declare const __ELECTRON_FORGE_INTERNAL_PROTOCOL_CONFIGURATION__: InternalConfig;
10+
11+
const config: InternalConfig = __ELECTRON_FORGE_INTERNAL_PROTOCOL_CONFIGURATION__ as any;
12+
13+
const appRoot = path.join(__dirname, '..');
14+
const rendererRoot = path.join(appRoot, 'renderer');
15+
16+
const STATUS_CODE_BAD_REQUEST = 400;
17+
const STATUS_CODE_FORBIDDEN = 403;
18+
const STATUS_CODE_INTERNAL_SERVER_ERROR = 500;
19+
20+
protocol.registerFileProtocol(config.protocolName, (request, cb) => {
21+
try {
22+
const requestUrl = new URL(decodeURI(request.url));
23+
24+
if (requestUrl.protocol !== `${config.protocolName}:`) {
25+
return cb({ statusCode: STATUS_CODE_BAD_REQUEST });
26+
}
27+
28+
if (request.url.includes('..')) {
29+
return cb({ statusCode: STATUS_CODE_FORBIDDEN });
30+
}
31+
32+
if (requestUrl.host !== 'renderer') {
33+
return cb({ statusCode: STATUS_CODE_BAD_REQUEST });
34+
}
35+
36+
if (!requestUrl.pathname || requestUrl.pathname === '/') {
37+
return cb({ statusCode: STATUS_CODE_BAD_REQUEST });
38+
}
39+
40+
// Resolve relative to appRoot
41+
const filePath = path.join(appRoot, requestUrl.pathname);
42+
// But ensure we are within the rendererRoot
43+
const relative = path.relative(rendererRoot, filePath);
44+
const isSafe = relative && !relative.startsWith('..') && !path.isAbsolute(relative);
45+
46+
if (!isSafe) {
47+
return cb({ statusCode: STATUS_CODE_BAD_REQUEST });
48+
}
49+
50+
return cb({ path: filePath });
51+
} catch (error) {
52+
const errorMessage = `Unexpected error in ${config.protocolName}:// protocol handler.`;
53+
console.error(errorMessage, error);
54+
return cb({ statusCode: STATUS_CODE_INTERNAL_SERVER_ERROR });
55+
}
56+
});
57+
58+
protocol.registerSchemesAsPrivileged([
59+
{
60+
scheme: config.protocolName,
61+
privileges: config.privileges,
62+
},
63+
]);

yarn.lock

+32-1
Original file line numberDiff line numberDiff line change
@@ -987,6 +987,21 @@
987987
fs-extra "^9.0.1"
988988
minimist "^1.2.5"
989989

990+
"@electron/get@^2.0.0":
991+
version "2.0.3"
992+
resolved "https://registry.yarnpkg.com/@electron/get/-/get-2.0.3.tgz#fba552683d387aebd9f3fcadbcafc8e12ee4f960"
993+
integrity sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==
994+
dependencies:
995+
debug "^4.1.1"
996+
env-paths "^2.2.0"
997+
fs-extra "^8.1.0"
998+
got "^11.8.5"
999+
progress "^2.0.3"
1000+
semver "^6.2.0"
1001+
sumchecker "^3.0.1"
1002+
optionalDependencies:
1003+
global-agent "^3.0.0"
1004+
9901005
"@electron/get@^3.0.0":
9911006
version "3.0.0"
9921007
resolved "https://registry.yarnpkg.com/@electron/get/-/get-3.0.0.tgz#2b0c794b98902d0bc5218546872c1379bef68aa2"
@@ -3130,6 +3145,13 @@
31303145
dependencies:
31313146
undici-types "~5.26.4"
31323147

3148+
"@types/node@^20.9.0":
3149+
version "20.11.20"
3150+
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.20.tgz#f0a2aee575215149a62784210ad88b3a34843659"
3151+
integrity sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==
3152+
dependencies:
3153+
undici-types "~5.26.4"
3154+
31333155
"@types/normalize-package-data@^2.4.0":
31343156
version "2.4.1"
31353157
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
@@ -5545,6 +5567,15 @@ electron-wix-msi@^5.0.0:
55455567
optionalDependencies:
55465568
"@bitdisaster/exe-icon-extractor" "^1.0.10"
55475569

5570+
electron@^29.0.1:
5571+
version "29.0.1"
5572+
resolved "https://registry.yarnpkg.com/electron/-/electron-29.0.1.tgz#936c0623a1bbf272dea423305f074de6ac016967"
5573+
integrity sha512-hsQr9clm8NCAMv4uhHlXThHn52UAgrHgyz3ubBAxZIPuUcpKVDtg4HPmx4hbmHIbYICI5OyLN3Ztp7rS+Dn4Lw==
5574+
dependencies:
5575+
"@electron/get" "^2.0.0"
5576+
"@types/node" "^20.9.0"
5577+
extract-zip "^2.0.1"
5578+
55485579
emoji-regex@^8.0.0:
55495580
version "8.0.0"
55505581
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
@@ -6458,7 +6489,7 @@ external-editor@^3.0.3:
64586489
iconv-lite "^0.4.24"
64596490
tmp "^0.0.33"
64606491

6461-
extract-zip@^2.0.0:
6492+
extract-zip@^2.0.0, extract-zip@^2.0.1:
64626493
version "2.0.1"
64636494
resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
64646495
integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==

0 commit comments

Comments
 (0)