パストラバーサル

検証されていないファイルパスによるパストラバーサル

説明

ユーザー入力をそのままファイルパスに使うと、意図したディレクトリの外にアクセスされるおそれがあります。攻撃者は ../、絶対パス(/、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 で実際のパスが管理下のルートの子孫か確認してから開きます。検査からファイルを開くまで構造を信頼できる必要があり、攻撃者が配下のパスを変更できる場合は、この確認だけで競合状態を解消できません。

参考資料