ファイルアップロードは「見落とされがちな攻撃面」
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コンテナやリモートサーバーで動かしている場合、host と port を渡すだけで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"
アプリケーションコードの変更は host と port を追加するだけ。それ以外は同じです。
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: https://github.com/pompelmi/pompelmi
- npm: https://www.npmjs.com/package/pompelmi
- ドキュメント: https://pompelmi.app
- Docker連携ガイド: https://pompelmi.app/docker.html
気になった点やフィードバックはコメントかGitHub Discussionsでお気軽にどうぞ!