feat: Security

This commit is contained in:
2026-06-05 18:34:01 +02:00
parent ee293d7daf
commit 45675192a5
134 changed files with 4128 additions and 446 deletions

View File

@@ -20,6 +20,8 @@ export async function handleSaveMessage(command: SaveMessageCommand, dataSource:
content: message.content,
timestamp: message.timestamp,
editedAt: message.editedAt ?? null,
revision: message.revision ?? 0,
headHash: message.headHash ?? null,
isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null,
linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null

View File

@@ -36,7 +36,8 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou
const nullableFields = [
'channelId',
'editedAt',
'replyToId'
'replyToId',
'headHash'
] as const;
for (const field of nullableFields) {
@@ -44,8 +45,13 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou
entity[field] = updates[field] ?? null;
}
if (updates.isDeleted !== undefined)
if (updates.revision !== undefined) {
existing.revision = updates.revision;
}
if (updates.isDeleted !== undefined) {
existing.isDeleted = updates.isDeleted ? 1 : 0;
}
if (updates.linkMetadata !== undefined)
existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;

View File

@@ -34,6 +34,8 @@ export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] =
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
timestamp: row.timestamp,
editedAt: row.editedAt ?? undefined,
revision: row.revision ?? 0,
headHash: row.headHash ?? undefined,
reactions: isDeleted ? [] : reactions,
isDeleted,
replyToId: row.replyToId ?? undefined,

View File

@@ -57,6 +57,8 @@ export interface MessagePayload {
content: string;
timestamp: number;
editedAt?: number;
revision?: number;
headHash?: string;
reactions?: ReactionPayload[];
isDeleted?: boolean;
replyToId?: string;

View File

@@ -41,4 +41,10 @@ export class MessageEntity {
@Column('text', { nullable: true })
linkMetadata!: string | null;
@Column('integer', { default: 0 })
revision!: number;
@Column('text', { nullable: true })
headHash!: string | null;
}

View File

@@ -7,13 +7,24 @@ import {
afterEach
} from 'vitest';
// Mock Electron modules before importing the module under test
const mockGetSystemIdleTime = vi.fn(() => 0);
const mockSend = vi.fn();
const mockGetMainWindow = vi.fn(() => ({
isDestroyed: () => false,
webContents: { send: mockSend }
}));
const {
mockGetSystemIdleTime,
mockSend,
mockGetMainWindow
} = vi.hoisted(() => {
const send = vi.fn();
const getSystemIdleTime = vi.fn(() => 0);
const getMainWindow = vi.fn(() => ({
isDestroyed: () => false,
webContents: { send }
}));
return {
mockGetSystemIdleTime: getSystemIdleTime,
mockSend: send,
mockGetMainWindow: getMainWindow
};
});
vi.mock('electron', () => ({
powerMonitor: {

View File

@@ -61,6 +61,8 @@ import {
import { listRunningProcessNames } from '../process-list';
import { detectActiveGame } from '../game-detection';
import { collectAppMetricsSnapshot } from '../app-metrics';
import { clearAllTokens } from '../api/auth-store';
import { assertPathUnderUserData, grantPluginReadRoot, resolveReadablePath } from '../path-jail';
const DEFAULT_MIME_TYPE = 'application/octet-stream';
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
@@ -72,6 +74,19 @@ const FILE_CLIPBOARD_FORMATS = [
'public.file-url',
'FileNameW'
] as const;
async function resolveUserDataFilePath(filePath: string): Promise<string | null> {
return await resolveReadablePath(filePath);
}
async function resolveWritableUserDataFilePath(filePath: string): Promise<string | null> {
try {
return await assertPathUnderUserData(filePath);
} catch {
return null;
}
}
const MIME_TYPES_BY_EXTENSION: Record<string, string> = {
'.7z': 'application/x-7z-compressed',
'.aac': 'audio/aac',
@@ -496,6 +511,10 @@ export function setupSystemHandlers(): void {
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
const snapshot = updateDesktopSettings(patch);
if (Object.prototype.hasOwnProperty.call(patch, 'allowedSignalingServers')) {
clearAllTokens();
}
await synchronizeAutoStartSetting(snapshot.autoStart);
updateCloseToTraySetting(snapshot.closeToTray);
await handleDesktopSettingsChanged();
@@ -565,6 +584,12 @@ export function setupSystemHandlers(): void {
return false;
}
const scopedDestination = await resolveWritableUserDataFilePath(destinationFilePath);
if (!scopedDestination) {
return false;
}
try {
const stats = await fsp.stat(sourceFilePath);
@@ -572,7 +597,7 @@ export function setupSystemHandlers(): void {
return false;
}
await fsp.copyFile(sourceFilePath, destinationFilePath);
await fsp.copyFile(sourceFilePath, scopedDestination);
return true;
} catch {
return false;
@@ -580,8 +605,14 @@ export function setupSystemHandlers(): void {
});
ipcMain.handle('file-exists', async (_event, filePath: string) => {
const scopedPath = await resolveUserDataFilePath(filePath);
if (!scopedPath) {
return false;
}
try {
await fsp.access(filePath, fs.constants.F_OK);
await fsp.access(scopedPath, fs.constants.F_OK);
return true;
} catch {
return false;
@@ -589,26 +620,40 @@ export function setupSystemHandlers(): void {
});
ipcMain.handle('get-file-url', async (_event, filePath: string) => {
if (typeof filePath !== 'string' || !filePath.trim()) {
const scopedPath = await resolveUserDataFilePath(filePath);
if (!scopedPath) {
return null;
}
try {
await fsp.access(filePath, fs.constants.F_OK);
return pathToFileURL(filePath).toString();
await fsp.access(scopedPath, fs.constants.F_OK);
return pathToFileURL(scopedPath).toString();
} catch {
return null;
}
});
ipcMain.handle('read-file', async (_event, filePath: string) => {
const data = await fsp.readFile(filePath);
const scopedPath = await resolveUserDataFilePath(filePath);
if (!scopedPath) {
return null;
}
const data = await fsp.readFile(scopedPath);
return data.toString('base64');
});
ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => {
const fileHandle = await fsp.open(filePath, 'r');
const scopedPath = await resolveUserDataFilePath(filePath);
if (!scopedPath) {
return null;
}
const fileHandle = await fsp.open(scopedPath, 'r');
try {
const safeStart = Math.max(0, Math.trunc(start));
@@ -623,7 +668,13 @@ export function setupSystemHandlers(): void {
});
ipcMain.handle('get-file-size', async (_event, filePath: string) => {
const stats = await fsp.stat(filePath);
const scopedPath = await resolveUserDataFilePath(filePath);
if (!scopedPath) {
return null;
}
const stats = await fsp.stat(scopedPath);
return stats.size;
});
@@ -632,23 +683,47 @@ export function setupSystemHandlers(): void {
return await readClipboardFiles();
});
ipcMain.handle('grant-plugin-read-root', (_event, rootPath: string) => {
grantPluginReadRoot(rootPath);
return true;
});
ipcMain.handle('write-file', async (_event, filePath: string, base64Data: string) => {
const scopedPath = await resolveWritableUserDataFilePath(filePath);
if (!scopedPath) {
return false;
}
const buffer = Buffer.from(base64Data, 'base64');
await fsp.writeFile(filePath, buffer);
await fsp.writeFile(scopedPath, buffer);
return true;
});
ipcMain.handle('append-file', async (_event, filePath: string, base64Data: string) => {
const scopedPath = await resolveWritableUserDataFilePath(filePath);
if (!scopedPath) {
return false;
}
const buffer = Buffer.from(base64Data, 'base64');
await fsp.appendFile(filePath, buffer);
await fsp.appendFile(scopedPath, buffer);
return true;
});
ipcMain.handle('delete-file', async (_event, filePath: string) => {
const scopedPath = await resolveWritableUserDataFilePath(filePath);
if (!scopedPath) {
return false;
}
try {
await fsp.unlink(filePath);
await fsp.unlink(scopedPath);
return true;
} catch (error) {
if ((error as { code?: string }).code === 'ENOENT') {
@@ -683,7 +758,14 @@ export function setupSystemHandlers(): void {
cancelled: false };
}
const stats = await fsp.stat(sourceFilePath);
const scopedSourcePath = await resolveUserDataFilePath(sourceFilePath);
if (!scopedSourcePath) {
return { saved: false,
cancelled: false };
}
const stats = await fsp.stat(scopedSourcePath);
if (!stats.isFile()) {
return { saved: false,
@@ -691,7 +773,7 @@ export function setupSystemHandlers(): void {
}
const result = await dialog.showSaveDialog({
defaultPath: defaultFileName || path.basename(sourceFilePath)
defaultPath: defaultFileName || path.basename(scopedSourcePath)
});
if (result.canceled || !result.filePath) {
@@ -699,7 +781,7 @@ export function setupSystemHandlers(): void {
cancelled: true };
}
await fsp.copyFile(sourceFilePath, result.filePath);
await fsp.copyFile(scopedSourcePath, result.filePath);
return { saved: true,
cancelled: false };
@@ -711,15 +793,22 @@ export function setupSystemHandlers(): void {
reason: 'missing-path' };
}
const scopedPath = await resolveUserDataFilePath(filePath);
if (!scopedPath) {
return { opened: false,
reason: 'outside-app-data' };
}
try {
const stats = await fsp.stat(filePath);
const stats = await fsp.stat(scopedPath);
if (!stats.isFile()) {
return { opened: false,
reason: 'not-a-file' };
}
const error = await shell.openPath(filePath);
const error = await shell.openPath(scopedPath);
return error
? { opened: false,
@@ -732,7 +821,13 @@ export function setupSystemHandlers(): void {
});
ipcMain.handle('ensure-dir', async (_event, dirPath: string) => {
await fsp.mkdir(dirPath, { recursive: true });
const scopedPath = await resolveWritableUserDataFilePath(dirPath);
if (!scopedPath) {
return false;
}
await fsp.mkdir(scopedPath, { recursive: true });
return true;
});

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MessageIntegrity1000000000013 implements MigrationInterface {
name = 'MessageIntegrity1000000000013';
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "messages" ADD COLUMN "revision" integer NOT NULL DEFAULT 0');
await queryRunner.query('ALTER TABLE "messages" ADD COLUMN "headHash" text');
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "messages" DROP COLUMN "headHash"');
await queryRunner.query('ALTER TABLE "messages" DROP COLUMN "revision"');
}
}

View File

@@ -0,0 +1,70 @@
import {
describe,
it,
expect,
beforeEach,
afterEach
} from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
assertPathUnderRoot,
clearGrantedPluginReadRoots,
grantPluginReadRoot,
resolveReadablePath
} from './path-jail';
describe('path-jail', () => {
let tempRoot = '';
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'metoyou-path-jail-'));
fs.mkdirSync(path.join(tempRoot, 'server', 'room-1'), { recursive: true });
fs.writeFileSync(path.join(tempRoot, 'server', 'room-1', 'file.txt'), 'ok');
});
afterEach(() => {
clearGrantedPluginReadRoots();
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('accepts paths inside allowed subdirectories', async () => {
const allowedPath = path.join(tempRoot, 'server', 'room-1', 'file.txt');
await expect(assertPathUnderRoot(tempRoot, allowedPath, ['server'])).resolves.toBe(allowedPath);
});
it('accepts cached plugin bundle paths under plugin-bundles', async () => {
const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0');
fs.mkdirSync(bundleDir, { recursive: true });
const bundlePath = path.join(bundleDir, 'main.js');
fs.writeFileSync(bundlePath, 'export default {}');
await expect(assertPathUnderRoot(tempRoot, bundlePath)).resolves.toBe(bundlePath);
});
it('rejects paths outside the user data root', async () => {
const outsidePath = path.join(os.tmpdir(), 'outside.txt');
await expect(assertPathUnderRoot(tempRoot, outsidePath, ['server'])).rejects.toThrow('outside allowed app-data paths');
});
it('rejects paths outside allowed subdirectories', async () => {
const pluginsPath = path.join(tempRoot, 'plugins', 'evil.txt');
await expect(assertPathUnderRoot(tempRoot, pluginsPath, ['server'])).rejects.toThrow('outside allowed app-data paths');
});
it('allows user-granted plugin source roots outside app data', async () => {
const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'metoyou-plugin-source-'));
const manifestPath = path.join(externalRoot, 'plugin-source.json');
fs.writeFileSync(manifestPath, '{}');
grantPluginReadRoot(externalRoot);
await expect(resolveReadablePath(manifestPath)).resolves.toBe(manifestPath);
fs.rmSync(externalRoot, { recursive: true, force: true });
});
});

108
electron/path-jail.ts Normal file
View 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;
}
}
}

View File

@@ -314,9 +314,10 @@ export interface ElectronAPI {
relaunchApp: () => Promise<boolean>;
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
readFile: (filePath: string) => Promise<string>;
readFileChunk: (filePath: string, start: number, end: number) => Promise<string>;
getFileSize: (filePath: string) => Promise<number>;
readFile: (filePath: string) => Promise<string | null>;
readFileChunk: (filePath: string, start: number, end: number) => Promise<string | null>;
getFileSize: (filePath: string) => Promise<number | null>;
grantPluginReadRoot: (rootPath: string) => Promise<boolean>;
writeFile: (filePath: string, data: string) => Promise<boolean>;
appendFile: (filePath: string, data: string) => Promise<boolean>;
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
@@ -451,6 +452,7 @@ const electronAPI: ElectronAPI = {
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
readFileChunk: (filePath, start, end) => ipcRenderer.invoke('read-file-chunk', filePath, start, end),
getFileSize: (filePath) => ipcRenderer.invoke('get-file-size', filePath),
grantPluginReadRoot: (rootPath) => ipcRenderer.invoke('grant-plugin-read-root', rootPath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data),
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),