feat: Security
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user