feat: Security
This commit is contained in:
108
electron/path-jail.ts
Normal file
108
electron/path-jail.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { app } from 'electron';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export const DEFAULT_USER_DATA_SUBDIRS = [
|
||||
'server',
|
||||
'direct-messages',
|
||||
'plugins',
|
||||
'plugin-bundles',
|
||||
'plugin-cache',
|
||||
'themes',
|
||||
'metoyou'
|
||||
] as const;
|
||||
|
||||
export function isPathInside(parentPath: string, candidatePath: string): boolean {
|
||||
const relativePath = path.relative(parentPath, candidatePath);
|
||||
|
||||
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
async function realpathOrSelf(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fsp.realpath(filePath);
|
||||
} catch {
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAllowedSubdirs(allowedSubdirs: readonly string[]): string[] {
|
||||
return allowedSubdirs.map((entry) => entry.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')).filter(Boolean);
|
||||
}
|
||||
|
||||
export async function assertPathUnderRoot(
|
||||
rootPath: string,
|
||||
candidatePath: string,
|
||||
allowedSubdirs: readonly string[] = DEFAULT_USER_DATA_SUBDIRS
|
||||
): Promise<string> {
|
||||
if (typeof candidatePath !== 'string' || !candidatePath.trim()) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
|
||||
const [realRoot, realCandidate] = await Promise.all([realpathOrSelf(rootPath), realpathOrSelf(candidatePath)]);
|
||||
|
||||
if (!isPathInside(realRoot, realCandidate)) {
|
||||
throw new Error('Path is outside allowed app-data paths');
|
||||
}
|
||||
|
||||
const relativePath = path.relative(realRoot, realCandidate).replace(/\\/g, '/');
|
||||
const [topLevelSegment] = relativePath.split('/');
|
||||
|
||||
if (!topLevelSegment || !normalizeAllowedSubdirs(allowedSubdirs).includes(topLevelSegment)) {
|
||||
throw new Error('Path is outside allowed app-data paths');
|
||||
}
|
||||
|
||||
return realCandidate;
|
||||
}
|
||||
|
||||
export async function assertPathUnderUserData(
|
||||
candidatePath: string,
|
||||
allowedSubdirs: readonly string[] = DEFAULT_USER_DATA_SUBDIRS
|
||||
): Promise<string> {
|
||||
return assertPathUnderRoot(app.getPath('userData'), candidatePath, allowedSubdirs);
|
||||
}
|
||||
|
||||
const grantedPluginReadRoots = new Set<string>();
|
||||
|
||||
export function grantPluginReadRoot(rootPath: string): void {
|
||||
if (typeof rootPath !== 'string' || !rootPath.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
grantedPluginReadRoots.add(path.resolve(rootPath));
|
||||
}
|
||||
|
||||
export function clearGrantedPluginReadRoots(): void {
|
||||
grantedPluginReadRoots.clear();
|
||||
}
|
||||
|
||||
async function assertPathUnderGrantedPluginRoot(candidatePath: string): Promise<string> {
|
||||
const realCandidate = await realpathOrSelf(candidatePath);
|
||||
|
||||
for (const rootPath of grantedPluginReadRoots) {
|
||||
const realRoot = await realpathOrSelf(rootPath);
|
||||
|
||||
if (isPathInside(realRoot, realCandidate)) {
|
||||
return realCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Path is outside allowed app-data paths');
|
||||
}
|
||||
|
||||
/** Resolves readable paths under app data or user-granted plugin source roots. */
|
||||
export async function resolveReadablePath(candidatePath: string): Promise<string | null> {
|
||||
if (typeof candidatePath !== 'string' || !candidatePath.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await assertPathUnderUserData(candidatePath);
|
||||
} catch {
|
||||
try {
|
||||
return await assertPathUnderGrantedPluginRoot(candidatePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user