不完全なホスト名の正規表現:ドットのエスケープ漏れ

ドットのエスケープがない不完全なホスト名の正規表現

説明

正規表現のドット(.)は、ホスト名の区切りではなく1文字に一致するメタ文字です。\. とエスケープしないと、意図より広い文字列が一致します。例えば /^example.com$/ は exampleXcom や example-com にも一致します。受け入れた値の用途によっては、リダイレクトやSSRFの制限を回避され、ユーザーやサーバーのリクエストが意図しないホストへ送られるおそれがあります。

想定される影響

  • オープンリダイレクトでユーザーを外部サイトへ誘導される可能性があります。
  • SSRFフィルターを回避し、内部や機密のエンドポイントへリクエストを送られるおそれがあります。
  • 特定ドメインに限定した連携先が、別のホストへ広がる可能性があります。
  • 似たホスト名を使ったフィッシングや、サービスへの信頼の低下につながるおそれがあります。

対処方法

  • ドメイン区切りのドットを \. とエスケープしてください。例は ^((www|beta)\.)?example\.com$ です。
  • ^ と $ でホスト名全体に一致させてください。
  • 可能なら、host === 'example.com' や許可したホスト名の集合を使う完全一致の比較を選んでください。
  • 標準APIでURLを解析し、ポートを含まない hostname を検証してください。
  • サブドメインを許可する場合は、host === 'example.com' または host.endsWith('.example.com') で境界を確認してください。
  • .*、.? のような広い一致や、重複・入れ子の反復を避け、ラベルの長さと文字の規則を制限してください。

例

URLのリダイレクト

変更前

javascript
const express = require("express");
const app = express();

// /jump?next=https://example.com
app.get("/jump", (req, res) => {
  const next = req.query.next;
  if (!next) return res.status(400).send("missing");

  let host;
  try {
    host = new URL(next).hostname; // ユーザーが指定したURLのホスト名
  } catch {
    return res.status(400).send("bad url");
  }

  // '.'をエスケープしていないため、意図より広く一致
  // 例: staticXexampleYcomやstatic-exampleZcomも通る可能性
  const allowed = /^((static|media).)?example.com$/i.test(host);
  if (allowed) return res.redirect(next);
  return res.status(400).send("blocked");
});

変更後

javascript
const express = require("express");
const app = express();

// 厳密な許可リストによる検証
const ALLOW_HOSTS = new Set([
  "example.com",
  "static.example.com",
  "media.example.com",
]);

app.get("/jump", (req, res) => {
  const next = req.query.next;
  if (typeof next !== "string") return res.status(400).send("missing");

  let parsed;
  try {
    parsed = new URL(next);
  } catch {
    return res.status(400).send("bad url");
  }

  const host = parsed.hostname.toLowerCase();
  const safeTransport =
    parsed.protocol === "https:" &&
    parsed.port === "" &&
    parsed.username === "" &&
    parsed.password === "";

  // 完全一致のホスト名とHTTPSの既定ポートだけを許可
  if (!safeTransport || !ALLOW_HOSTS.has(host)) {
    return res.status(400).send("blocked");
  }
  return res.redirect(parsed.toString());
});

// 正規表現が必要な場合は、次のようにドットをエスケープ。
// const ok = /^((static|media)\.)?example\.com$/i.test(host);

解説:

  • 変更前: ^((static|media).)?example.com$ のドットが別の文字にも一致します。似たホスト名で検証を回避され、オープンリダイレクトや、サーバーからのリクエストではSSRFにつながる可能性があります。
  • 変更後: 解析したホスト名を許可リストと完全一致で比較します。HTTPSの既定ポートだけを許可し、認証情報を含むURLは拒否します。コメントの正規表現でも、ドットのエスケープとアンカーを使っています。

ブラウザーのホスト名

変更前

javascript
function checkLocation(document) {
  return /^example.com$/.test(globalThis.location.hostname);
}

現在のページのホスト名を確認する例です。実際の区切りに一致するよう、ドットをエスケープしてください。

変更後

javascript
function checkLocation(document) {
  return /^example\.com$/.test(globalThis.location.hostname);
}

参考資料