diff --git a/.env.example b/.env.example index 207f926..b86fa13 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,9 @@ # cp .env.example .env 후 값을 바꿔서 쓰세요. (.env는 git에 올리지 않습니다) JWT_SECRET=change-me-to-a-long-random-secret-key-at-least-32-bytes + +# rush-gateway: actuator 관리용 엔드포인트(health 제외)에 접근할 수 있는 IP 대역(CIDR, 콤마 구분) +GATEWAY_ADMIN_ALLOWED_IPS=127.0.0.1/32,::1/128 +# rush-gateway: 앞단 LB/프록시 대역(CIDR). 이 대역에서 온 요청만 X-Forwarded-For를 믿습니다. +TRUSTED_PROXIES= +# rush-gateway: CORS를 허용할 Origin(콤마 구분) +CORS_ALLOWED_ORIGINS= diff --git a/docker-compose.yml b/docker-compose.yml index 0a7a41f..b2ac830 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,9 @@ services: SP_URL: http://rush-sp:8086 LIVE_URL: ws://rush-live:8087 CHAT_URL: ws://rush-chat:8088 + GATEWAY_ADMIN_ALLOWED_IPS: ${GATEWAY_ADMIN_ALLOWED_IPS:-127.0.0.1/32,::1/128} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} ports: ["8080:8080"] # 외부에 열리는 유일한 포트 depends_on: rush-config: { condition: service_healthy } diff --git a/rush-config/src/main/resources/config/rush-gateway.yml b/rush-config/src/main/resources/config/rush-gateway.yml index f26da52..9888f15 100644 --- a/rush-config/src/main/resources/config/rush-gateway.yml +++ b/rush-config/src/main/resources/config/rush-gateway.yml @@ -1,3 +1,18 @@ # rush-gateway 전용 설정 (라우팅은 rush-gateway/application.yml 참고) server: port: 8080 + +gateway: + # actuator 관리용 엔드포인트(health 제외)에 접근할 수 있는 IP 대역(CIDR, 콤마 구분) + admin-allowed-ips: ${GATEWAY_ADMIN_ALLOWED_IPS:127.0.0.1/32,::1/128} + # 앞단 LB/프록시 대역. 이 대역에서 온 요청만 X-Forwarded-For를 믿습니다. + trusted-proxies: ${TRUSTED_PROXIES:} + # CORS를 허용할 Origin(콤마 구분). 비어 있으면 CORS를 허용하지 않습니다. + cors-allowed-origins: ${CORS_ALLOWED_ORIGINS:} + +# 공통 application.yml의 exposure를 덮어씁니다. health 외에는 허용 IP에서만 열립니다. +management: + endpoints: + web: + exposure: + include: health, info, gateway, circuitbreakers diff --git a/rush-gateway/README.md b/rush-gateway/README.md index 917c6cb..77a6aa4 100644 --- a/rush-gateway/README.md +++ b/rush-gateway/README.md @@ -23,6 +23,19 @@ WebSocket을 제외한 라우트는 서킷브레이커를 거칩니다. 서비 모든 요청에 `X-Request-Id`를 새로 붙여 서비스와 응답에 넘기고, 메서드·경로·상태·소요 시간·사용자 ID를 한 줄로 남깁니다. +## 접근 제어 + +- JWT 검증은 `JwtAuthenticationFilter`가 라우트마다 합니다. Spring Security는 경로 접근 규칙, CORS, 보안 응답 헤더만 맡습니다. +- actuator는 `/actuator/health`만 공개합니다. `gateway`(라우트 조회만), `circuitbreakers`, `info`는 `GATEWAY_ADMIN_ALLOWED_IPS`에 속한 IP에서만 열리고, 그 외에는 `403`을 돌려줍니다. +- 클라이언트 IP는 연결 IP로 판별합니다. 연결 IP가 `TRUSTED_PROXIES`에 속할 때만 `X-Forwarded-For`를 읽으므로, 헤더를 위조해도 IP 제한을 우회할 수 없습니다. +- docker compose로 띄우면 호스트에서 보낸 요청은 도커 브리지 IP로 들어옵니다. 호스트에서 관리용 엔드포인트를 보려면 `GATEWAY_ADMIN_ALLOWED_IPS`에 그 대역을 추가하세요. + +| 환경변수 | 기본값 | 설명 | +|---|---|---| +| `GATEWAY_ADMIN_ALLOWED_IPS` | `127.0.0.1/32,::1/128` | 관리용 actuator 허용 대역 | +| `TRUSTED_PROXIES` | 없음 | 앞단 LB/프록시 대역 | +| `CORS_ALLOWED_ORIGINS` | 없음 | CORS 허용 Origin | + ## 실행 ```bash diff --git a/rush-gateway/build.gradle.kts b/rush-gateway/build.gradle.kts index 2b14330..b4eaf93 100644 --- a/rush-gateway/build.gradle.kts +++ b/rush-gateway/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { implementation(libs.spring.cloud.starter.config) implementation(libs.spring.cloud.starter.netflix.eureka.client) implementation(libs.spring.boot.starter.actuator) + implementation(libs.spring.boot.starter.security) implementation(libs.jjwt.api) runtimeOnly(libs.jjwt.impl) runtimeOnly(libs.jjwt.jackson) diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/GatewayApplication.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/GatewayApplication.kt index e5d7ddd..08eaa34 100644 --- a/rush-gateway/src/main/kotlin/com/rush/gateway/GatewayApplication.kt +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/GatewayApplication.kt @@ -1,9 +1,11 @@ package com.rush.gateway import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication @SpringBootApplication +@ConfigurationPropertiesScan class GatewayApplication fun main(args: Array) { diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/config/GatewayProperties.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/config/GatewayProperties.kt new file mode 100644 index 0000000..29e4495 --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/config/GatewayProperties.kt @@ -0,0 +1,17 @@ +package com.rush.gateway.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * 게이트웨이 전용 설정입니다. 값은 rush-config의 rush-gateway.yml에서 오고, + * config 서버 없이 뜰 때를 위해 기본값을 둡니다. + */ +@ConfigurationProperties("gateway") +data class GatewayProperties( + // actuator 관리용 엔드포인트(health 제외)에 접근할 수 있는 IP 대역(CIDR) + val adminAllowedIps: List = listOf("127.0.0.1/32", "::1/128"), + // 이 대역에서 온 요청만 X-Forwarded-For를 믿습니다. 비어 있으면 연결 IP만 씁니다. + val trustedProxies: List = emptyList(), + // CORS를 허용할 Origin. 비어 있으면 CORS 헤더를 붙이지 않습니다. + val corsAllowedOrigins: List = emptyList(), +) diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponse.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponse.kt index fbafc7e..d2338f4 100644 --- a/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponse.kt +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponse.kt @@ -12,6 +12,7 @@ data class ErrorResponse( private fun messageOf(status: HttpStatus) = when (status) { HttpStatus.UNAUTHORIZED -> "인증이 필요합니다." + HttpStatus.FORBIDDEN -> "접근 권한이 없습니다." HttpStatus.NOT_FOUND -> "요청한 경로를 찾을 수 없습니다." HttpStatus.SERVICE_UNAVAILABLE -> "일시적으로 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요." HttpStatus.GATEWAY_TIMEOUT -> "서비스 응답이 지연되고 있습니다. 잠시 후 다시 시도해 주세요." diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/security/ClientIpResolver.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/security/ClientIpResolver.kt new file mode 100644 index 0000000..f56d637 --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/security/ClientIpResolver.kt @@ -0,0 +1,51 @@ +package com.rush.gateway.security + +import com.rush.gateway.config.GatewayProperties +import org.springframework.http.server.reactive.ServerHttpRequest +import org.springframework.stereotype.Component + +/** + * 클라이언트 IP를 판별합니다. IP 제한, 접근 로그, 헤더 정화가 모두 이 결과를 씁니다. + * + * 연결 IP가 신뢰 프록시일 때만 X-Forwarded-For를 오른쪽부터 읽어 + * 신뢰 프록시가 아닌 첫 IP를 클라이언트로 봅니다. 그 외에는 연결 IP를 그대로 씁니다. + */ +@Component +class ClientIpResolver( + properties: GatewayProperties, +) { + private val trustedProxies = IpRangeMatcher(properties.trustedProxies) + + fun resolve(request: ServerHttpRequest): String { + val remote = remoteIp(request) ?: UNKNOWN + if (!trustedProxies.matches(remote)) return remote + + var client = remote + val hops = + request.headers[X_FORWARDED_FOR] + .orEmpty() + .flatMap { it.split(',') } + .map { it.trim() } + for (hop in hops.asReversed()) { + if (!IpRangeMatcher.isIpLiteral(hop)) break + client = hop + if (!trustedProxies.matches(hop)) break + } + return client + } + + /** 요청이 신뢰 프록시를 거쳐 왔는지 여부입니다. */ + fun isFromTrustedProxy(request: ServerHttpRequest): Boolean = trustedProxies.matches(remoteIp(request)) + + // IPv6 연결은 "0:0:0:0:0:0:0:1%0"처럼 scope id가 붙으므로 떼어 냅니다. + private fun remoteIp(request: ServerHttpRequest): String? = + request.remoteAddress + ?.address + ?.hostAddress + ?.substringBefore('%') + + companion object { + const val X_FORWARDED_FOR = "X-Forwarded-For" + private const val UNKNOWN = "unknown" + } +} diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/security/IpRangeMatcher.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/security/IpRangeMatcher.kt new file mode 100644 index 0000000..a1f753f --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/security/IpRangeMatcher.kt @@ -0,0 +1,32 @@ +package com.rush.gateway.security + +import org.springframework.security.web.util.matcher.IpAddressMatcher +import java.net.InetAddress + +/** + * CIDR 목록 중 하나라도 IP에 맞는지 봅니다. + * IP 리터럴이 아닌 값은 DNS 조회를 하지 않도록 매칭 전에 걸러 내고, 잘못된 IP는 맞지 않는 것으로 봅니다. + */ +class IpRangeMatcher( + ranges: List, +) { + private val matchers = ranges.map { it.trim() }.filter { it.isNotEmpty() }.map(::IpAddressMatcher) + + fun matches(ip: String?): Boolean = + ip != null && + isIpLiteral(ip) && + runCatching { matchers.any { it.matches(ip) } }.getOrDefault(false) + + companion object { + private val IPV4 = Regex("""^\d{1,3}(\.\d{1,3}){3}$""") + private val IPV6 = Regex("""^[0-9a-fA-F:.]+$""") + + fun isIpLiteral(value: String): Boolean = isIpv4(value) || isIpv6(value) + + private fun isIpv4(value: String): Boolean = IPV4.matches(value) && value.split('.').all { it.toInt() <= 255 } + + // ':'가 들어간 값은 InetAddress가 IPv6 리터럴로만 해석하므로 DNS 조회가 일어나지 않습니다. + private fun isIpv6(value: String): Boolean = + value.contains(':') && IPV6.matches(value) && runCatching { InetAddress.getByName(value) }.isSuccess + } +} diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/security/SecurityConfig.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/security/SecurityConfig.kt new file mode 100644 index 0000000..d0fb053 --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/security/SecurityConfig.kt @@ -0,0 +1,87 @@ +package com.rush.gateway.security + +import com.rush.gateway.config.GatewayProperties +import com.rush.gateway.error.ErrorResponseWriter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.security.authorization.AuthorizationDecision +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.security.web.server.ServerAuthenticationEntryPoint +import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.reactive.CorsConfigurationSource +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource +import reactor.core.publisher.Mono + +/** + * Spring Security는 경로 접근 규칙, CORS, 보안 응답 헤더만 맡습니다. + * JWT 검증은 라우트에 걸리는 JwtAuthenticationFilter가 그대로 합니다. + */ +@Configuration +@EnableWebFluxSecurity +class SecurityConfig( + private val properties: GatewayProperties, + private val clientIpResolver: ClientIpResolver, + private val errorResponseWriter: ErrorResponseWriter, +) { + private val adminAllowedIps = IpRangeMatcher(properties.adminAllowedIps) + + @Bean + fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain = + http + .csrf { it.disable() } + .httpBasic { it.disable() } + .formLogin { it.disable() } + .logout { it.disable() } + .cors { it.configurationSource(corsConfigurationSource()) } + .authorizeExchange { + it + .pathMatchers("/actuator/health", "/actuator/health/**") + .permitAll() + .pathMatchers("/actuator/**") + .access { _, context -> + val ip = clientIpResolver.resolve(context.exchange.request) + Mono.just(AuthorizationDecision(adminAllowedIps.matches(ip))) + }.anyExchange() + .permitAll() + }.exceptionHandling { + it + .authenticationEntryPoint(forbiddenEntryPoint()) + .accessDeniedHandler(forbiddenHandler()) + }.build() + + // 로그인 개념이 없으므로 인증 실패도 접근 거부(403)로 응답합니다. + private fun forbiddenEntryPoint() = + ServerAuthenticationEntryPoint { exchange, _ -> + errorResponseWriter.write(exchange, HttpStatus.FORBIDDEN) + } + + private fun forbiddenHandler() = + ServerAccessDeniedHandler { exchange, _ -> + errorResponseWriter.write(exchange, HttpStatus.FORBIDDEN) + } + + private fun corsConfigurationSource(): CorsConfigurationSource { + val source = UrlBasedCorsConfigurationSource() + if (properties.corsAllowedOrigins.isEmpty()) return source + + val config = + CorsConfiguration().apply { + allowedOrigins = properties.corsAllowedOrigins + allowedMethods = + listOf(HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS) + .map { it.name() } + allowedHeaders = listOf("*") + exposedHeaders = listOf(HttpHeaders.LOCATION, "X-Request-Id") + allowCredentials = true + maxAge = 3600 + } + source.registerCorsConfiguration("/**", config) + return source + } +} diff --git a/rush-gateway/src/main/resources/application.yml b/rush-gateway/src/main/resources/application.yml index 66951c8..e8b5137 100644 --- a/rush-gateway/src/main/resources/application.yml +++ b/rush-gateway/src/main/resources/application.yml @@ -6,6 +6,9 @@ spring: name: rush-gateway config: import: optional:configserver:${CONFIG_URL:http://localhost:8888} + autoconfigure: + # 로그인 사용자가 없으므로 기본 사용자와 임시 비밀번호를 만들지 않습니다. + exclude: org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration cloud: loadbalancer: cache: @@ -90,8 +93,13 @@ resilience4j: default: timeout-duration: 5s +# health만 공개되고, 나머지는 SecurityConfig가 gateway.admin-allowed-ips에서만 허용합니다. management: endpoints: web: exposure: - include: health, info, gateway + include: health, info, gateway, circuitbreakers + endpoint: + gateway: + # SCG 기본값은 none(꺼짐)입니다. 라우트 조회만 열고 생성·삭제·refresh는 막습니다. + access: read-only diff --git a/rush-gateway/src/test/kotlin/com/rush/gateway/security/ActuatorAccessTest.kt b/rush-gateway/src/test/kotlin/com/rush/gateway/security/ActuatorAccessTest.kt new file mode 100644 index 0000000..e4809fe --- /dev/null +++ b/rush-gateway/src/test/kotlin/com/rush/gateway/security/ActuatorAccessTest.kt @@ -0,0 +1,82 @@ +package com.rush.gateway.security + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.web.reactive.server.WebTestClient + +// @AutoConfigureWebTestClient는 mock 바인딩이라 remoteAddress가 없으므로, 실제 서버(RANDOM_PORT)로 요청합니다. +private const val SECRET = "test-secret-key-for-gateway-actuator-test-0123456789" + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.cloud.config.enabled=false", + "eureka.client.enabled=false", + "jwt.secret=$SECRET", + "gateway.admin-allowed-ips=10.0.0.0/8", + ], +) +class ActuatorDeniedIpTest { + @Autowired + lateinit var client: WebTestClient + + @Test + fun `health는 누구나 볼 수 있다`() { + client + .get() + .uri("/actuator/health") + .exchange() + .expectStatus() + .isOk + } + + @Test + fun `gateway 엔드포인트는 403 공통 오류 응답을 준다`() { + client + .get() + .uri("/actuator/gateway/routes") + .exchange() + .expectStatus() + .isForbidden + .expectBody() + .jsonPath("$.code") + .isEqualTo("FORBIDDEN") + .jsonPath("$.message") + .isEqualTo("접근 권한이 없습니다.") + } + + @Test + fun `X-Forwarded-For를 위조해도 허용되지 않는다`() { + client + .get() + .uri("/actuator/circuitbreakers") + .header("X-Forwarded-For", "10.0.0.1") + .exchange() + .expectStatus() + .isForbidden + } +} + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.cloud.config.enabled=false", + "eureka.client.enabled=false", + "jwt.secret=$SECRET", + ], +) +class ActuatorAllowedIpTest { + @Autowired + lateinit var client: WebTestClient + + @Test + fun `기본 허용 대역인 localhost는 gateway 엔드포인트를 볼 수 있다`() { + client + .get() + .uri("/actuator/gateway/routes") + .exchange() + .expectStatus() + .isOk + } +} diff --git a/rush-gateway/src/test/kotlin/com/rush/gateway/security/ClientIpResolverTest.kt b/rush-gateway/src/test/kotlin/com/rush/gateway/security/ClientIpResolverTest.kt new file mode 100644 index 0000000..97fd630 --- /dev/null +++ b/rush-gateway/src/test/kotlin/com/rush/gateway/security/ClientIpResolverTest.kt @@ -0,0 +1,69 @@ +package com.rush.gateway.security + +import com.rush.gateway.config.GatewayProperties +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.mock.http.server.reactive.MockServerHttpRequest +import java.net.Inet6Address +import java.net.InetAddress +import java.net.InetSocketAddress + +class ClientIpResolverTest { + private fun request( + remote: String, + vararg xff: String, + ) = MockServerHttpRequest + .get("/shop/ping") + .remoteAddress(InetSocketAddress(remote, 12345)) + .apply { if (xff.isNotEmpty()) header("X-Forwarded-For", *xff) } + .build() + + @Test + fun `신뢰 프록시가 없으면 X-Forwarded-For를 무시하고 연결 IP를 쓴다`() { + val resolver = ClientIpResolver(GatewayProperties()) + + assertEquals("203.0.113.7", resolver.resolve(request("203.0.113.7", "127.0.0.1"))) + } + + @Test + fun `신뢰 프록시에서 온 요청은 X-Forwarded-For의 마지막 비신뢰 IP를 쓴다`() { + val resolver = ClientIpResolver(GatewayProperties(trustedProxies = listOf("10.0.0.0/8"))) + + // 클라이언트가 앞에 127.0.0.1을 위조해 넣어도 프록시가 붙인 실제 IP가 선택됩니다. + assertEquals("198.51.100.4", resolver.resolve(request("10.0.0.5", "127.0.0.1, 198.51.100.4, 10.0.0.9"))) + } + + @Test + fun `X-Forwarded-For가 없으면 신뢰 프록시의 연결 IP를 쓴다`() { + val resolver = ClientIpResolver(GatewayProperties(trustedProxies = listOf("10.0.0.0/8"))) + + assertEquals("10.0.0.5", resolver.resolve(request("10.0.0.5"))) + } + + @Test + fun `IP가 아닌 값을 만나면 그 직전 IP에서 멈춘다`() { + val resolver = ClientIpResolver(GatewayProperties(trustedProxies = listOf("10.0.0.0/8"))) + + assertEquals("10.0.0.9", resolver.resolve(request("10.0.0.5", "evil.example.com, 10.0.0.9"))) + } + + @Test + fun `범위를 벗어난 IPv4를 만나면 그 직전 IP에서 멈춘다`() { + val resolver = ClientIpResolver(GatewayProperties(trustedProxies = listOf("10.0.0.0/8"))) + + assertEquals("10.0.0.5", resolver.resolve(request("10.0.0.5", "999.999.999.999"))) + } + + @Test + fun `IPv6 연결 주소의 scope id는 떼어 낸다`() { + val resolver = ClientIpResolver(GatewayProperties()) + val loopback = Inet6Address.getByAddress(null, InetAddress.getByName("::1").address, 1) + val request = + MockServerHttpRequest + .get("/shop/ping") + .remoteAddress(InetSocketAddress(loopback, 12345)) + .build() + + assertEquals("0:0:0:0:0:0:0:1", resolver.resolve(request)) + } +} diff --git a/rush-gateway/src/test/kotlin/com/rush/gateway/security/IpRangeMatcherTest.kt b/rush-gateway/src/test/kotlin/com/rush/gateway/security/IpRangeMatcherTest.kt new file mode 100644 index 0000000..714ddd7 --- /dev/null +++ b/rush-gateway/src/test/kotlin/com/rush/gateway/security/IpRangeMatcherTest.kt @@ -0,0 +1,33 @@ +package com.rush.gateway.security + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class IpRangeMatcherTest { + private val matcher = IpRangeMatcher(listOf("10.0.0.0/8", "::1/128")) + + @Test + fun `허용 범위에 속한 IP는 맞는다`() { + assertTrue(matcher.matches("10.1.2.3")) + assertTrue(matcher.matches("::1")) + } + + @Test + fun `범위를 벗어난 IPv4 옥텟은 예외 없이 맞지 않는 것으로 본다`() { + assertFalse(matcher.matches("999.999.999.999")) + assertFalse(matcher.matches("10.0.0.256")) + } + + @Test + fun `형식이 잘못된 IPv6는 예외 없이 맞지 않는 것으로 본다`() { + assertFalse(matcher.matches(":::::")) + assertFalse(matcher.matches("1:2:3:4:5:6:7:8:9")) + } + + @Test + fun `IP가 아닌 값은 맞지 않는다`() { + assertFalse(matcher.matches("localhost")) + assertFalse(matcher.matches(null)) + } +}