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?

URL.hostname で IDN を A-label に正規化する

0
Posted at

日本語.jp のような国際化ドメイン名を、比較や保存しやすい ASCII 表記にそろえたいことがあります。

ホスト名を A-label に正規化するだけであれば、punycode パッケージを追加しなくても、標準の URL API を利用できます。

new URL('https://日本語.jp').hostname
// 'xn--wgv71a119e.jp'

この記事では、国際化ドメイン名を正規化する方法と、入力検証を別に設ける理由を整理します。

用語

  • IDN: 国際化ドメイン名 (Internationalized Domain Name)。日本語.jpmünchen.de のように、ASCII 以外の文字を含むドメイン名
  • A-label: IDN を ASCII で表した形式。xn-- から始まる。たとえば 日本語.jpxn--wgv71a119e.jp
  • U-label: 日本語.jp のような Unicode 側の表記
  • Punycode: Unicode を ASCII だけで表すための符号化方式。IDN の A-label 生成に使われる

この記事では、比較や保存に使う値を A-label に統一します。

URL.hostname で正規化する

Node.js の WHATWG URL API は、ホスト名に含まれる Unicode 文字を ASCII 表記へ正規化します。

new URL('https://日本語.jp').hostname
// 'xn--wgv71a119e.jp'

new URL('https://münchen.de').hostname
// 'xn--mnchen-3ya.de'

new URL('https://中国.cn').hostname
// 'xn--fiqs8s.cn'

大文字小文字も同時に正規化されます。

new URL('https://EXAMPLE.COM').hostname
// 'example.com'

すでに A-label になっている入力を渡しても、結果は変わりません。

new URL('https://xn--wgv71a119e.jp').hostname
// 'xn--wgv71a119e.jp'

Node.js には内蔵の punycode モジュールがありますが、これは非推奨です。別途 npm の punycode パッケージを入れて使う選択肢もあります。ただ、URL のホスト名を A-label に正規化するだけなら、標準 API で十分です。

A-label にそろえる理由

入力時には Unicode を許可しても、保存値や比較キーは A-label に統一すると扱いやすくなります。

たとえば、データベースに次の 2 件が別々に保存される状態は避けたいはずです。

日本語.jp
xn--wgv71a119e.jp

どちらも同じドメイン名を表しています。保存前に正規化すれば、一意制約も期待どおりに機能します。

同じ考え方は、CDN やエッジ環境で許可リストと照合するときにも使えます。

入力
  ↓
A-label に正規化
  ↓
API、データベース、許可リストで同じ値を使う

正規化と入力検証は分ける

URL.hostname は正規化には便利ですが、外部に公開された通常のドメイン名として受け付けてよいかまでは判断しきれません。

たとえば、次のような入力も別途考慮する必要があります。

  • パスやポートを含む文字列
  • IPv4、IPv6 リテラル
  • 末尾のドット
  • アンダースコアを含む名前
  • 見た目が似た Unicode 文字を使うドメイン名

また、new URL() は URL の解析器です。ホスト名だけを受け取りたい画面で、example.com/path のような入力を黙って受け入れないようにします。

次の関数は、https://example.com/path のような URL 全体ではなく、example.com のようなホスト名だけを受け取る前提です。

URL として意味を持つ区切り文字を先に拒否しておくと、example.com/path のような入力を黙って受け入れずに済みます。

export interface AsciiHostnameOk {
  ok: true;
  ascii: string;
}

export interface AsciiHostnameError {
  ok: false;
  reason: string;
}

export type AsciiHostnameResult = AsciiHostnameOk | AsciiHostnameError;

const HOSTNAME_FORBIDDEN_CHARS_RE = /[\u0000-\u0020\u007F/:?#@[\]\\]/;

export function toAsciiHostname(input: string): AsciiHostnameResult {
  const trimmed = input.trim();
  if (!trimmed) {
    return { ok: false, reason: 'ホスト名を入力してください' };
  }

  // URL として意味を持つ区切り文字や制御文字は、解析前に拒否する。
  if (HOSTNAME_FORBIDDEN_CHARS_RE.test(trimmed)) {
    return {
      ok: false,
      reason: 'ホスト名のみを入力してください',
    };
  }

  try {
    const hostname = new URL(`https://${trimmed}`).hostname;
    if (!hostname || hostname === '.') {
      return { ok: false, reason: 'ホスト名の解析に失敗しました' };
    }
    return { ok: true, ascii: hostname };
  } catch {
    return { ok: false, reason: 'ホスト名の形式が不正です' };
  }
}

この関数の責務は、入力を A-label に正規化することです。

IP リテラルを許可するか、公開ホスト名として利用できる文字だけに制限するか、末尾のドットを削除するかは、別の検証関数で決めます。

URL 全体を受け取る画面では、先に new URL(input) で解析し、その結果の hostname を使います。ホスト名だけを受け取る画面とは、検証関数を分ける方が安全です。

Zod のスキーマで使う

入力時には Unicode のホスト名を許可し、後段の処理で扱う値は A-label にそろえられます。

Zod の transform() を使うと、入力を検証しながら、保存や比較に使う値へ変換できます。

import { z } from 'zod';

export const hostnameSchema = z
  .string()
  .trim()
  .transform((value, ctx) => {
    const result = toAsciiHostname(value);
    if (!result.ok) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: result.reason,
      });
      return z.NEVER;
    }
    return result.ascii;
  });
hostnameSchema.parse('日本語.jp')
// 'xn--wgv71a119e.jp'

公開ホスト名としての厳密な検証が必要なら、toAsciiHostname() の後に追加します。

許可リストも同じ形式にそろえる

許可リスト側にも Unicode が入る可能性があります。リクエスト側だけでなく、登録値も A-label にそろえます。

export function buildHostAllowlist(
  value?: string,
): ReadonlySet<string> | null {
  if (!value?.trim()) return null;

  const hostnames = value
    .split(',')
    .map((raw) => toAsciiHostname(raw))
    .filter((result): result is AsciiHostnameOk => result.ok)
    .map((result) => result.ascii);

  return hostnames.length > 0 ? new Set(hostnames) : null;
}

この例では、不正な許可リスト項目を無視しています。設定ミスを早く検出したい場合は、無視せずに例外にして、アプリ起動時に失敗させる設計もあります。

照合側は単純になります。

const url = new URL(request.url);
const allowed = allowlist.has(url.hostname);

Node.js 以外でも使えるか

URL は Web 標準の API です。Node.js だけでなく、ブラウザやエッジ環境でも利用できます。

new URL('https://日本語.jp').hostname
// 'xn--wgv71a119e.jp'

ただし、対象環境の互換性設定は確認してください。

Cloudflare Workers では、互換性日付や url_standard 互換性フラグによって URL 実装の挙動が変わることがあります。Fastly Compute にも URL API があります。

複数のランタイムで同じ正規化関数を共有する場合は、実際に使う環境ごとに代表的な IDN をテストしておくと安心です。

見た目が似たドメイン名には別の対策が必要

A-label に正規化しても、見た目が似たドメイン名を使う攻撃までは防げません。

次の 2 つは、見た目が似ています。

new URL('https://example.com').hostname
// 'example.com'

new URL('https://exаmple.com').hostname
// 'xn--exmple-4ve.com'

2 つ目の а は、ラテン文字の a ではなくキリル文字です。

このような問題は、ホモグラフ攻撃と呼ばれることがあります。

許可リストで既知のドメイン名だけを受け入れる、管理画面で A-label も表示するなど、用途に応じた対策を別途検討します。

まとめ

IDN を A-label に正規化するだけなら、標準の URL API を利用できます。

new URL(`https://${hostname}`).hostname

要点は次のとおりです。

  • 保存値や比較キーは A-label に統一する
  • 正規化と、公開ホスト名としての検証は分ける
  • リクエスト側と許可リスト側で同じ正規化関数を使う
  • 複数のランタイムで共有する場合は、互換性設定を確認してテストする
  • 見た目が似たドメイン名を使う攻撃には、別の対策を設ける

参考情報

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?