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