はじめに
Webシステムでファイルアップロードする場面って色々ありますよね。
イントラ内の特定利用者が使うシステムならある程度信頼してもいいのかもしれませんが、
コンシューマー向けで不特定多数の人が利用する場合、悪意が無くてもウイルスファイルをアップされるリスクありますよね?
ファイルを受領した側が送信者の言われるがままに、拡張子を変えたりしてマルウェアに感染ってことも想定されます。
今ではSassサービスなどもあると思いますが、ここではバックエンドAPIでClamAVを使ったスキャンの例を実装してみます。
前提/環境/注意事項
<前提>
NodeJS / Express / NestJS がある程度分かっている人(細かい説明は割愛するので)
<環境>
・Windows10
・VSCode
・DockerDesktop
・Node(Express)
・NestJs
<注意以降>
・セキュリティに関わる部分なので、あくまで参考程度にお願いいたします。
実際は、外部から受領するファイルに関して、様々なバリデーション(拡張子、MIME、ファイルヘッダ)が必要となります。
実際のサービスに実装する場合、テストを十分に行い、セキュリティ専門家の監査を受けた上でサービスインする事をお勧めします。
準備1(プロジェクトスケルトン作成)
Nest CLIを使い、プロジェクトスケルトンを作成します。
$ nest new uploadfile-viruscheck-example
準備2(ライブラリインストール)
利用するライブラリ(nestjs-clamscan)をnpm install します。
利用するライブラリ(multer)をnpm install します。
$ cd fileupload-api
$ npm i nestjs-clamscan --save$ npm install multer --save
準備3(clamAVサーバー起動)
NestJSはVSCode上でnpmスクリプトからビルド&実行します。clamAVに関しては、Dockerで予め起動させておきます。
$ docker run --name clamav -d-p 3310:3310 quay.io/ukhomeofficedigital/clamav:latest
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
abb51b742e80 quay.io/ukhomeofficedigital/clamav:latest "/docker-entrypoint.…" 44 seconds ago Up 36 seconds 0.0.0.0:3310->3310/tcp clamav
停止は docker stop clamav
2回目以降の起動は docker start clamav
実装1コントローラー作成
クライアント側から、「multipart/form-data」で送られてきたデータを受け取り、
レスポンスは「HTTP 202 Accepted 」でレスポンスを返します。内部では、ウイルススキャンを実行しファイルを格納します。
NestJSでは一連の処理をデコレーターとインタセプタで記述できるので、Express素で実装するよりコード量が減ります。
import{Controller,HttpCode,HttpStatus,Param,Post,UploadedFile,UseInterceptors,}from'@nestjs/common';import{FileInterceptor}from'@nestjs/platform-express';import{memoryStorage}from'multer';import{AppService}from'./app.service';@Controller('api/v1')exportclassAppController{constructor(privatereadonlyappService:AppService){}@Post('files/:id')@UseInterceptors(FileInterceptor('file',{storage:memoryStorage(),}),)@HttpCode(HttpStatus.ACCEPTED)asyncuploadFile(@Param('id')id:string,@UploadedFile('file')file:Express.Multer.File,):Promise<void>{awaitthis.appService.scanFile(id,file);}}
実装2ウイルススキャン実施部分実装
サービスクラスでウイルススキャンを行います。
今回はclamdjsをNestJSのServiceとして提供されている「Nestjs-clamscan」を利用します。
・npm
・git
import{HttpException,HttpStatus,Injectable}from'@nestjs/common';import{ClamScanService}from'nestjs-clamscan';@Injectable()exportclassAppService{constructor(privatereadonlyclamScanService:ClamScanService){}asyncscanFile(id:string,file:Express.Multer.File):Promise<void>{constresult=awaitthis.clamScanService.scanBuffer(file.buffer);if(!result){console.log('file is bad(infect)');console.log(`result=>${result}`);thrownewHttpException('upload file is bad',HttpStatus.BAD_REQUEST);}console.log('file is good');}}
動作確認(テスト)
動作確認に関しては、定番?のテスト用マルウェア(無害)を用いいます。
これが検知できればOKです。(実際のマルウェアではくれぐれも試さないでください)
今回は、ファイルとしてではなく、Bufferとしてオンコードしました。
import{Test,TestingModule}from'@nestjs/testing';import{ClamscanModule}from'nestjs-clamscan';import{AppController}from'./app.controller';import{AppService}from'./app.service';describe('AppController',()=>{letappController:AppController;consttestFileContents={good:'test-contents',bad:'X5O!P%@AP[4PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*',};beforeEach(async()=>{constapp:TestingModule=awaitTest.createTestingModule({imports:[ClamscanModule.forRoot({host:process.env.HOST||'127.0.0.1',port:Number(process.env.PORT)||3310,}),],controllers:[AppController],providers:[AppService],}).compile();appController=app.get<AppController>(AppController);});describe('uploadFile',()=>{it('file is good should be output log "file is good"',async()=>{constlogSpy=jest.spyOn(console,'log');awaitappController.uploadFile('f0001',createFile(testFileContents.good));expect(logSpy).toHaveBeenCalledWith('file is good');return;});it('file is bad should be output log "file is bad(infect)"',async()=>{constlogSpy=jest.spyOn(console,'log');awaitappController.uploadFile('f0002',createFile(testFileContents.bad));expect(logSpy).toHaveBeenCalledWith('file is bad(infect)');return;});});constcreateFile=(contents:string):Express.Multer.File=>{return{fieldname:'file',originalname:'file0001',encoding:'UTF-8',mimetype:'text/plain',size:Buffer.from(contents,'utf-8').length,buffer:Buffer.from(contents,'utf-8'),stream:undefined,destination:undefined,filename:undefined,path:undefined,};};});
import{Test,TestingModule}from'@nestjs/testing';import{HttpException,HttpStatus,INestApplication}from'@nestjs/common';import*asrequestfrom'supertest';import{AppModule}from'./../src/app.module';describe('AppController (e2e)',()=>{letapp:INestApplication;beforeEach(async()=>{constmoduleFixture:TestingModule=awaitTest.createTestingModule({imports:[AppModule],}).compile();app=moduleFixture.createNestApplication();awaitapp.init();});describe('POST /api/v1/files/:id',()=>{it('good file is status 202',async()=>{returnrequest(app.getHttpServer()).post('/api/v1/files/001').attach('file',Buffer.from('test','utf-8'),{filename:'good.txt',contentType:'text/plain',}).expect(202);});it('bad file is status 400',async()=>{returnrequest(app.getHttpServer()).post('/api/v1/files/002').attach('file',Buffer.from('X5O!P%@AP[4PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*','utf-8',),{filename:'bad.txt',contentType:'text/plain',},).expect(400,HttpException.createBody('','upload file is bad',HttpStatus.BAD_REQUEST));});});});
その他
ファイルアップロードのバックエンドAPIの基本的な部分を作成してみました。
後は、さらに応用で作りこみですね!
要件によっては、ウイルススキャン後にオブジェクトストレージへ格納したり、
データしてインポートしたり等になると思います。
データとしてインポートする際には、スキーマバリデーションなども必要かもしれません。
ファイルメタデータも管理しないと、いつだれが送ったファイルかわかりませんしね。