🌊

CIDR形式にIPが含まれるかを判定する方法を考える

に公開

本記事では、CIDR形式とIPアドレスの包含関係を判定する実装を行った知見からIPv4とCDIR形式に関して深掘りをしたので、その知見を残したいと思います。

*本記事で共有するコードはざっと生成AIを用いて作成したものになるので、あくまで参考にしていただけますと嬉しいです。

ホワイトリストIPアドレスとは?

IPホワイトリストとは、事前に許可した特定のIPアドレスからのアクセスのみを許可し、それ以外からのアクセスはすべて拒否(ブロック)するセキュリティ設定のことです。

この方法は、関係者以外からの不正アクセスやサイバー攻撃を受けるリスクを大幅に減らすことができるため、企業の内部システムや機密情報を扱うサービスなどで、セキュリティを強化する目的で広く利用されています。

IPv4 とCIDR形式

ホワイトリストIPを考えるにあたって、IPの表示形式に関しての理解が重要になってきます。IPアドレスの種類には、IPv4, IPv6, CIDR形式が存在します。

IPv4(Internet Protocol version 4)

形式: 4つの数字を「.(ドット)」で区切ったもの。
: 192.168.0.1
数字の範囲: 各部分は 0〜255 の整数(つまり 256通り)
アドレス数: 約 43億個(2³²)

IPv6(Internet Protocol version 6)

形式: 16進数(0〜f)を「:(コロン)」で区切ったもの。
: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
(省略形:2001:db8:85a3::8a2e:370:7334)
アドレス数: 約340澗(2¹²⁸ ≈ 3.4×10³⁸個)。
特徴:

  • ほぼ無限に近い数があるので、今後も枯渇しない。
  • IPv4より効率的な通信・セキュリティ機能が組み込まれている。

CIDR形式とは

CIDR(Classless Inter-Domain Routing)は IPアドレスの表記方法 で、192.168.0.0/24 のように

  • IPアドレス部分(例: 192.168.0.0)
  • ネットワークプレフィックス長(例: /24 → サブネットマスク255.255.255.0と同義)
    を組み合わせた形式です。

IPv4とIPv6での利用

  • 10.0.0.0/8 (IPv4での利用)
  • 2001:db8::/32 (IPv6での利用)

CIDR形式といえばIPv4というくらいIPv4形式に対して利用されるので、CIDR形式ではIPv6のイメージがなかったかもしれないですが、利用することができます。

CIDR形式が含むIPの例と実装案

ここで考える必要がある点として、単体IPとCIDR形式の重複登録が起こる可能性が生じます。利用しなくなったCIDR形式のグループを削除しても単体IPで登録がしてある場合、そのIPから接続ができてしまいます。これを避けるためにも重複して登録ができないようにする必要があります。

では、これをどうやって判定しましょうか?
判定するにあたって必要な知識があります。それは、ネットワークプレフィックス長の種類と含まれるIPアドレスのパターンです。プレフィックス長の種類は以下の通りです。

  • /0
    • アドレス数: 4,294,967,296
    • すべてのIPv4: これをホワイトリストに追加する意味はほぼない、、、バリデーションで追加を弾いてもいいくらい
  • /8
    • アドレス数: 16,777,216
  • /16
    • アドレス数: 65,536
  • /24
    • アドレス数: 256
    • 例: 192.168.0.0/24の場合
      • 192.168.0.0 ~ 192.168.0.255
  • /30
    • アドレス数: 4
    • 例: 192.168.0.0/30の場合
      • 192.168.0.0 ~ 192.168.0.3
  • /31
    • アドレス数: 2
    • 例: 192.168.0.0/31の場合
      • 192.168.0.0, 192.168.0.1
  • 32
    • アドレス数: 1
    • 例: 192.168.0.0/32の場合
      • 192.168.0.0

アドレス数から見てもわかるように最も使われることが多いのは/24のネットワークプレフィックス長です。

この例から以下の実装による判定関数が思いつく方が多いかと思います。この実装は、IPアドレスのネットワーク部の一致を確認し、ホスト部が範囲内に収まるかを判定しています。

const isIpInCidr = (
  sampleIp: string,
  cidrSampleIp: string
): boolean => {
  const [baseStr, prefixStr] = cidrSampleIp.split("/");
  const p = Number(prefixStr);

  const s = sampleIp.split(".").map(Number); // [s0,s1,s2,s3]
  const b = baseStr.split(".").map(Number);  // [b0,b1,b2,b3]

  switch (p) {
    case 32:
      // 完全一致
      return s[0] === b[0] && s[1] === b[1] && s[2] === b[2] && s[3] === b[3];

    case 31:
      // /31: 同一2個ブロック(末尾オクテットを2で割った商が同じ)
      return (
        s[0] === b[0] &&
        s[1] === b[1] &&
        s[2] === b[2] &&
        Math.floor(s[3] / 2) === Math.floor(b[3] / 2)
      );

    case 30:
      // /30: 同一4個ブロック(末尾オクテットを4で割った商が同じ)
      return (
        s[0] === b[0] &&
        s[1] === b[1] &&
        s[2] === b[2] &&
        Math.floor(s[3] / 4) === Math.floor(b[3] / 4)
      );

    case 24:
      // /24: 先頭3オクテット一致
      return s[0] === b[0] && s[1] === b[1] && s[2] === b[2];

    default:
      return false;
  }
};

この実装、一見良さそうに見えますよね?
挙動は全くといっていいほど問題はありません、、、、。実を言うと、プレフィックス長は0~32までの整数をすべて取ることができます。つまり、上記の実装方法だと0~32の32通りの場合分けが必要になります。また一方で、CIDR形式の包含関係の判定の定義に立ち返るとより適切な方法を取ることができるとわかります。

CIDR判定の定義

IPアドレスを32bit形式に変換したもの(判定したいIPアドレス) AND ネットマスク

以下で、192.168.0.5192.168.0.0/24 に含まれるかを判定します。

IPアドレスを32bit形式へ変換

32bit形式とは、32桁の2進数に直すと言うことです。各.ごとに8桁の2進数に直すことで実現することができます。

: 192.168.0.5の場合

192: 11000000
168: 10101000
0  : 00000000
5  : 00000101

これを連結させます。

11000000 10101000 00000000 00000101

これがIPアドレスを32bit形式に直したものです。(32bit表記①)

プレフィックス長からネットマスクを作成

先ほどちらっとお話ししたと思うのですが、プレフィックス長は0~32の整数で構成されています。これは何故かというと、32bit形式の先頭から何桁まで1を取るかといったものになります。

/24であればネットマスクは以下の通りです。(32bit表記②)

11111111 11111111 1111111 00000000

AND演算

これらの表記から判定を行います。

① AND ② の処理を行います。

11000000 10101000 00000000 00000000

これを10進数の表記に直すと

192.168.0.0

CIDR表記のネットワーク部になるので、このIP(192.168.0.5)は192.168.0.0/24に含まれるということがわかりました。

このようにして、CIDR形式に含まれるかを判定することができます。

堅牢な判定関数の作成

これらの内容を踏まえて判定関数をあらためて書き直すと以下のように書けます。

const ipToIntStrict = (ip: string): number | null => {
  const trimmed = ip.trim();
  // ざっくり形式チェック(負号・空文字・余分な文字を排除)
  if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(trimmed)) return null;

  const oct = trimmed.split(".").map((s) => Number(s));
  // 各オクテットが 0..255 の整数か
  for (const n of oct) {
    if (!Number.isInteger(n) || n < 0 || n > 255) return null;
  }
  const [a, b, c, d] = oct;
  // 32bit 符号なし整数へ(>>>0 で符号なし化)
  return (((a << 24) >>> 0) | (b << 16) | (c << 8) | d) >>> 0;
};

const prefixToMask = (p: number): number | null => {
  if (!Number.isInteger(p) || p < 0 || p > 32) return null;
  if (p === 0)  return 0 >>> 0;                 // 0x00000000
  if (p === 32) return 0xFFFFFFFF >>> 0;        // 0xFFFFFFFF
  // 上位 p ビット=1, 下位 (32-p)=0
  return (0xFFFFFFFF << (32 - p)) >>> 0;
};

export const isIpInCidr = (sampleIp: string, cidrSampleIp: string): boolean => {
  if (typeof sampleIp !== "string" || typeof cidrSampleIp !== "string") return false;

  const parts = cidrSampleIp.split("/");
  if (parts.length !== 2) return false;

  const [baseStrRaw, prefixStrRaw] = parts;
  const baseStr = baseStrRaw.trim();
  const prefix = Number(prefixStrRaw);

  const ip32   = ipToIntStrict(sampleIp);
  const base32 = ipToIntStrict(baseStr);
  const mask   = prefixToMask(prefix);

  if (ip32 === null || base32 === null || mask === null) return false;

  // 含有条件: (IP & mask) === (BASE & mask)
  return (ip32 & mask) === (base32 & mask);
};

最後少し走り書きになってしまいましたが、いかがだったでしょうか?
多少なりともお役に立てていると嬉しいです。

Discussion