mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* fix(security): canonicalize file-path-guard and restrict FILE_SYNC_* to outside userData file-path-guard now canonicalizes via fs.realpathSync.native (deepest existing ancestor) before the path.relative check, so symlinks and case-insensitive / 8.3 filesystem aliases can't bypass containment — this also hardens the existing backup-dir (GHSA-x937) check, which was purely lexical and bypassable on macOS. Adds assertPathOutside and applies it to every FILE_SYNC_* handler and READ_LOCAL_IMAGE_AS_DATA_URL in local-file-sync.ts, so the renderer (untrusted plugin/XSS) can no longer read/write/delete/list/inline files inside the app's private dir (userData: settings/grants/db) while user-chosen sync folders still work. * fix(security): validate clipboard image basePath, fileName and copy source ipc-handler-wrapper now uses isPathInsideDir containment (not a bare startsWith, which accepted a sibling like <userData>-evil) and rejects any fileName that is not a plain image basename or imageId containing separators/.. — closing the clipboard grant-file forgery (fileName='simpleSettings'). CLIPBOARD_COPY_IMAGE_FILE now requires an image-extension source. The supported-image-extension allowlist is consolidated into a single SUPPORTED_IMAGE_EXTENSIONS in mime-type-mapping.
104 lines
3.2 KiB
JavaScript
104 lines
3.2 KiB
JavaScript
/**
|
|
* Security tests for createValidatedHandler path/name validation.
|
|
* Closes the grant-file forgery via clipboard image save (fileName='simpleSettings'),
|
|
* the `<userData>-evil` sibling-prefix bypass (containment vs bare startsWith), and
|
|
* `..` traversal out of the images dir. Run with: npm run test:electron
|
|
*/
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const Module = require('node:module');
|
|
|
|
require('ts-node/register/transpile-only');
|
|
|
|
const modPath = path.resolve(__dirname, 'ipc-handler-wrapper.ts');
|
|
const originalModuleLoad = Module._load;
|
|
const USERDATA = path.resolve('/home/u/.config/sp');
|
|
|
|
Module._load = function (request, parent, isMain) {
|
|
if (request === 'electron') {
|
|
return { app: { getPath: () => USERDATA } };
|
|
}
|
|
return originalModuleLoad.call(this, request, parent, isMain);
|
|
};
|
|
|
|
const { createValidatedHandler, validatePathInUserData } = require(modPath);
|
|
|
|
// A SAVE-like handler (no errorValue): validation failures must reject.
|
|
const saveLike = createValidatedHandler(async () => 'WROTE', { validatePath: true });
|
|
const call = (args) => saveLike({}, args);
|
|
|
|
test('validatePathInUserData: containment, not bare startsWith', () => {
|
|
assert.equal(validatePathInUserData(path.join(USERDATA, 'clipboard-images')), true);
|
|
// The userData root itself is not a valid write target (strict containment).
|
|
assert.equal(validatePathInUserData(USERDATA), false);
|
|
assert.equal(validatePathInUserData(USERDATA + '-evil'), false); // sibling prefix
|
|
assert.equal(validatePathInUserData(path.resolve('/etc')), false);
|
|
});
|
|
|
|
test('allows a normal clipboard image write', async () => {
|
|
const r = await call({
|
|
basePath: path.join(USERDATA, 'clipboard-images'),
|
|
fileName: 'abc123.png',
|
|
base64Data: 'x',
|
|
});
|
|
assert.equal(r, 'WROTE');
|
|
});
|
|
|
|
test('rejects fileName="simpleSettings" (grant-file forgery)', async () => {
|
|
await assert.rejects(
|
|
() =>
|
|
call({
|
|
basePath: path.join(USERDATA, 'clipboard-images'),
|
|
fileName: 'simpleSettings',
|
|
base64Data: 'x',
|
|
}),
|
|
/Invalid file name/,
|
|
);
|
|
});
|
|
|
|
test('rejects a `..`/separator traversal in fileName', async () => {
|
|
await assert.rejects(
|
|
() =>
|
|
call({
|
|
basePath: path.join(USERDATA, 'clipboard-images'),
|
|
fileName: '../simpleSettings.png',
|
|
base64Data: 'x',
|
|
}),
|
|
/Invalid file name/,
|
|
);
|
|
});
|
|
|
|
test('rejects a non-image fileName even with a safe basename', async () => {
|
|
await assert.rejects(
|
|
() =>
|
|
call({
|
|
basePath: path.join(USERDATA, 'clipboard-images'),
|
|
fileName: 'evil.sh',
|
|
base64Data: 'x',
|
|
}),
|
|
/Invalid file name/,
|
|
);
|
|
});
|
|
|
|
test('rejects basePath outside userData', async () => {
|
|
await assert.rejects(
|
|
() => call({ basePath: path.resolve('/etc'), fileName: 'a.png', base64Data: 'x' }),
|
|
/Invalid base path/,
|
|
);
|
|
});
|
|
|
|
test('rejects an imageId containing a traversal', async () => {
|
|
const loadLike = createValidatedHandler(async () => 'READ', { validatePath: true });
|
|
await assert.rejects(
|
|
() =>
|
|
loadLike(
|
|
{},
|
|
{
|
|
basePath: path.join(USERDATA, 'clipboard-images'),
|
|
imageId: '../../etc/passwd',
|
|
},
|
|
),
|
|
/Invalid image id/,
|
|
);
|
|
});
|