0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Node.jsのファイルアップロードを3行で守る — ゼロ依存のウイルススキャナー pompelmi 入門

0
Posted at

ファイルアップロードは「見落とされがちな攻撃面」

Node.jsでアップロード機能を実装するとき、多くのコードはこんな形になっています。

app.post('/upload', upload.single('file'), async (req, res) => {
  // MIMEと拡張子をチェック
  if (!req.file.mimetype.startsWith('image/')) {
    return res.status(400).send('Invalid type');
  }
  // そのままS3へ
  await s3.putObject({ Body: req.file.buffer }).promise();
  res.send('OK');
});

これで安全でしょうか?

Content-Type ヘッダーはクライアントが自由に書き換えられます。拡張子も同様です。
「画像」に見せかけてPHPシェルやマクロ入りOfficeファイルを送りつけることは難しくありません。

この問題を解決するのが pompelmi です。


pompelmiとは

pompelmiは、世界で最も広く使われているオープンソースウイルスエンジン ClamAV をNode.jsから扱いやすくしたラッパーライブラリです。

特徴:

  • ファイルをスキャンして Verdict.Clean / Verdict.Malicious / Verdict.ScanError のいずれかを返す
  • ランタイム依存ゼロ — Node.js組み込みの child_process のみ使用
  • デーモン不要clamscan コマンドをその都度起動するシンプルな設計
  • クラウド不要 — ファイルはインフラの外に出ない
  • ネイティブバインディング不要 — インストールは npm install だけ
  • macOS / Linux / Windows 対応

Node Weekly、Stack Overflow Blog、Help Net Security等で紹介済み。GitHubスター数 585+。


インストール

npm install pompelmi

ClamAV本体が必要です(pompelmiはClamAVをバンドルしていません)。

# macOS
brew install clamav && freshclam

# Linux (Ubuntu / Debian)
sudo apt-get install -y clamav clamav-daemon && sudo freshclam

# Windows (Chocolatey)
choco install clamav -y

基本的な使い方

const { scan, Verdict } = require('pompelmi');

const result = await scan('/path/to/uploaded-file.zip');

if (result === Verdict.Malicious) {
  throw new Error('File rejected: malware detected');
}

たったこれだけです。

スキャン結果の種類

結果 ClamAV終了コード 意味
Verdict.Clean 0 脅威なし。安全に処理できる
Verdict.Malicious 1 既知のウイルス・マルウェアシグネチャに一致
Verdict.ScanError 2 スキャン自体が失敗(I/Oエラー、暗号化ZIPなど)。不明な状態なので危険として扱うべき

各Verdictシンボルには .description プロパティがあり、ログ出力などに使えます。

console.log(Verdict.Clean.description);     // => 'Clean'
console.log(Verdict.Malicious.description); // => 'Malicious'
console.log(Verdict.ScanError.description); // => 'ScanError'

なぜstdoutをパースしないのか

他の多くのClamAVラッパーはClamAVの標準出力テキストを正規表現でパースしています。

/path/to/file: Eicar-Test-Signature FOUND

これは不安定です。ClamAVのバージョンアップでメッセージフォーマットが変われば壊れます。

pompelmiは終了コードだけを見ます。ClamAVが公式に定義している安定したインターフェースです。

終了コード 0 → Clean
終了コード 1 → Malicious
終了コード 2 → ScanError
それ以外   → Promise.reject(予期しないコード)

No stdout parsing. No regex. No surprises.


Expressとの統合

multerを使ったファイルアップロードの完全な例:

const express = require('express');
const multer = require('multer');
const path = require('path');
const { scan, Verdict } = require('pompelmi');

const app = express();
const upload = multer({ dest: '/tmp/uploads/' });

app.post('/upload', upload.single('file'), async (req, res) => {
  const filePath = path.resolve(req.file.path);

  try {
    const result = await scan(filePath);

    if (result === Verdict.Malicious) {
      return res.status(422).json({ error: 'Malware detected. Upload rejected.' });
    }

    if (result === Verdict.ScanError) {
      // スキャン失敗 = 状態不明 → 安全側に倒して拒否
      console.warn('Scan incomplete, rejecting as precaution.');
      return res.status(422).json({ error: 'Scan failed. File rejected as precaution.' });
    }

    // Verdict.Clean → ストレージへ保存
    await saveToFinalStorage(filePath);
    return res.status(200).json({ verdict: result.description });

  } catch (err) {
    console.error('Scan error:', err.message);
    return res.status(500).json({ error: 'Internal scan error.' });
  }
});

エラーハンドリングの考え方

pompelmiがPromiseをrejectするケース(スキャン結果ではなくシステムエラー):

状況 エラーメッセージ
filePath が文字列でない filePath must be a string
ファイルが存在しない File not found: <path>
clamscan がPATHにない ENOENT(OS由来)
予期しない終了コード Unexpected exit code: N
プロセスがシグナルで強制終了 Process killed by signal: <SIGNAL>

ScanError はPromiseがresolveするケースで、スキャンはできたがファイルの安全性が確認できなかった状態です。try/catch とは別に処理が必要な点に注意してください。


Docker / リモートスキャン

ClamAVをDockerコンテナやリモートサーバーで動かしている場合、hostport を渡すだけでOKです。

const result = await pompelmi.scan('/path/to/upload.zip', {
  host: '127.0.0.1',
  port: 3310,
});

docker-compose.yml の例:

services:
  app:
    build: .
    depends_on:
      - clamav

  clamav:
    image: clamav/clamav:stable
    ports:
      - "3310:3310"

アプリケーションコードの変更は hostport を追加するだけ。それ以外は同じです。


ClamAVのインストールを自動化する

CI/CDやDockerビルド時にClamAVを自動インストールしたい場合、内部ユーティリティを使えます。

const { ClamAVInstaller, updateClamAVDatabase } = require('pompelmi');

// ClamAVをインストール(すでにインストール済みなら何もしない)
const installMsg = await ClamAVInstaller();
console.log(installMsg);

// ウイルス定義DBを更新(すでに存在すれば何もしない)
const updateMsg = await updateClamAVDatabase();
console.log(updateMsg);
プラットフォーム パッケージマネージャー コマンド
macOS Homebrew brew install clamav
Linux apt-get sudo apt-get install -y clamav clamav-daemon
Windows Chocolatey choco install clamav -y

TypeScriptでの使用

型定義は同梱されています。追加インストール不要です。

import { scan, Verdict } from 'pompelmi';

async function safeScan(filePath: string): Promise<string | null> {
  try {
    const result = await scan(filePath);

    if (result === Verdict.ScanError) {
      console.warn('Scan incomplete, rejecting file as precaution.');
      return null;
    }

    return result.description; // 'Clean' | 'Malicious'
  } catch (err: unknown) {
    if (err instanceof Error) {
      console.error('Scan failed:', err.message);
    }
    return null;
  }
}

複数ファイルの並列スキャン

const { scan, Verdict } = require('pompelmi');

const files = ['/tmp/upload1.pdf', '/tmp/upload2.zip', '/tmp/upload3.png'];

const results = await Promise.all(
  files.map(async (filePath) => ({
    filePath,
    verdict: await scan(filePath),
  }))
);

const threats = results.filter(r => r.verdict === Verdict.Malicious);
if (threats.length > 0) {
  console.error('Threats found:', threats.map(t => t.filePath));
}

まとめ

比較対象 問題点
MIMEタイプチェックのみ クライアントが自由に偽装できる
拡張子チェックのみ shell.php.jpg 等で容易に突破される
クラウドAV API ファイルが外部へ送信される。GDPRリスク、レイテンシ、コスト発生
pompelmi インプロセス完結、プライベート、ゼロ外部依存、OSS

ファイルアップロードを受け付けるすべてのNode.jsアプリケーションにとって、pompelmiは数行で追加できる実用的な防衛層です。


リンク

気になった点やフィードバックはコメントかGitHub Discussionsでお気軽にどうぞ!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?