自作のローカルWebアプリを Tailscale Funnel で外に出して運用しています。
公開にあたって「合言葉」「総当たり対策」「レート制限」「パストラバーサル対策」を自分で書き、
外から叩いて動作も確認していました。
先日あらためて全体を見直したところ、そのうち3つが、実際には機能していませんでした。
いずれも curl 数回で再現できます。順に、再現手順・原因・直し方を書きます。
1. X-Forwarded-For を信じたせいで、総当たり対策が無意味になっていた
書いていたコード
トンネル経由だと接続元が全員 127.0.0.1 に見えるため、
「誰か1人の失敗で全員がブロックされる」のを避けようとして、こう書いていました。
function ipOf(req) {
const fwd = req.headers["x-forwarded-for"];
if (fwd) return String(fwd).split(",")[0].trim(); // ← ここ
return req.socket.remoteAddress || "?";
}
合言葉を5回間違えたら15分ブロック、という処理をこの ipOf() の値で数えていました。
再現
X-Forwarded-For を毎回変えながら、わざと間違った合言葉で10回叩きます。
for i in $(seq 1 10); do
curl -s -o /dev/null -w "%{http_code} " \
-H "X-Forwarded-For: 198.51.100.$i" \
"http://127.0.0.1:8787/index.html?key=wrong$i"
done
403 403 403 403 403 403 403 403 403 403
10回とも403のまま。一度もブロックされません。
同じ値で送れば6回目に429が返るので、防御自体は動いています。
つまり ヘッダを1行変えるだけで、総当たり対策を無限に回避できる状態でした。
さらに悪い方
X-Forwarded-For は攻撃者が自由に書けます。ということは、
for i in $(seq 1 5); do
curl -s -H "X-Forwarded-For: <正規利用者のIP>" \
"http://127.0.0.1:8787/index.html?key=wrong"
done
これで正規の利用者を15分間締め出せます。
防御のつもりで書いたコードが、そのまま第三者による締め出し攻撃の手段になっていました。
原因
X-Forwarded-For はクライアントが送れるただの文字列です。
中継が挟まると中継が値を追記しますが、クライアントは好きなだけ前に積めるので、
先頭は常に自己申告です。信用できるのは「自分が信頼している中継が最後に足した要素」だけです。
そして、その要素を信用してよいのは本当に中継を経由したときだけです。
中継を挟まず直接繋いだ相手の X-Forwarded-For は、全部が捏造です。
直し方
この構成では、中継(Funnel)は同じマシン上にいるので、中継経由の接続はソケットがループバックになるという性質が使えます。
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
function clientKey(req) {
const socketIp = req.socket?.remoteAddress || "?";
// 中継を挟んでいない = XFF は全部捏造。見ない。
if (!LOOPBACK.has(socketIp)) return socketIp;
const fwd = req.headers["x-forwarded-for"];
if (!fwd) return socketIp;
const parts = String(fwd).split(",").map(s => s.trim()).filter(Boolean);
const nearest = parts[parts.length - 1]; // 最後段 = 中継が付けた値
return nearest ? `${socketIp}|${nearest}` : socketIp;
}
環境によって信頼できる中継の数は違うので、最後からN番目 は自分の構成に合わせてください。
要点は 「先頭を使わない」「中継の有無を確認してから使う」 の2点です。
判定の順番も変えました
もうひとつ、ブロックの判定順にも問題がありました。
// 変更前: ブロック中なら、合言葉が正しくても拒否する
if (isBlocked(ip)) return deny(429);
if (key !== TOKEN) { noteFailure(ip); return deny(403); }
「総当たりで当てられた場合の保険」のつもりでしたが、
これだと攻撃者が失敗を積むだけで、正しい合言葉を持つ本人まで締め出されることになります。
// 変更後: 正しい合言葉は常に通す。失敗した要求だけを数える
if (tokenMatches(TOKEN, key)) { failures.recordSuccess(who); return ok(); }
if (isBlocked(who)) return deny(429);
if (failures.isThrottledGlobally()) return deny(429); // 偽装で個別回避されても総量で止める
failures.recordFailure(who);
return deny(403);
個別ブロックは偽装で回避できても、全体の失敗予算(例: 15分に50回)は回避できません。
実測でも、偽装しながら60回叩くと45〜50回目で429になり、
その状態でも正しい合言葉のリクエストは200で通ります。
2. 「localhost からの操作には認証不要」が、任意のサイトから破れていた
書いていたコード
PC本体からの操作は合言葉なしで通す、という設計にしていました。
if (!isLocalhost(req)) {
// 合言葉の検証
}
// ここから書き込み処理
再現
ブラウザで適当なページを開いて、これを実行するだけです。
fetch('http://127.0.0.1:8787/api/message', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' }, // ← 単純リクエスト
body: JSON.stringify({ from: 'owner', text: '任意のサイトから書けます' }),
});
200 {"id":160,"from":"owner","text":"任意のサイトから書けます", ...}
通ります。
Content-Type: text/plain は CORS の単純リクエストなのでプリフライトが飛びません。
レスポンスはブラウザ側で読めませんが、書き込みは成立します。
サーバ側は JSON.parse(body) するだけで Content-Type を見ていないため、そのまま受理されます。
つまり 利用者が何か別のサイトを開いているだけで、そのサイトがローカルアプリに書き込める状態でした。
自分しか使わないローカルアプリだから安全、という前提が崩れます。
直し方
ブラウザは POST に必ず Origin を付けます。逆に curl や自動化スクリプトは付けません。
これを使って、ブラウザ発の別サイトからの書き込みだけを弾きます。
function isCrossOriginWrite(req) {
const site = req.headers["sec-fetch-site"];
if (site && site !== "same-origin" && site !== "none") return true;
const origin = req.headers["origin"];
if (!origin) return false; // 非ブラウザ = CSRF ではない
if (origin === "null") return true; // file:// など
try {
return new URL(origin).host !== req.headers["host"];
} catch {
return true; // 解釈できない Origin は通さない
}
}
if (isCrossOriginWrite(req)) return deny(403);
再現手順をもう一度流すと 403 になり、同一オリジン(自分の画面)からの書き込みは 200 のままです。
3. startsWith(ROOT) はパストラバーサル対策になっていなかった
書いていたコード
よく見る形だと思います。
const full = path.join(ROOT, file);
if (!full.startsWith(ROOT)) { res.writeHead(403); return res.end(); }
ROOT は .../product/app です。
再現
.. は new URL() の時点で正規化されて消えるので、パーセントエンコードしたまま渡します。
curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:8787/%2e%2e%2fserver-config.json"
# 403 ← これは弾ける
curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:8787/%2e%2e%2fapp-x.txt"
# 404 ← 弾けていない
404 が返るのは「検査を通過して、ファイルを読みに行った」という意味です。
path.join(ROOT, "../app-x.txt") は .../product/app-x.txt になります。
これは文字列としては .../product/app で始まるので、startsWith(ROOT) が true になります。
つまり ROOT と同じ文字で始まる兄弟(app-x.txt、app2/、app.config など)には到達できます。
たまたまそういう名前のファイルが無かっただけで、1つ置いた瞬間に読める状態でした。
直し方
区切り文字まで含めて比較します。path.resolve を使い、ヌルバイトも落とします。
function resolveWithinRoot(root, relPath) {
if (typeof relPath !== "string" || relPath.includes("\0")) return null;
const base = path.resolve(root);
const full = path.resolve(base, relPath);
if (full === base) return full;
return full.startsWith(base + path.sep) ? full : null;
}
path.relative(base, full) が .. で始まらないことを見る書き方でも構いません。
base + path.sep を足すか、path.relative を使うか。素の startsWith は使わない、が結論です。
まとめ
| 書いていたもの | 実際 | 直し方 |
|---|---|---|
xff.split(",")[0] を接続元にする |
ヘッダ偽装で回避・他人を締め出せる | 中継の有無を確認し、最後段を使う |
| ブロック中は鍵が正しくても拒否 | 攻撃者が本人を締め出せる | 正しい鍵は常に通す+全体の失敗予算 |
| localhost は認証不要 | 任意のサイトから書き込める |
Origin / Sec-Fetch-Site を見る |
full.startsWith(ROOT) |
兄弟ディレクトリに届く |
ROOT + path.sep か path.relative
|
3つとも「書いた時点では正しいつもりで、外から叩いて確認もした」ものです。
確認の仕方が甘かった、というより、確認する観点が無かったというのが正直なところでした。
同じ形のコードを書いている方は、上の curl をそのまま流してみてください。数分で分かります。
なお、これらの判定は分散して書くと必ず食い違います。実際、私は同じ判定を3ファイルにコピーしていて、
**3つとも同じ欠陥を持っていました。**1ファイルに集約してテストを書き、
コピー先と内容が一致しているかを検査するテストも足しました。