0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカルアプリをトンネル公開したら、自分で書いた防御が3つとも効いていなかった

0
Posted at

自作のローカル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.txtapp2/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.seppath.relative

3つとも「書いた時点では正しいつもりで、外から叩いて確認もした」ものです。
確認の仕方が甘かった、というより、確認する観点が無かったというのが正直なところでした。

同じ形のコードを書いている方は、上の curl をそのまま流してみてください。数分で分かります。

なお、これらの判定は分散して書くと必ず食い違います。実際、私は同じ判定を3ファイルにコピーしていて、
**3つとも同じ欠陥を持っていました。**1ファイルに集約してテストを書き、
コピー先と内容が一致しているかを検査するテストも足しました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?