109 lines
3.1 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|