IP 매칭 유틸리티 추가 - CIDR/와일드카드 포함(#194).
eapim-portal CI (from elink-portal-common) / build (push) Has been cancelled
eapim-portal CI (from elink-portal-common) / build (push) Has been cancelled
포탈 캐시 리로드 다중 서버 지원 - UI 개선/동시 호출 추가. 내부 API 유틸/테스트 및 JSP UI 연계 최적화.
This commit is contained in:
@@ -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", " "));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user