Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add file filter #192

Merged
merged 2 commits into from
Nov 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"@eslint/js": "^9.11.0",
"@swc/cli": "^0.4.0",
"@swc/core": "^1.7.26",
"@tsed/barrels": "^5.4.0",
"@tsed/barrels": "5.3.2",
"@tsed/cli-plugin-passport": "5.4.0",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.7",
Expand Down
14 changes: 14 additions & 0 deletions src/engine/IFileFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Awaitable } from "../utils/typeings.js";
import type { PlatformMulterFile } from "@tsed/common";
import { Exception } from "@tsed/exceptions";

export enum FileFilterPriority {
HIGHEST = Number.MAX_SAFE_INTEGER,
LOWEST = Number.MIN_SAFE_INTEGER,
}

export interface IFileFilter {
doFilter(file: string | PlatformMulterFile): Awaitable<boolean>;
get error(): Exception;
get priority(): number;
}
42 changes: 42 additions & 0 deletions src/engine/impl/fileFilters/AbstractFileFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { IFileFilter } from "../../IFileFilter.js";
import { PlatformMulterFile } from "@tsed/common";
import { Awaitable } from "../../../utils/typeings.js";
import { FileUtils } from "../../../utils/Utils.js";
import { Logger } from "@tsed/logger";
import { Exception } from "@tsed/exceptions";
import path from "node:path";

export abstract class AbstractFileFilter implements IFileFilter {
protected constructor(protected logger: Logger) {}
public async doFilter(file: string | PlatformMulterFile): Promise<boolean> {
let didPass = false;
try {
didPass = await this.doFilterInternal(file);
if (!didPass) {
await this.deleteFileOnFilterFail(file);
}
} catch (e) {
await this.deleteFileOnFilterFail(file);
throw e;
}
return didPass;
}

protected async deleteFileOnFilterFail(file: string | PlatformMulterFile): Promise<void> {
const resource = typeof file === "string" ? file : file.path;
const fileExists = await FileUtils.fileExists(resource);
if (fileExists) {
try {
await FileUtils.deleteFile(path.basename(resource), false);
} catch (e) {
this.logger.error(`Unable to delete resource ${resource}`);
throw e;
}
}
}

protected abstract doFilterInternal(file: string | PlatformMulterFile): Awaitable<boolean>;

public abstract get error(): Exception;
public abstract get priority(): number;
}
33 changes: 33 additions & 0 deletions src/engine/impl/fileFilters/AvFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Inject, Injectable, ProviderScope } from "@tsed/di";
import { FILE_FILTER } from "../../../model/di/tokens.js";
import { PlatformMulterFile } from "@tsed/common";
import { AvManager } from "../../../manager/AvManager.js";
import { AbstractFileFilter } from "./AbstractFileFilter.js";
import { Logger } from "@tsed/logger";
import { BadRequest, Exception } from "@tsed/exceptions";
import { FileFilterPriority } from "../../IFileFilter.js";

@Injectable({
scope: ProviderScope.SINGLETON,
type: FILE_FILTER,
})
export class AvFilter extends AbstractFileFilter {
public constructor(
@Inject() private avManager: AvManager,
@Inject() logger: Logger,
) {
super(logger);
}

protected override doFilterInternal(file: string | PlatformMulterFile): Promise<boolean> {
return this.avManager.scanFile(file);
}

public override get error(): Exception {
return new BadRequest("Failed to store file");
}

public override get priority(): number {
return FileFilterPriority.HIGHEST;
}
}
34 changes: 34 additions & 0 deletions src/engine/impl/fileFilters/MimeFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Inject, Injectable, ProviderScope } from "@tsed/di";
import { FILE_FILTER } from "../../../model/di/tokens.js";
import { PlatformMulterFile } from "@tsed/common";
import { AbstractFileFilter } from "./AbstractFileFilter.js";
import { Logger } from "@tsed/logger";
import { MimeService } from "../../../services/MimeService.js";
import { Exception, UnsupportedMediaType } from "@tsed/exceptions";
import { FileFilterPriority } from "../../IFileFilter.js";

@Injectable({
scope: ProviderScope.SINGLETON,
type: FILE_FILTER,
})
export class MimeFilter extends AbstractFileFilter {
public constructor(
@Inject() private mimeService: MimeService,
@Inject() logger: Logger,
) {
super(logger);
}

protected override async doFilterInternal(file: string | PlatformMulterFile): Promise<boolean> {
const resource = typeof file === "string" ? file : file.path;
return !(await this.mimeService.isBlocked(resource));
}

public get error(): Exception {
return new UnsupportedMediaType(`MIME type not supported`);
}

public override get priority(): number {
return FileFilterPriority.LOWEST;
}
}
3 changes: 3 additions & 0 deletions src/engine/impl/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export * from "./captcha/HcaptchaEngine.js";
export * from "./captcha/ReCAPTCHAEngine.js";
export * from "./captcha/TurnstileCaptchaEngine.js";
export * from "./ErrorProcessors/ProcessUploadErrorProcessorEngine.js";
export * from "./fileFilters/AbstractFileFilter.js";
export * from "./fileFilters/AvFilter.js";
export * from "./fileFilters/MimeFilter.js";
export * from "./HttpErrorRenderers/AuthenticationErrorRenderEngine.js";
export * from "./HttpErrorRenderers/BucketAuthenticationErrorRenderEngine.js";
export * from "./HttpErrorRenderers/DefaultHttpRenderEngine.js";
Expand Down
21 changes: 5 additions & 16 deletions src/manager/AvManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ import { Inject, Injectable, OnInit } from "@tsed/di";
import { AvFactory } from "../factory/AvFactory.js";
import type { PlatformMulterFile } from "@tsed/common";
import { Logger } from "@tsed/logger";
import { BadRequest } from "@tsed/exceptions";
import path from "node:path";
import { IAvEngine } from "../engine/IAvEngine.js";
import { AvScanResult } from "../utils/typeings.js";
import { FileUtils } from "../utils/Utils.js";

@Injectable()
export class AvManager implements OnInit {
Expand All @@ -30,27 +28,18 @@ export class AvManager implements OnInit {
}
}

public async scanFile(file: string | PlatformMulterFile): Promise<void> {
public async scanFile(file: string | PlatformMulterFile): Promise<boolean> {
if (this.avEngines.length === 0) {
return;
return true;
}
const resource = typeof file === "string" ? path.basename(file) : file.filename;
const scanResults = await this.doScan(resource);

let passed = true;
for (const scanResult of scanResults) {
if (scanResult.passed) {
continue;
}
const fileExists = await FileUtils.fileExists(resource);
if (fileExists) {
try {
await FileUtils.deleteFile(file, false);
} catch (e) {
// this basically means we could not delete the virus...
this.logger.error(`Unable to delete resource ${resource} after positive AV detection`);
throw new BadRequest(e.message);
}
}
passed = false;
let errStr = `AV engine ${scanResult.engineName} found issues `;
if (scanResult.additionalMessage) {
errStr += `AV scan of resource ${resource} for issues terminated with message "${scanResult.additionalMessage}"`;
Expand All @@ -63,8 +52,8 @@ export class AvManager implements OnInit {
errStr = "AV Scan ended with no reported reason";
}
this.logger.warn(errStr);
throw new BadRequest("Failed to store file");
}
return passed;
}

private doScan(resource: string): Promise<AvScanResult[]> {
Expand Down
21 changes: 21 additions & 0 deletions src/manager/FileFilterManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Inject, Injectable } from "@tsed/di";
import { PlatformMulterFile } from "@tsed/common";
import { FILE_FILTER } from "../model/di/tokens.js";
import { IFileFilter } from "../engine/IFileFilter.js";

@Injectable()
export class FileFilterManager {
public constructor(@Inject(FILE_FILTER) private readonly fileFilters: IFileFilter[]) {}

public async process(file: string | PlatformMulterFile): Promise<IFileFilter[]> {
const results = await Promise.all(
this.fileFilters
.sort((a, b) => b.priority - a.priority)
.map(async filter => ({
filter,
passed: await filter.doFilter(file),
})),
);
return results.filter(result => !result.passed).map(result => result.filter);
}
}
1 change: 1 addition & 0 deletions src/model/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export const HTTP_RENDER_ENGINE = Symbol.for("IHttpErrorRenderEngine");
export const ERROR_PROCESSOR_ENGINE = Symbol.for("IErrorProcessorEngine");
export const AV_ENGINE = Symbol.for("IAvEngine");
export const CAPTCHA_ENGINE = Symbol.for("ICaptchaEngine");
export const FILE_FILTER = Symbol.for("IFileFilter");
17 changes: 1 addition & 16 deletions src/services/FileService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { MimeService } from "./MimeService.js";
import { FileUtils } from "../utils/Utils.js";
import { FileUploadModel } from "../model/db/FileUpload.model.js";
import { FileUploadResponseDto } from "../model/dto/FileUploadResponseDto.js";
import { BadRequest, NotFound, UnsupportedMediaType } from "@tsed/exceptions";
import { BadRequest, NotFound } from "@tsed/exceptions";

/**
* Class that deals with interacting files from the filesystem
Expand Down Expand Up @@ -106,21 +106,6 @@ export class FileService {
return FileUploadResponseDto.fromModel(entry, this.baseUrl, humanReadable);
}

public async checkMime(resourcePath: string): Promise<void> {
let failedMime = false;
try {
failedMime = await this.mimeService.isBlocked(resourcePath);
} catch (e) {
FileUtils.deleteFile(resourcePath);
throw e;
}

if (failedMime) {
FileUtils.deleteFile(resourcePath);
throw new UnsupportedMediaType(`MIME type not supported`);
}
}

private resourceNotFound(resource: string): never {
throw new NotFound(`resource ${resource} is not found`);
}
Expand Down
20 changes: 12 additions & 8 deletions src/services/FileUploadService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { FileRepo } from "../db/repo/FileRepo.js";
import type { PlatformMulterFile } from "@tsed/common";
import { FileUploadModel } from "../model/db/FileUpload.model.js";
import { FileUrlService } from "./FileUrlService.js";
import { MimeService } from "./MimeService.js";
import { Builder, type IBuilder } from "builder-pattern";
import path from "node:path";
import fs from "node:fs/promises";
Expand All @@ -16,7 +15,6 @@ import { BadRequest, Exception, InternalServerError } from "@tsed/exceptions";
import { FileUtils, ObjectUtils } from "../utils/Utils.js";
import TimeUnit from "../model/constants/TimeUnit.js";
import argon2 from "argon2";
import { AvManager } from "../manager/AvManager.js";
import { EncryptionService } from "./EncryptionService.js";
import { RecordInfoSocket } from "./socket/RecordInfoSocket.js";
import { EntryModificationDto } from "../model/dto/EntryModificationDto.js";
Expand All @@ -25,6 +23,8 @@ import { ProcessUploadException } from "../model/exceptions/ProcessUploadExcepti
import { FileService } from "./FileService.js";
import { BucketService } from "./BucketService.js";
import BucketType from "../model/constants/BucketType.js";
import { FileFilterManager } from "../manager/FileFilterManager.js";
import { MimeService } from "./MimeService.js";

@Service()
export class FileUploadService {
Expand All @@ -39,11 +39,11 @@ export class FileUploadService {
@Inject() private fileUrlService: FileUrlService,
@Inject() private mimeService: MimeService,
@Inject() private logger: Logger,
@Inject() private avManager: AvManager,
@Inject() private encryptionService: EncryptionService,
@Inject() private recordInfoSocket: RecordInfoSocket,
@Inject() private fileService: FileService,
@Inject() private bucketService: BucketService,
@Inject() private fileFilterManager: FileFilterManager,
) {}

public async processUpload({
Expand All @@ -61,9 +61,10 @@ export class FileUploadService {
const token = crypto.randomUUID();
const uploadEntry = Builder(FileUploadModel).ip(ip).token(token);
[resourcePath, originalFileName] = await this.determineResourcePathAndFileName(source);

await this.filterFile(resourcePath);

uploadEntry.fileName(path.parse(resourcePath).name);
await this.scanFile(resourcePath);
await this.fileService.checkMime(resourcePath);
const mediaType = await this.mimeService.findMimeType(resourcePath);
uploadEntry.mediaType(mediaType);
const fileSize = await FileUtils.getFileSize(path.basename(resourcePath));
Expand Down Expand Up @@ -303,8 +304,11 @@ export class FileUploadService {
hashSum.update(fileBuffer);
return hashSum.digest("hex");
}

private scanFile(resourcePath: string): Promise<void> {
return this.avManager.scanFile(resourcePath);
private async filterFile(resourcePath: string): Promise<void> {
const failedFilters = await this.fileFilterManager.process(resourcePath);
if (failedFilters.length > 0) {
// throw the error of the highest priority
throw failedFilters.sort((a, b) => b.priority - a.priority)[0].error;
}
}
}