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

@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type { Attachment } from '../../domain/models/attachment.model';
import {
isAllowedAttachmentStoredPath,
resolveAttachmentStorageBucket,
resolveAttachmentStoredFilename,
sanitizeAttachmentRoomName
@@ -74,7 +75,9 @@ export class AttachmentStorageService {
return null;
}
return this.findExistingPath([`${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/image/${filename}`]);
const safeFilename = resolveAttachmentStoredFilename('legacy', filename);
return this.findExistingPath([`${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/image/${safeFilename}`]);
}
async readFile(filePath: string): Promise<string | null> {
@@ -234,13 +237,14 @@ export class AttachmentStorageService {
private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
const appDataPath = await this.resolveAppDataPath();
if (!electronApi) {
if (!electronApi || !appDataPath) {
return null;
}
for (const candidatePath of candidates) {
if (!candidatePath) {
if (!candidatePath || !isAllowedAttachmentStoredPath(candidatePath, appDataPath)) {
continue;
}

View File

@@ -0,0 +1,26 @@
import {
describe,
it,
expect
} from 'vitest';
import { isAllowedAttachmentStoredPath, resolveAttachmentStoredFilename } from './attachment-storage.util';
describe('attachment-storage.util', () => {
it('allows attachment paths under server and direct-messages roots', () => {
const appDataPath = '/home/user/.config/metoyou';
expect(isAllowedAttachmentStoredPath(`${appDataPath}/server/room-1/image/file.png`, appDataPath)).toBe(true);
expect(isAllowedAttachmentStoredPath(`${appDataPath}/direct-messages/dm-1/files/file.bin`, appDataPath)).toBe(true);
});
it('rejects paths outside attachment roots', () => {
const appDataPath = '/home/user/.config/metoyou';
expect(isAllowedAttachmentStoredPath('/etc/passwd', appDataPath)).toBe(false);
expect(isAllowedAttachmentStoredPath(`${appDataPath}/plugins/evil.js`, appDataPath)).toBe(false);
});
it('sanitizes legacy filenames to basename-only storage names', () => {
expect(resolveAttachmentStoredFilename('legacy', '../../escape.txt')).toBe('legacy.txt');
});
});

View File

@@ -26,6 +26,21 @@ export function resolveAttachmentStoredFilename(attachmentId: string, filename:
: `${sanitizedAttachmentId}${sanitizedExtension}`;
}
export function isAllowedAttachmentStoredPath(candidatePath: string, appDataPath: string): boolean {
const normalizedCandidate = candidatePath.trim().replace(/\\/g, '/');
const normalizedRoot = appDataPath.trim().replace(/\\/g, '/')
.replace(/\/+$/, '');
if (!normalizedCandidate.startsWith(`${normalizedRoot}/`)) {
return false;
}
const relativePath = normalizedCandidate.slice(normalizedRoot.length + 1);
return relativePath.startsWith('server/')
|| relativePath.startsWith('direct-messages/');
}
export function resolveAttachmentStorageBucket(mime: string): 'video' | 'audio' | 'image' | 'files' {
if (mime.startsWith('video/')) {
return 'video';