Files
Toju/electron/path-jail.ts
2026-06-05 18:34:01 +02:00

109 lines
3.1 KiB
TypeScript

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;
}
}
}