説明
ユーザー入力をそのままファイルパスに使うと、意図したディレクトリの外にアクセスされるおそれがあります。攻撃者は ../、絶対パス(/、C:\)、シンボリックリンクなどでアクセス先を変えられます。操作内容とプロセスの権限によっては、/etc/passwd などのシステムファイルの読み取り、設定やログの上書き、ファイルの削除につながります。
想定される影響
- システムファイル、ソースコード、
.envなどの設定情報の漏えい - アップロードされたファイル、ログ、設定の上書きや削除
- 後で実行される場所への書き込みによる間接的な権限昇格
- 重要なファイルの削除や破損によるサービス停止
対処方法
- 入力をそのままパスに使わず、
path.resolveで正規化し、境界を確認してください。- 既存のパスを解決してください。例は
const resolved = fs.realpathSync(path.resolve(SAFE_ROOT, input));です。 - 正規化したルートの外は拒否してください。接頭辞で確認する場合は
SAFE_ROOT + path.sepを使い、似た名前のディレクトリと区別してください。
- 既存のパスを解決してください。例は
- ファイル名だけが必要なら、許可リスト、
sanitize-filename、^[a-zA-Z0-9._-]{1,100}$などの厳密な形式検証を使ってください。 - 許可していない絶対パス、
..、空の値を拒否してください。 realpathで既存のシンボリックリンクを解決してください。realpathやfs.lstatの検査後もファイルが変わる可能性があるため、ルートとその配下を攻撃者が変更できないようにしてください。- ファイルアクセス権を最小限にし、エラー応答で実際のパスを公開しないでください。
- アップロードやダウンロードのルート(
SAFE_ROOT)を固定し、その外へのアクセスを拒否してください。
例
変更前
javascript
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
// 変更前: 入力を join するだけでは .. による脱出が可能
app.get('/view', (req, res) => {
const name = req.query.f; // 例: ../../etc/passwd
const unsafePath = path.join(__dirname, 'public', name);
fs.createReadStream(unsafePath)
.on('error', () => res.status(404).end('not found'))
.pipe(res);
});
app.listen(3000);
変更後
javascript
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
// SAFE_ROOT はサーバーが管理し、攻撃者が変更できないディレクトリであること
const SAFE_ROOT = fs.realpathSync(path.resolve(__dirname, 'public'));
function isInside(baseDir, targetPath) {
const relative = path.relative(baseDir, targetPath);
return (
relative !== '' &&
relative !== '..' &&
!relative.startsWith('..' + path.sep) &&
!path.isAbsolute(relative)
);
}
app.get('/view', (req, res) => {
const name = req.query.f;
if (typeof name !== 'string') {
return res.status(400).send('parameter f is required');
}
// パスを正規化し、シンボリックリンクを解決
const candidate = path.resolve(SAFE_ROOT, name);
let resolved;
try {
resolved = fs.realpathSync(candidate);
} catch {
return res.status(404).end('not found');
}
// 境界を確認: SAFE_ROOT 配下のファイルだけを許可
if (!isInside(SAFE_ROOT, resolved)) {
return res.status(403).end('forbidden');
}
// 検証したパスだけを使用
const stream = fs.createReadStream(resolved);
stream.on('error', () => res.status(404).end('not found'));
stream.pipe(res);
});
app.listen(3000);
説明:
- 変更前: 入力
fを検証せずにパスに結合します。path.joinは..を正規化しますが、結果が基準ディレクトリ内に残ることは保証しません。 - 変更後: 文字列入力を
SAFE_ROOTを基準に解決し、fs.realpathSyncでリンクを解決します。path.relativeで実際のパスが管理下のルートの子孫か確認してから開きます。検査からファイルを開くまで構造を信頼できる必要があり、攻撃者が配下のパスを変更できる場合は、この確認だけで競合状態を解消できません。