コース目次 / 第2章
追いかける — 初見のコードで、実際にやってみる
見たことのない社内向けツールのコードを提示し、sourceからsinkへのテイント追跡を実際に行います。読んだだけで3つの疑わしい箇所を見つけ、なぜ疑わしいかを言語化します。
前章のカタログを使って、実際に読みます。以下は、ある社内向けレポート閲覧ツールという想定のコードです。まだ動かしていません。まずは、目だけで読んでください。
このコードはこの章のための架空の教材で、実行はしません。実際のコードレビューでも、まず読んで当たりをつけてから、必要なら手元で検証する——という順序を体験するための演習です。
// 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章です。
この章はまだ完了していません。
保存できませんでした(プライベートブラウズ中かもしれません)。この端末に進捗は残りませんが、先へは進めます。