自作のローカルWebアプリを Tailscale Funnel で外部公開したところ、認証をかけていたはずの書き込みAPIが、外部から合言葉なしで通ってしまう状態になっていました。公開直後に自分で攻撃テストをして気づいたので実害はありませんでしたが、原因が典型的だったので共有します。
何が起きたか
ローカル向けに書いたNode.jsのサーバーで、「自分のPCからの操作なら認証不要、外部からは合言葉必須」という実装をしていました。
// 危険なコード
function isLocalhost(req) {
const a = req.socket.remoteAddress || "";
return a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
}
if (!isLocalhost(req)) {
// 外部からは合言葉を検証する
const key = url.searchParams.get("key") || req.headers["x-app-key"] || "";
if (key !== TOKEN) return json(res, 403, { error: "invalid key" });
}
ローカル環境では期待どおりに動きます。問題は、これを Tailscale Funnel(や Cloudflare Tunnel、ngrok なども同様)で外部公開したときです。
tailscale funnel --bg --https=10000 8787
この構成では、外部からのリクエストはトンネルのクライアントがローカルの 127.0.0.1:8787 に転送します。つまりサーバーから見た接続元は、外部の利用者であっても常に 127.0.0.1 です。
結果として isLocalhost(req) が常に true を返し、認証チェックそのものがスキップされます。URLさえ知っていれば誰でも書き込めました。
実際に確認した挙動
外部から、合言葉なしでPOSTを投げてみます。
curl -X POST https://<公開ホスト>:10000/api/message \
-H "Content-Type: application/json" \
-d '{"from":"owner","text":"test"}'
期待は 403 ですが、200 が返って書き込みが成功しました。ローカル判定を通過していたためです。
対処
接続元IPだけで信頼を判断せず、中継を示すヘッダーが付いていれば外部として扱うようにしました。
// 中継(リバースプロキシ/トンネル)を示すヘッダー
const PROXY_HEADERS = [
"x-forwarded-for",
"x-forwarded-proto",
"x-forwarded-host",
"forwarded",
"tailscale-user-login",
"cf-connecting-ip"
];
function isLocalhost(req) {
// 1つでも中継ヘッダーがあれば「外部から来た」とみなす
if (PROXY_HEADERS.some(h => req.headers[h])) return false;
const a = req.socket.remoteAddress || "";
return a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
}
修正後に同じ検証をやり直しました。
- 外部から合言葉なし → 403 で拒否
- 外部から合言葉あり → 200
- PC本体(トンネルを経由しない直接アクセス)→ 従来どおり認証不要
なぜこの穴が生まれやすいのか
「ローカルからのアクセスは信頼する」という設計自体は、開発中は自然です。問題は、公開手段を後から足したときに前提が壊れることです。
- 開発時: 接続元が 127.0.0.1 = 自分のPC → 正しい
- 公開後: 接続元が 127.0.0.1 = トンネルが転送しただけ → 前提が崩れる
コードは1行も変えていないのに、公開方法を変えた瞬間に認証が無効化されます。レビューでも見つけにくく、「動いているから大丈夫」で通り過ぎがちです。
教訓として持ち帰ったこと
外部公開の構成を変えたら、必ず外部から攻撃テストを実行する。 「認証をかけたつもり」ではなく、実際に認証なしのリクエストが拒否されることを外から確認するまで、完了とみなさない。今回はこれで公開から数分で発見できました。
あわせて、閲覧側も見直しました。書き込みだけ守っていても、公開URLを知られれば内部データは読み放題です。公開してよいファイルだけをホワイトリストにして、それ以外は閲覧にも合言葉を必須にしています。
const PUBLIC_FILES = new Set(["blog.html", "ogp.png"]);
if (!isLocalhost(req) && !PUBLIC_FILES.has(file)) {
// 合言葉を検証。失敗回数はIPごとに記録し、5回でブロック
}
同種の構成(ローカルアプリ + トンネルで外部公開)を使っている方は、一度 curl で認証なしリクエストを投げてみることをおすすめします。
補足: 公開前後に行う検査手順
この記事では原因を中心に書きました。実際に自分のアプリを検査する手順(curlの実行例、判定コードの全文、公開のたびに確認する5項目)は、長くなるので別途まとめています。
ローカルアプリを外部公開する前の検査手順 — コード全文と curl の実例
同じ構成(ローカルアプリ + トンネル公開)を使っている方は、一度 curl で認証なしのリクエストを投げてみることをおすすめします。