IP 매칭 유틸리티 추가 - CIDR/와일드카드 포함(#194).
eapim-portal CI (from elink-portal-common) / build (push) Has been cancelled

포탈 캐시 리로드 다중 서버 지원 - UI 개선/동시 호출 추가.
내부 API 유틸/테스트 및 JSP UI 연계 최적화.
This commit is contained in:
Rinjae
2026-08-06 09:59:23 +09:00
parent 2b1538f2ea
commit 7ca3acd195
2 changed files with 288 additions and 0 deletions
@@ -0,0 +1,181 @@
package com.eactive.apim.portal.common.util;
/**
* 허용 IP 목록(allowlist) 매칭 유틸.
*
* <p>내부 API 가드에서 {@code PTL_PROPERTY} 로 관리하는 허용 IP 문자열을 해석한다.
* 목록은 <b>콤마(,) / 세미콜론(;) / 줄바꿈</b> 으로 구분하며 각 항목은 다음 형식을 지원한다.</p>
*
* <table border="1">
* <caption>지원 형식</caption>
* <tr><th>형식</th><th>예</th><th>설명</th></tr>
* <tr><td>정확 일치</td><td>{@code 127.0.0.1}, {@code ::1}</td><td>문자열 완전 일치(IPv6 포함)</td></tr>
* <tr><td>IPv4 CIDR</td><td>{@code 172.30.1.0/24}</td><td>prefix 0~32</td></tr>
* <tr><td>IPv4 와일드카드</td><td>{@code 172.30.*.*}</td><td>옥텟 단위 {@code *}</td></tr>
* <tr><td>전체 허용</td><td>{@code *}</td><td>모든 IP 허용 — 운영 사용 금지 권고</td></tr>
* </table>
*
* <p>{@code #} 로 시작하는 항목은 주석으로 무시한다.
* IPv4-mapped IPv6({@code ::ffff:172.30.1.5}) 와 IPv6 loopback 표기({@code 0:0:0:0:0:0:0:1})
* 는 비교 전 정규화하므로 듀얼스택 환경에서도 IPv4 규칙이 그대로 적용된다.</p>
*
* <p>CIDR·와일드카드 항목의 형식이 잘못되면(잘못된 옥텟, 범위 밖 prefix 등) <b>매칭 실패로 처리</b>한다 —
* 허용 목록이므로 해석 불가 항목을 통과시키지 않는다. 정확 일치 항목은 IPv6 등 임의 표기를 허용해야 하므로
* 유효성 검증 없이 문자열을 그대로 비교한다(형식이 깨진 항목은 실제 소켓 IP 와 일치할 수 없어 무효 규칙이 된다).</p>
*/
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;
}
}
@@ -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", " "));
}
}