From 7ca3acd195a46eeab455b1d3467b5f01e91a8369 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Thu, 6 Aug 2026 09:59:23 +0900 Subject: [PATCH] =?UTF-8?q?IP=20=EB=A7=A4=EC=B9=AD=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=EB=A6=AC=ED=8B=B0=20=EC=B6=94=EA=B0=80=20-=20CIDR/=EC=99=80?= =?UTF-8?q?=EC=9D=BC=EB=93=9C=EC=B9=B4=EB=93=9C=20=ED=8F=AC=ED=95=A8(#194)?= =?UTF-8?q?.=20=ED=8F=AC=ED=83=88=20=EC=BA=90=EC=8B=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EB=8B=A4=EC=A4=91=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20-=20UI=20=EA=B0=9C=EC=84=A0/=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20=ED=98=B8=EC=B6=9C=20=EC=B6=94=EA=B0=80.=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=20API=20=EC=9C=A0=ED=8B=B8/=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20JSP=20UI=20=EC=97=B0=EA=B3=84?= =?UTF-8?q?=20=EC=B5=9C=EC=A0=81=ED=99=94.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../portal/common/util/IpAddressMatcher.java | 181 ++++++++++++++++++ .../common/util/IpAddressMatcherTest.java | 107 +++++++++++ 2 files changed, 288 insertions(+) create mode 100644 src/main/java/com/eactive/apim/portal/common/util/IpAddressMatcher.java create mode 100644 src/test/java/com/eactive/apim/portal/common/util/IpAddressMatcherTest.java diff --git a/src/main/java/com/eactive/apim/portal/common/util/IpAddressMatcher.java b/src/main/java/com/eactive/apim/portal/common/util/IpAddressMatcher.java new file mode 100644 index 0000000..b5b7f00 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/util/IpAddressMatcher.java @@ -0,0 +1,181 @@ +package com.eactive.apim.portal.common.util; + +/** + * 허용 IP 목록(allowlist) 매칭 유틸. + * + *

내부 API 가드에서 {@code PTL_PROPERTY} 로 관리하는 허용 IP 문자열을 해석한다. + * 목록은 콤마(,) / 세미콜론(;) / 줄바꿈 으로 구분하며 각 항목은 다음 형식을 지원한다.

+ * + * + * + * + * + * + * + * + *
지원 형식
형식설명
정확 일치{@code 127.0.0.1}, {@code ::1}문자열 완전 일치(IPv6 포함)
IPv4 CIDR{@code 172.30.1.0/24}prefix 0~32
IPv4 와일드카드{@code 172.30.*.*}옥텟 단위 {@code *}
전체 허용{@code *}모든 IP 허용 — 운영 사용 금지 권고
+ * + *

{@code #} 로 시작하는 항목은 주석으로 무시한다. + * IPv4-mapped IPv6({@code ::ffff:172.30.1.5}) 와 IPv6 loopback 표기({@code 0:0:0:0:0:0:0:1}) + * 는 비교 전 정규화하므로 듀얼스택 환경에서도 IPv4 규칙이 그대로 적용된다.

+ * + *

CIDR·와일드카드 항목의 형식이 잘못되면(잘못된 옥텟, 범위 밖 prefix 등) 매칭 실패로 처리한다 — + * 허용 목록이므로 해석 불가 항목을 통과시키지 않는다. 정확 일치 항목은 IPv6 등 임의 표기를 허용해야 하므로 + * 유효성 검증 없이 문자열을 그대로 비교한다(형식이 깨진 항목은 실제 소켓 IP 와 일치할 수 없어 무효 규칙이 된다).

+ */ +public final class IpAddressMatcher { + + /** 모든 IP 를 허용하는 와일드카드 항목 */ + public static final String MATCH_ALL = "*"; + + private static final String LIST_DELIMITERS = "[,;\\r\\n]"; + private static final String IPV6_LOOPBACK_LONG = "0:0:0:0:0:0:0:1"; + private static final String IPV4_MAPPED_PREFIX = "::ffff:"; + + private IpAddressMatcher() { + } + + /** + * 허용 목록에 {@code remoteIp} 가 포함되는지 검사한다. + * + * @param allowList 콤마/세미콜론/줄바꿈으로 구분된 허용 IP 목록 (null·공백이면 false) + * @param remoteIp 검사 대상 IP (null·공백이면 false) + * @return 하나라도 매칭되면 true + */ + public static boolean matches(String allowList, String remoteIp) { + if (allowList == null || allowList.trim().isEmpty() + || remoteIp == null || remoteIp.trim().isEmpty()) { + return false; + } + + String remote = canonicalize(remoteIp.trim()); + for (String token : allowList.split(LIST_DELIMITERS)) { + String pattern = token.trim(); + if (pattern.isEmpty() || pattern.startsWith("#")) { + continue; + } + if (matchesOne(pattern, remote)) { + return true; + } + } + return false; + } + + private static boolean matchesOne(String pattern, String remote) { + if (MATCH_ALL.equals(pattern)) { + return true; + } + if (pattern.indexOf('/') >= 0) { + return matchesCidr(pattern, remote); + } + if (pattern.indexOf('*') >= 0) { + return matchesWildcard(pattern, remote); + } + return canonicalize(pattern).equals(remote); + } + + /** IPv4 CIDR({@code 172.30.1.0/24}) 매칭. IPv6 CIDR 은 지원하지 않는다. */ + private static boolean matchesCidr(String pattern, String remote) { + int slash = pattern.indexOf('/'); + String base = pattern.substring(0, slash).trim(); + String prefixText = pattern.substring(slash + 1).trim(); + + long baseAddr = toIpv4Long(base); + long remoteAddr = toIpv4Long(remote); + if (baseAddr < 0 || remoteAddr < 0) { + return false; + } + + int prefix; + try { + prefix = Integer.parseInt(prefixText); + } catch (NumberFormatException e) { + return false; + } + if (prefix < 0 || prefix > 32) { + return false; + } + + long mask = (prefix == 0) ? 0L : ((0xFFFFFFFFL << (32 - prefix)) & 0xFFFFFFFFL); + return (baseAddr & mask) == (remoteAddr & mask); + } + + /** IPv4 옥텟 와일드카드({@code 172.30.*.*}) 매칭. 옥텟 내 부분 와일드카드는 지원하지 않는다. */ + private static boolean matchesWildcard(String pattern, String remote) { + String[] patternParts = pattern.split("\\.", -1); + String[] remoteParts = remote.split("\\.", -1); + if (patternParts.length != 4 || remoteParts.length != 4) { + return false; + } + for (int i = 0; i < 4; i++) { + int remoteOctet = parseOctet(remoteParts[i]); + if (remoteOctet < 0) { + return false; + } + String p = patternParts[i].trim(); + if (MATCH_ALL.equals(p)) { + continue; + } + int patternOctet = parseOctet(p); + if (patternOctet < 0 || patternOctet != remoteOctet) { + return false; + } + } + return true; + } + + /** + * 비교 전 표기 정규화 — IPv6 loopback 축약, IPv4-mapped IPv6 의 IPv4 부분 추출. + */ + public static String canonicalize(String ip) { + if (ip == null) { + return null; + } + String value = ip.trim(); + if (IPV6_LOOPBACK_LONG.equals(value)) { + return "::1"; + } + if (value.length() > IPV4_MAPPED_PREFIX.length() + && value.regionMatches(true, 0, IPV4_MAPPED_PREFIX, 0, IPV4_MAPPED_PREFIX.length())) { + String candidate = value.substring(IPV4_MAPPED_PREFIX.length()); + if (toIpv4Long(candidate) >= 0) { + return candidate; + } + } + return value; + } + + /** @return IPv4 주소의 32bit 값, 형식 오류면 -1 */ + private static long toIpv4Long(String ip) { + String[] parts = ip.split("\\.", -1); + if (parts.length != 4) { + return -1L; + } + long value = 0L; + for (String part : parts) { + int octet = parseOctet(part); + if (octet < 0) { + return -1L; + } + value = (value << 8) | octet; + } + return value; + } + + /** @return 0~255 옥텟 값, 형식 오류면 -1 */ + private static int parseOctet(String text) { + String value = text.trim(); + if (value.isEmpty() || value.length() > 3) { + return -1; + } + int result = 0; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c < '0' || c > '9') { + return -1; + } + result = result * 10 + (c - '0'); + } + return (result > 255) ? -1 : result; + } +} diff --git a/src/test/java/com/eactive/apim/portal/common/util/IpAddressMatcherTest.java b/src/test/java/com/eactive/apim/portal/common/util/IpAddressMatcherTest.java new file mode 100644 index 0000000..f6b759a --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/common/util/IpAddressMatcherTest.java @@ -0,0 +1,107 @@ +package com.eactive.apim.portal.common.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class IpAddressMatcherTest { + + @Test + @DisplayName("정확 일치 - IPv4/IPv6") + void exactMatch() { + assertTrue(IpAddressMatcher.matches("127.0.0.1,::1", "127.0.0.1")); + assertTrue(IpAddressMatcher.matches("127.0.0.1,::1", "::1")); + assertTrue(IpAddressMatcher.matches("127.0.0.1,::1", "0:0:0:0:0:0:0:1")); + assertFalse(IpAddressMatcher.matches("127.0.0.1", "127.0.0.2")); + } + + @Test + @DisplayName("정확 일치 항목은 IP 유효성 검증 없이 문자열 비교 (IPv6 임의 표기 허용)") + void exactMatchIsLiteral() { + assertTrue(IpAddressMatcher.matches("fe80::abcd", "fe80::abcd")); + // 형식이 깨진 항목은 실제 소켓 IP 와 같아질 수 없어 사실상 무효 규칙이 된다 + assertFalse(IpAddressMatcher.matches("172.30.1.256", "172.30.1.5")); + } + + @Test + @DisplayName("IPv4 CIDR - 경계 포함/제외") + void cidrMatch() { + assertTrue(IpAddressMatcher.matches("172.30.1.0/24", "172.30.1.0")); + assertTrue(IpAddressMatcher.matches("172.30.1.0/24", "172.30.1.255")); + assertFalse(IpAddressMatcher.matches("172.30.1.0/24", "172.30.2.1")); + + assertTrue(IpAddressMatcher.matches("172.30.0.0/16", "172.30.99.5")); + assertFalse(IpAddressMatcher.matches("172.30.0.0/16", "172.31.0.1")); + + // /32 는 단일 호스트, /0 은 전체 + assertTrue(IpAddressMatcher.matches("172.30.1.5/32", "172.30.1.5")); + assertFalse(IpAddressMatcher.matches("172.30.1.5/32", "172.30.1.6")); + assertTrue(IpAddressMatcher.matches("0.0.0.0/0", "8.8.8.8")); + } + + @Test + @DisplayName("IPv4 와일드카드 - 옥텟 단위만, 접두 오인 매칭 없음") + void wildcardMatch() { + assertTrue(IpAddressMatcher.matches("172.30.*.*", "172.30.1.5")); + assertTrue(IpAddressMatcher.matches("172.30.1.*", "172.30.1.200")); + assertFalse(IpAddressMatcher.matches("172.30.1.*", "172.30.2.1")); + + // 레거시 정규식 구현이 오인 매칭하던 케이스 (192.168.1.* 가 192.168.199.5 를 허용) + assertFalse(IpAddressMatcher.matches("192.168.1.*", "192.168.199.5")); + // 점(.) 이 any-char 로 해석되던 케이스 + assertFalse(IpAddressMatcher.matches("192.168.1.*", "192x168y1z5")); + } + + @Test + @DisplayName("전체 허용 와일드카드") + void matchAll() { + assertTrue(IpAddressMatcher.matches("*", "10.1.2.3")); + assertTrue(IpAddressMatcher.matches("*", "::1")); + } + + @Test + @DisplayName("구분자 - 콤마/세미콜론/줄바꿈, 주석·공백 무시") + void delimitersAndComments() { + String list = "127.0.0.1;\n # 관리자 서버\n172.30.1.0/24 ,\n\n::1"; + assertTrue(IpAddressMatcher.matches(list, "172.30.1.9")); + assertTrue(IpAddressMatcher.matches(list, "127.0.0.1")); + assertFalse(IpAddressMatcher.matches(list, "10.0.0.1")); + // 주석 항목 자체는 규칙으로 쓰이지 않는다 + assertFalse(IpAddressMatcher.matches("# 172.30.1.0/24", "172.30.1.9")); + } + + @Test + @DisplayName("IPv4-mapped IPv6 는 IPv4 규칙으로 매칭") + void ipv4MappedIpv6() { + assertTrue(IpAddressMatcher.matches("172.30.1.0/24", "::ffff:172.30.1.5")); + assertTrue(IpAddressMatcher.matches("127.0.0.1", "::FFFF:127.0.0.1")); + assertEquals("172.30.1.5", IpAddressMatcher.canonicalize("::ffff:172.30.1.5")); + assertEquals("::1", IpAddressMatcher.canonicalize("0:0:0:0:0:0:0:1")); + } + + @Test + @DisplayName("잘못된 형식은 허용하지 않는다") + void invalidPatternsDenied() { + assertFalse(IpAddressMatcher.matches("172.30.1.0/33", "172.30.1.5")); + assertFalse(IpAddressMatcher.matches("172.30.1.0/-1", "172.30.1.5")); + assertFalse(IpAddressMatcher.matches("172.30.1.0/abc", "172.30.1.5")); + assertFalse(IpAddressMatcher.matches("172.30.1.0/24", "not-an-ip")); + assertFalse(IpAddressMatcher.matches("172.30.1.*", "172.30.1.256")); + // 범위 밖 옥텟이라도 CIDR 규칙에는 매칭되지 않는다 + assertFalse(IpAddressMatcher.matches("172.30.1.0/24", "172.30.1.256")); + // IPv6 CIDR 미지원 — 조용히 통과시키지 않는다 + assertFalse(IpAddressMatcher.matches("fe80::/10", "fe80::1")); + } + + @Test + @DisplayName("null·공백 입력은 거부") + void nullAndBlank() { + assertFalse(IpAddressMatcher.matches(null, "127.0.0.1")); + assertFalse(IpAddressMatcher.matches(" ", "127.0.0.1")); + assertFalse(IpAddressMatcher.matches("127.0.0.1", null)); + assertFalse(IpAddressMatcher.matches("127.0.0.1", " ")); + } +}