自分のPCで動かしている小さな HTTP サーバを、トンネルで外へ公開しています。配っているファイルが正しく取れるかを確かめる道具を作り、http://127.0.0.1:8787/ から取って md5 を照合していました。
その道具は、外から来た人が通る門を、構造上1つも通っていませんでした。同じ形を小さなサーバで再現すると、こうなります。
| 場合 | 127.0.0.1 から | 数えた | 中継ヘッダつき | 数えた |
|---|---|---|---|---|
| 公開一覧にある配布物 | 200 | 0 | 200 | 1 |
| 公開一覧から外れた配布物 | 200 | 0 | 403 | 0 |
| 非公開ファイル(陰性対照) | 200 | 0 | 403 | 0 |
| 門の無いサーバの非公開ファイル | 200 | 0 | 200 | 0 |
2行目が本題です。配布物を公開一覧から外してしまっても、127.0.0.1 から取るかぎり 200 が返ります。外から来た人には 403 です。確かめる道具は緑のまま、外の人は1人も取れません。
1. なぜ通らないのか
私のサーバは、外から来た要求を2つの門で扱っています(実ファイルから該当する2行だけを、字下げを外して抜き出しています。1行目の .png の除外は、画面の画像を数えないためのものです)。
if (!isLocalhost(req) && PUBLIC_FILES.has(file) && !file.endsWith(".png")) {
if (!isLocalhost(req) && !PUBLIC_FILES.has(file)) {
1つ目は「公開ファイルへの外からの要求を数える」門、2つ目は「公開一覧に無いファイルを外から求められたら、合言葉を確かめ、無ければ拒む(403。締め出し中は 429)」門です(2行目の続きの、合言葉を確かめる部分は省いています)。どちらも !isLocalhost(req) の内側にあります。自分のPCから画面を開いたときに、数を水増ししたり自分を締め出したりしないためです。
isLocalhost は別のファイルにある関数の別名で、その中身はこうです(同じく2行を、字下げを外して抜き出しています)。
if (PROXY_HEADERS.some((h) => req.headers[h])) return false;
return LOOPBACK.has(req.socket?.remoteAddress || "");
トンネルは同じPCの上で動くので、外から来た要求もソケットはループバックになります。そこで、トンネルが付ける中継ヘッダ(x-forwarded-for など。私のサーバでは6つ)が1つでもあれば「外」と扱います。
確かめる道具は、中継ヘッダを付けずに 127.0.0.1 から取っていました。サーバから見れば、それは自分のPCからの要求です。**2つの門は、どちらも最初の条件で飛ばされます。**200 が返るのは、ファイルを公開しているからではなく、門の外側を通っているからです。
2. 「陽性だけ」では足りない
外から来た体裁(中継ヘッダつき)で取って 200 が返れば、サーバの門は通す、とは言えます。ただしそれだけを見る検査は、表の4行目のような「門が無いサーバ」でも緑になります。非公開のファイルまで 200 で返しているのに、です。
だから直した道具は、外の体裁で公開ファイルを1つ取る(陽性)のに加えて、非公開ファイルを1つ取って 403 で拒まれることも見ます(陰性対照)。両方そろって初めて「門がある」と言えます。
3. 再現スクリプト
依存はありません。ファイルを読み書きせず、127.0.0.1 の使い捨てサーバだけを使います。実物から2か所を縮めています。中継ヘッダは6つのうち2つだけを見ます。2つ目の門は合言葉を確かめず、一律に拒みます。
// 自分のサーバを 127.0.0.1 から確かめると、外から来た要求が通る門を通らない。
// 依存0・ファイルを読み書きしない・127.0.0.1 の使い捨てサーバだけ(外へは出ない)。
// 使い方: node repro-loopback-gate.mjs (表)
// node repro-loopback-gate.mjs --json (JSON)
import http from "node:http";
// 中継(トンネル)が付けるヘッダ。1つでもあれば「外から来た」と扱う。
const PROXY_HEADERS = ["x-forwarded-for", "forwarded"];
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
function isLocal(req) {
if (PROXY_HEADERS.some((h) => req.headers[h])) return false;
return LOOPBACK.has(req.socket.remoteAddress || "");
}
const FILES = { "page.html": "<p>page</p>", "tool.mjs": "export {}", "data.js": "secret" };
function start(publicFiles, gated) {
const counts = {};
const server = http.createServer((req, res) => {
const file = req.url.slice(1);
if (gated && !isLocal(req) && publicFiles.has(file)) counts[file] = (counts[file] || 0) + 1; // 門1: 数える
if (gated && !isLocal(req) && !publicFiles.has(file)) { res.writeHead(403); return res.end(); } // 門2: 拒む
if (!(file in FILES)) { res.writeHead(404); return res.end(); }
res.writeHead(200); res.end(FILES[file]);
});
return new Promise((ok) => server.listen(0, "127.0.0.1", () => ok({ server, counts })));
}
async function get(server, file, outside) {
const { port } = server.address();
const headers = outside ? { "x-forwarded-for": "203.0.113.7" } : {};
const r = await fetch(`http://127.0.0.1:${port}/${file}`, { headers });
await r.arrayBuffer();
return r.status;
}
const cases = [
// [名前, 公開一覧, 門があるか, ファイル]
["公開一覧にある配布物", ["page.html", "tool.mjs"], true, "tool.mjs"],
["公開一覧から外れた配布物", ["page.html"], true, "tool.mjs"],
["非公開ファイル(陰性対照)", ["page.html", "tool.mjs"], true, "data.js"],
["門の無いサーバの非公開ファイル", ["page.html", "tool.mjs"], false, "data.js"],
];
const rows = [];
for (const [name, pub, gated, file] of cases) {
const { server, counts } = await start(new Set(pub), gated);
const loopback = await get(server, file, false);
const afterLoopback = counts[file] || 0;
const outside = await get(server, file, true);
const afterOutside = counts[file] || 0;
server.close();
rows.push({ name, gated, file, loopbackStatus: loopback, loopbackCounted: afterLoopback, outsideStatus: outside, outsideCounted: afterOutside - afterLoopback });
}
const out = { node: process.version, rows };
if (process.argv.includes("--json")) console.log(JSON.stringify(out, null, 2));
else {
console.log("| 場合 | 127.0.0.1 から | 数えた | 中継ヘッダつき | 数えた |");
console.log("|---|---|---|---|---|");
for (const r of rows) console.log(`| ${r.name} | ${r.loopbackStatus} | ${r.loopbackCounted} | ${r.outsideStatus} | ${r.outsideCounted} |`);
}
実行結果(Node v24.18.1・Windows 11・1台。2回流して JSON は1バイトも同じでした):
| 場合 | 127.0.0.1 から | 数えた | 中継ヘッダつき | 数えた |
|---|---|---|---|---|
| 公開一覧にある配布物 | 200 | 0 | 200 | 1 |
| 公開一覧から外れた配布物 | 200 | 0 | 403 | 0 |
| 非公開ファイル(陰性対照) | 200 | 0 | 403 | 0 |
| 門の無いサーバの非公開ファイル | 200 | 0 | 200 | 0 |
「数えた」の列も見てください。127.0.0.1 からの要求は、公開一覧にあるファイルでも 0 件です。自分の確認で自分の指標を汚さないための作りですが、裏返すと、確かめる道具はこの「数える」門も一度も通っていません。
4. 私の場合
- 照合の道具を作ったのは 2026-09-06 07:15、外の体裁と陰性対照を足したのは 2026-09-07 07:20 です(約24時間)。その間、道具は中継ヘッダを付けずに
127.0.0.1から取っていました。道具になる前にも、同じく127.0.0.1から取って照合を報告していた日があります(何日あったかは、経路を記録していない回があるので数えていません) - その24時間に、サーバのコードは変わっておらず、配布物は両端のコミットとも公開一覧に入っています。2026-10-22 に直した道具で取ると、配布物は2つとも 200、非公開ファイルは 403 でした。コミットされたコードの上では、配布は壊れていませんでした(その24時間に動いていたプロセスとトンネルが実際に外へ届いていたかは確かめていません。2026-10-22 の 200 と 403 も、その日の事実です)
- 直した道具が付ける中継ヘッダの値は、文書用のアドレス(
203.0.113.7)です。実在の相手を名乗らないためです。自分の確認だと分かる独自のヘッダも付けています。数える門の中でその印を見て、自分の確認を数から外しています
5. 測っていないこと
- これは「外部から見えることを確かめた」ではありません。中継ヘッダを付けても、要求はこのPCから出ています。トンネルの経路そのものは通っていません。サーバ側の門の判定を確かめたところまでです
- 陰性対照に使ったのは非公開ファイル1件だけです。他の非公開ファイルで同じ 403 が出るかは見ていません
- 中継ヘッダの一覧は私のサーバのものです。別のトンネルや逆プロキシが別の名前のヘッダを付ける場合は、同じ判定になりません
- 動かしたのは Node v24.18.1 の1台だけです
6. やめたこと
- 自分のサーバを、門の内側からだけ確かめること
- 陽性(取れた)だけを見て、門が閉まっていること(拒まれる)を見ないこと
私の無料ページに、Windows で AI を無人で走らせている人向けに、タスクの設定を読み取りだけで確かめる道具を置いています(ページの説明は道具より古く、道具のほうが多くを見ます)。 https://h26005.tailf680df.ts.net:10000/free.html