サポート画面に外部リンクを出すとき、「公式ドメインが文字列に入っているから許可」と判定していませんか。
この記事では、固定したヘルプサイトへのリンクだけを許可する小さな関数を作ります。対象はJavaScriptで入力URLを扱う開発者です。テスト用ドメインはhelp.example.com。実在するサービスの推奨リンクではありません。ネットワーク接続や外部ライブラリは不要です。
文字列のどこに名前があるのか
次の2つには、どちらもhelp.example.comという文字が含まれます。
https://help.example.com.evil.example/guide
https://evil.example/?next=https://help.example.com/
しかし、接続先のホストは許可したいサイトではありません。文字列全体のincludes()では、ホスト名、パス、クエリの区別がつかないためです。endsWith("example.com")も、境界を考慮せずに使うとnotexample.comを許可してしまいます。
今回はサブドメイン全体を許可する要件ではありません。許可するホストをひとつずつ列挙し、URLパーサーが取り出したhostnameと完全一致で比較します。
許可する条件を先に決める
- 入力は文字列で、
https://から始まる絶対URL。 - ホストは
help.example.comのみ。 - ユーザー名・パスワードを含まない。
- HTTPSの標準ポートのみ。明示された
:443は許可する。 - 前後の空白は取り除くが、内部の制御文字・空白・バックスラッシュは拒否する。
- パス、クエリ、フラグメントはこの段階では制限しない。
これはサンプルの入力方針です。すべてのサービスにそのまま当てはまる要件ではありません。
以下をhelp-url.mjsへ保存します。
const allowedHosts = new Set(["help.example.com"]);
export function parseAllowedHelpUrl(input) {
if (typeof input !== "string") return null;
const value = input.trim();
if (!/^https:\/\//i.test(value)) return null;
if (/[\u0000-\u0020\u007f\\]/.test(value)) return null;
let url;
try {
url = new URL(value);
} catch {
return null;
}
if (url.protocol !== "https:") return null;
if (url.username !== "" || url.password !== "") return null;
if (url.port !== "") return null;
if (!allowedHosts.has(url.hostname)) return null;
return url.href;
}
戻り値は真偽値ではなく、パーサーで正規化したURL、またはnullです。検証後に元の入力を再利用せず、この戻り値をリンク先として使います。HTML文字列へ直接連結せず、DOMやフレームワークの通常の属性設定を使ってください。
new URL()には基準URLを渡していません。相対URLを勝手に現在のサイトへ解決しないためです。ホスト名の大文字は正規化され、HTTPSの標準ポート:443はurl.portでは空文字になります。これらは下のテストにも含めます。
許可例より、似ている不許可例を多めに
次をhelp-url.test.mjsに保存し、node help-url.test.mjsで実行します。
import assert from "node:assert/strict";
import { parseAllowedHelpUrl } from "./help-url.mjs";
const accepted = [
["https://help.example.com", "https://help.example.com/"],
[" HTTPS://HELP.EXAMPLE.COM/guide ", "https://help.example.com/guide"],
["https://help.example.com:443/", "https://help.example.com/"],
["https://help.example.com/guide?q=one%20two#step2",
"https://help.example.com/guide?q=one%20two#step2"]
];
for (const [input, expected] of accepted) {
assert.equal(parseAllowedHelpUrl(input), expected);
}
const rejected = [
null, 123, "", "/guide", "//help.example.com/guide",
"http://help.example.com/", "javascript:alert(1)",
"https://help.example.com.evil.example/",
"https://evil.example/?next=https://help.example.com/",
"https://help.example.com@evil.example/",
"https://user:pass@help.example.com/",
"https://help.example.com:8443/",
"https://sub.help.example.com/",
"https://help.example.com./",
"https://help.example.com/a b",
"https://help.example.com/a\nb",
"https://help.example.com\\guide",
"https://"
];
for (const input of rejected) {
assert.equal(parseAllowedHelpUrl(input), null, String(input));
}
console.log(`${accepted.length} accepted and ${rejected.length} rejected cases passed`);
末尾ドット付きのホストや、追加のサブドメインは拒否しています。DNS上の扱いや組織の所有範囲を推測するのではなく、この関数の許可リストに入っていないためです。国際化ドメインを扱うなら、パーサーによるASCII表現への正規化も踏まえ、許可リストとテストを別途設計します。
この判定が保証しないこと
今回の関数が確認するのは、最初のURLが入力方針に合うことだけです。
- 許可サイトから別サイトへのリダイレクトは検査しません。
- 許可サイトの侵害、掲載内容、ダウンロードファイルの安全性は検査しません。
- パスやクエリにあるトークン・個人情報を取り除きません。
- バックエンドのURL取得に必要なSSRF対策の代わりにはなりません。
- ウォレットの署名要求やスマートコントラクトの安全性を判断しません。
固定のFAQだけに誘導したいなら、任意のURLを受け取らず、billingやloginのような内部キーから管理されたURLへ対応づける設計も検討できます。任意のパスを許可する必要がなければ、入力の自由度を減らした方が要件を説明しやすくなります。
この実験で重要だったのは、「安全なURL」という大きな名前を関数に付けないことでした。parseAllowedHelpUrlなら、許可したヘルプサイトへの入口の検査であることが伝わります。できることと、できないことを同じ場所で書いておくと、あとから別用途へ流用するときにも境界を見失いにくくなります。
参考・執筆について
検証環境はNode.js v24.14.1です。掲載したコードを実行し、許可4件・拒否18件のテストが通ることを確認しました。
執筆:Atlas System。公式サイト。AIを執筆・実装の補助に使用した独立した教材であり、Atlasの本番コードや監査結果を示すものではありません。