コース目次 / 第2章

追いかける — 初見のコードで、実際にやってみる

見たことのない社内向けツールのコードを提示し、sourceからsinkへのテイント追跡を実際に行います。読んだだけで3つの疑わしい箇所を見つけ、なぜ疑わしいかを言語化します。

第2章 / 全6章目安 約14分この章のゴール: 初見のコードに対して、source→sinkの追跡を自分で行えるようになる

前章のカタログを使って、実際に読みます。以下は、ある社内向けレポート閲覧ツールという想定のコードです。まだ動かしていません。まずは、目だけで読んでください。

このコードはこの章のための架空の教材で、実行はしません。実際のコードレビューでも、まず読んで当たりをつけてから、必要なら手元で検証する——という順序を体験するための演習です。

// report-download.js — 社内向けレポート閲覧ツール(コード読解演習用)。
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');

const REPORTS_DIR = path.join(__dirname, 'reports');
const db = new DatabaseSync(path.join(__dirname, 'reports.db'));

http.createServer((req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1:4100');
  const role = req.headers['x-staff-role']; // 社内ゲートウェイが付与する想定のヘッダー

  if (url.pathname === '/report') {
    const name = url.searchParams.get('name') || '';
    if (name.startsWith('..')) { res.writeHead(400); res.end('不正なファイル名です'); return; }
    const filePath = path.join(REPORTS_DIR, name);
    fs.readFile(filePath, (err, data) => {
      if (err) { res.writeHead(404); res.end('見つかりません'); return; }
      res.end(data);
    });
    return;
  }

  if (url.pathname === '/search') {
    if (role !== 'admin') { res.writeHead(403); res.end('権限がありません'); return; }
    const keyword = url.searchParams.get('q') || '';
    const rows = db.prepare(`SELECT * FROM reports WHERE title LIKE '%${keyword}%'`).all();
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify(rows));
    return;
  }

  res.end('ok');
}).listen(4100);

読み終えたら、自分なりに「疑わしい箇所」をメモしてから、以下を読み進めてください。

追跡1 — /report のファイル名

source: url.searchParams.get(‘name’)。
sink: fs.readFile(filePath, …)(パストラバーサルの sink)。
途中の無害化: name.startsWith(‘..’) のチェックがある。一見、対策されているように見えます。
でも——ここが部分一致(先頭だけ)のチェックだと気づけたでしょうか。この検証は「文字列が .. で“始まる”か」しか見ていません。name の途中や後ろに .. が入っていたら、素通りします。sec-securecoding の原則2で言えば、これはブラックリスト(それも不完全な)——「連結後の実パスが基準ディレクトリの中に収まっているか」という許可リスト側の確認がありません。path.resolve した実パスでの確認が抜けている、というのが正確な指摘です。

追跡2 — /search のキーワード

source: url.searchParams.get(‘q’)。
sink: db.prepare(`…${keyword}…`)(SQL の sink)。
途中の無害化: 無い。テンプレートリテラルで、keyword が SQL 文にそのまま連結されています。sec-injection で学んだ、教科書どおりの SQL インジェクションの形です。LIKE 句だからといって安全にはなりません——プレースホルダを使わない限り、常にこの sink は危険です。

追跡3 — 認可の判定(要注意・保留)

role !== ‘admin’ というチェック自体は、あります(sec-authz の垂直権限昇格対策のように見える)。でも、role の出どころを見てください——req.headers[‘x-staff-role’]、リクエストヘッダーから直接読んでいます。
sec-http で学んだとおり、ヘッダーは送る側が自由に名乗れる自己申告です。もし、この値を「信頼できるゲートウェイが必ず設定し、外部からは絶対に上書きできない」という設計であれば安全ですが、そのコード1つを読んだだけでは、それが保証されているかは分かりません。

「読むだけでは断定できない」も、大事な発見

3つめが、この章でいちばん大事な学びです。

1つめと2つめは、コードだけで「危険」と断定できます。でも3つめは、「このヘッダーは、本当に外部から上書きできないのか?」という問いとして、報告書に書くべき項目です——断定はせず、確認すべき前提として指摘する。ホワイトボックス診断は「読んで即断定」だけでなく、「読んで、確認すべき疑問を洗い出す」作業でもあります。次の章で、この限界をもう少し掘り下げます。

持ち帰る一言

部分的な対策は、油断させる。 startsWith('..') のような一見の対策は、かえって見落としを誘います。sink を見つけたら、その手前の無害化が「本当に十分か」を、sec-securecoding の4原則に照らして確かめる。断定できないものは、確認すべき疑問として残す。次は、修正前後の差分を読むことで、パターン認識をさらに鍛えます。

こうなっていればOK

卒業まであと3章です。

この章はまだ完了していません。