Skip to content

Commit 92142c7

Browse files
committed
Generate file handle IDs randomly
Mitigates a hypothetical attack where a malicious extension could enumerate all previously opened file IDs and then attempt to overwrite them as they were just sequential numbers.
1 parent 87bba95 commit 92142c7

File tree

3 files changed

+86
-46
lines changed

3 files changed

+86
-46
lines changed

src-main/windows/editor.js

+82-42
Original file line numberDiff line numberDiff line change
@@ -187,36 +187,75 @@ const isChildPath = (parent, child) => {
187187
return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative);
188188
};
189189

190+
/** @type {Set<string>} */
191+
const allFileIDs = new Set();
192+
193+
/**
194+
* @returns {string} A unique string.
195+
*/
196+
const generateFileId = () => {
197+
let result;
198+
let tries = 0;
199+
200+
do {
201+
tries++;
202+
if (tries > 50) {
203+
// Should never happen...
204+
throw new Error('Failed to generate file ID');
205+
}
206+
207+
result = 'desktop_file_id{';
208+
209+
// >200 bits of randomness; impractical to brute force.
210+
// Math.random() is not cryptographically secure, but even if someone can reverse it, they would
211+
// still only be able to access files that were already opened, so impact is not that big.
212+
const soup = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
213+
for (let i = 0; i < 40; i++) {
214+
result += soup[Math.floor(Math.random() * soup.length)];
215+
}
216+
217+
result += '}';
218+
} while (allFileIDs.has(result));
219+
220+
allFileIDs.add(result);
221+
return result;
222+
};
223+
190224
class EditorWindow extends ProjectRunningWindow {
191225
/**
192-
* @param {OpenedFile|null} file
226+
* @param {OpenedFile|null} initialFile
193227
* @param {boolean} isInitiallyFullscreen
194228
*/
195-
constructor (file, isInitiallyFullscreen) {
229+
constructor (initialFile, isInitiallyFullscreen) {
196230
super();
197231

198-
// This file ID system is not quite perfect. Ideally we would completely revoke permission to access
199-
// old projects after you load the next one, but our handling of file handles in scratch-gui is
200-
// pretty bad right now, so this is the best compromise.
201-
this.openedFiles = [];
202-
this.activeFileIndex = -1;
232+
/**
233+
* Ideally we would revoke access after loading a new project, but our file handle handling in
234+
* the GUI isn't robust enough for that yet. We do at least use random file handle IDs which
235+
* makes it much harder for malicious code in the renderer process to enumerate all previously
236+
* opened IDs and overwrite them.
237+
* @type {Map<string, OpenedFile>}
238+
*/
239+
this.openedFiles = new Map();
240+
this.activeFileId = null;
203241

204-
if (file !== null) {
205-
this.openedFiles.push(file);
206-
this.activeFileIndex = 0;
242+
if (initialFile !== null) {
243+
this.activeFileId = generateFileId();
244+
this.openedFiles.set(this.activeFileId, initialFile);
207245
}
208246

209247
this.openedProjectAt = Date.now();
210248

211-
const getFileByIndex = (index) => {
212-
if (typeof index !== 'number') {
213-
throw new Error('File ID not number');
214-
}
215-
const value = this.openedFiles[index];
216-
if (!(value instanceof OpenedFile)) {
249+
/**
250+
* @param {string} id
251+
* @returns {OpenedFile}
252+
* @throws if invalid ID
253+
*/
254+
const getFileById = (id) => {
255+
if (!this.openedFiles.has(id)) {
217256
throw new Error('Invalid file ID');
218257
}
219-
return this.openedFiles[index];
258+
return this.openedFiles.get(id);
220259
};
221260

222261
this.window.webContents.on('will-prevent-unload', (event) => {
@@ -263,14 +302,11 @@ class EditorWindow extends ProjectRunningWindow {
263302
});
264303

265304
ipc.handle('get-initial-file', () => {
266-
if (this.activeFileIndex === -1) {
267-
return null;
268-
}
269-
return this.activeFileIndex;
305+
return this.activeFileId;
270306
});
271307

272-
ipc.handle('get-file', async (event, index) => {
273-
const file = getFileByIndex(index);
308+
ipc.handle('get-file', async (event, id) => {
309+
const file = getFileById(id);
274310
const {name, data} = await file.read();
275311
return {
276312
name,
@@ -301,18 +337,18 @@ class EditorWindow extends ProjectRunningWindow {
301337
this.window.setDocumentEdited(changed);
302338
});
303339

304-
ipc.handle('opened-file', (event, index) => {
305-
const file = getFileByIndex(index);
340+
ipc.handle('opened-file', (event, id) => {
341+
const file = getFileById(id);
306342
if (file.type !== TYPE_FILE) {
307343
throw new Error('Not a file');
308344
}
309-
this.activeFileIndex = index;
345+
this.activeFileId = id;
310346
this.openedProjectAt = Date.now();
311347
this.window.setRepresentedFilename(file.path);
312348
});
313349

314350
ipc.handle('closed-file', () => {
315-
this.activeFileIndex = -1;
351+
this.activeFileId = null;
316352
this.window.setRepresentedFilename('');
317353
});
318354

@@ -331,14 +367,16 @@ class EditorWindow extends ProjectRunningWindow {
331367
return null;
332368
}
333369

334-
const file = result.filePaths[0];
335-
settings.lastDirectory = path.dirname(file);
370+
const filePath = result.filePaths[0];
371+
settings.lastDirectory = path.dirname(filePath);
336372
await settings.save();
337373

338-
this.openedFiles.push(new OpenedFile(TYPE_FILE, file));
374+
const id = generateFileId();
375+
this.openedFiles.set(id, new OpenedFile(TYPE_FILE, filePath));
376+
339377
return {
340-
id: this.openedFiles.length - 1,
341-
name: path.basename(file)
378+
id,
379+
name: path.basename(filePath)
342380
};
343381
});
344382

@@ -356,30 +394,32 @@ class EditorWindow extends ProjectRunningWindow {
356394
return null;
357395
}
358396

359-
const file = result.filePath;
397+
const filePath = result.filePath;
360398

361-
const unsafePath = getUnsafePaths().find(i => isChildPath(i.path, file));
399+
const unsafePath = getUnsafePaths().find(i => isChildPath(i.path, filePath));
362400
if (unsafePath) {
363-
// No need to wait for the message box to close
401+
// No need to block until the message box is closed
364402
dialog.showMessageBox(this.window, {
365403
type: 'error',
366404
title: APP_NAME,
367405
message: translate('unsafe-path.title'),
368406
detail: translate(`unsafe-path.details`)
369407
.replace('{APP_NAME}', unsafePath.app)
370-
.replace('{file}', file),
408+
.replace('{file}', filePath),
371409
noLink: true
372410
});
373411
return null;
374412
}
375413

376-
settings.lastDirectory = path.dirname(file);
414+
settings.lastDirectory = path.dirname(filePath);
377415
await settings.save();
378416

379-
this.openedFiles.push(new OpenedFile(TYPE_FILE, file));
417+
const id = generateFileId();
418+
this.openedFiles.set(id, new OpenedFile(TYPE_FILE, filePath));
419+
380420
return {
381-
id: this.openedFiles.length - 1,
382-
name: path.basename(file)
421+
id,
422+
name: path.basename(filePath)
383423
};
384424
});
385425

@@ -390,8 +430,8 @@ class EditorWindow extends ProjectRunningWindow {
390430
};
391431
});
392432

393-
ipc.on('start-write-stream', async (startEvent, index) => {
394-
const file = getFileByIndex(index);
433+
ipc.on('start-write-stream', async (startEvent, id) => {
434+
const file = getFileById(id);
395435
if (file.type !== TYPE_FILE) {
396436
throw new Error('Not a file');
397437
}

src-preload/editor.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ ipcRenderer.on('export-project-to-port', (e) => {
4141
window.addEventListener('message', (e) => {
4242
if (e.source === window) {
4343
const data = e.data;
44-
if (data && typeof data.ipcStartWriteStream === 'number') {
44+
if (data && typeof data.ipcStartWriteStream === 'string') {
4545
ipcRenderer.postMessage('start-write-stream', data.ipcStartWriteStream, e.ports);
4646
}
4747
}

src-renderer-webpack/editor/gui/filesystem-api.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ const toUnit8Array = (contents) => {
2323

2424
class WrappedFileWritable {
2525
/**
26-
* @param {number} id File ID from main
26+
* @param {string} id File ID from main
2727
*/
2828
constructor (id) {
2929
this._channel = new MessageChannel();
3030

31-
/** @type {Map<number, {resolve: () => void, reject: (error: unknown) => void}>} */
31+
/** @type {Map<string, {resolve: () => void, reject: (error: unknown) => void}>} */
3232
this._callbacks = new Map();
3333
this._lastMessageId = 1;
3434

@@ -106,7 +106,7 @@ class WrappedFileWritable {
106106

107107
class WrappedFileHandle {
108108
/**
109-
* @param {number} id File ID from main.
109+
* @param {string} id File ID from main.
110110
* @param {string} name Name including file extension.
111111
*/
112112
constructor (id, name) {

0 commit comments

Comments
 (0)