From de8e9912c8955434d76be1c067ebc4836a789154 Mon Sep 17 00:00:00 2001 From: jyx-07 Date: Tue, 3 Nov 2026 17:51:47 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(gateway):=20=EC=9C=84=EC=A1=B0?= =?UTF-8?q?=EB=90=9C=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=EC=9B=A8=EC=9D=B4=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EA=B5=90=EC=B2=B4=EC=99=80=20=EC=9E=98?= =?UTF-8?q?=EB=AA=BB=EB=90=9C=20=ED=98=95=EC=8B=9D=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rush-gateway/README.md | 9 + .../com/rush/gateway/error/ErrorResponse.kt | 1 + .../gateway/filter/JwtAuthenticationFilter.kt | 26 ++- .../gateway/filter/RequestSanitizeFilter.kt | 119 +++++++++++++ .../rush/gateway/filter/RequestValidator.kt | 25 +++ .../src/main/resources/application.yml | 7 + .../gateway/filter/RequestSanitizeTest.kt | 156 ++++++++++++++++++ .../gateway/filter/RequestValidatorTest.kt | 37 +++++ 8 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 rush-gateway/src/main/kotlin/com/rush/gateway/filter/RequestSanitizeFilter.kt create mode 100644 rush-gateway/src/main/kotlin/com/rush/gateway/filter/RequestValidator.kt create mode 100644 rush-gateway/src/test/kotlin/com/rush/gateway/filter/RequestSanitizeTest.kt create mode 100644 rush-gateway/src/test/kotlin/com/rush/gateway/filter/RequestValidatorTest.kt diff --git a/rush-gateway/README.md b/rush-gateway/README.md index 77a6aa4..b2fcfbd 100644 --- a/rush-gateway/README.md +++ b/rush-gateway/README.md @@ -30,6 +30,15 @@ WebSocket을 제외한 라우트는 서킷브레이커를 거칩니다. 서비 - 클라이언트 IP는 연결 IP로 판별합니다. 연결 IP가 `TRUSTED_PROXIES`에 속할 때만 `X-Forwarded-For`를 읽으므로, 헤더를 위조해도 IP 제한을 우회할 수 없습니다. - docker compose로 띄우면 호스트에서 보낸 요청은 도커 브리지 IP로 들어옵니다. 호스트에서 관리용 엔드포인트를 보려면 `GATEWAY_ADMIN_ALLOWED_IPS`에 그 대역을 추가하세요. +## 요청 정화 + +`RequestSanitizeFilter`가 요청을 서비스로 넘기기 전에 정리합니다. + +- 경로 조작(`..`, `%2e`, `%2f`, `%5c`, `//`, `\`)이 있거나 헤더에 제어문자가 있으면 `400`을 돌려줍니다. 8KB를 넘는 헤더는 `431`입니다. +- 클라이언트가 보낸 `X-User-Id`, `X-Real-IP`, `X-Forwarded-*`, `Forwarded`, `X-Internal-*`는 지웁니다. 그 뒤 `X-Real-IP`, `X-Forwarded-For`, `-Proto`, `-Host`, `-Port`를 게이트웨이가 판별한 값으로 다시 씁니다. +- WebSocket용 `?token=`은 검증이 끝나면 지우고 서비스로 넘깁니다. +- 각 서비스의 `/{prefix}/v3/api-docs`는 토큰 없이 접근할 수 있습니다. + | 환경변수 | 기본값 | 설명 | |---|---|---| | `GATEWAY_ADMIN_ALLOWED_IPS` | `127.0.0.1/32,::1/128` | 관리용 actuator 허용 대역 | 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 d2338f4..64cc2fb 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 @@ -11,6 +11,7 @@ data class ErrorResponse( private fun messageOf(status: HttpStatus) = when (status) { + HttpStatus.BAD_REQUEST -> "잘못된 요청입니다." HttpStatus.UNAUTHORIZED -> "인증이 필요합니다." HttpStatus.FORBIDDEN -> "접근 권한이 없습니다." HttpStatus.NOT_FOUND -> "요청한 경로를 찾을 수 없습니다." diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/filter/JwtAuthenticationFilter.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/filter/JwtAuthenticationFilter.kt index 12d8aa8..723d525 100644 --- a/rush-gateway/src/main/kotlin/com/rush/gateway/filter/JwtAuthenticationFilter.kt +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/filter/JwtAuthenticationFilter.kt @@ -10,7 +10,9 @@ import org.springframework.core.Ordered import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import org.springframework.web.server.ServerWebExchange +import org.springframework.web.util.UriComponentsBuilder import reactor.core.publisher.Mono +import java.net.URI /** * 게이트웨이에서 JWT를 한 번만 검증하고, 검증된 사용자 ID를 X-User-Id 헤더로 뒤쪽 서비스에 넘깁니다. @@ -27,6 +29,9 @@ class JwtAuthenticationFilter( // 토큰 없이 접근 가능한 경로 private val publicPaths = listOf("/auth/", "/actuator/") + // 각 서비스의 OpenAPI 문서(/{prefix}/v3/api-docs)는 게이트웨이 Swagger UI가 토큰 없이 가져옵니다. + private val apiDocsPath = Regex("^/[a-z-]+/v3/api-docs(/.*)?$") + override fun filter( exchange: ServerWebExchange, chain: GatewayFilterChain, @@ -35,13 +40,14 @@ class JwtAuthenticationFilter( // 클라이언트가 X-User-Id를 직접 넣어 위조하지 못하게 항상 지웁니다. val sanitized = request.mutate().headers { it.remove(USER_ID_HEADER) }.build() - if (publicPaths.any { request.path.value().startsWith(it) }) { + val path = request.path.value() + if (publicPaths.any { path.startsWith(it) } || apiDocsPath.matches(path)) { return chain.filter(exchange.mutate().request(sanitized).build()) } val token = request.headers.getFirst("Authorization")?.removePrefix("Bearer ") - ?: request.queryParams.getFirst("token") // WebSocket은 헤더를 못 넣는 경우가 많아서 쿼리로도 받습니다. + ?: request.queryParams.getFirst(TOKEN_QUERY) // WebSocket은 헤더를 못 넣는 경우가 많아서 쿼리로도 받습니다. ?: return unauthorized(exchange) val userId = @@ -55,15 +61,29 @@ class JwtAuthenticationFilter( }.getOrNull() ?: return unauthorized(exchange) exchange.attributes[USER_ID_HEADER] = userId - val authed = sanitized.mutate().header(USER_ID_HEADER, userId).build() + // 쿼리로 받은 토큰은 검증이 끝났으므로 서비스 쪽 로그에 남지 않게 지웁니다. + val authed = + sanitized + .mutate() + .uri(withoutTokenQuery(request.uri)) + .header(USER_ID_HEADER, userId) + .build() return chain.filter(exchange.mutate().request(authed).build()) } + private fun withoutTokenQuery(uri: URI): URI = + UriComponentsBuilder + .fromUri(uri) + .replaceQueryParam(TOKEN_QUERY) + .build(true) + .toUri() + private fun unauthorized(exchange: ServerWebExchange): Mono = errorResponseWriter.write(exchange, HttpStatus.UNAUTHORIZED) override fun getOrder() = -1 companion object { const val USER_ID_HEADER = "X-User-Id" + private const val TOKEN_QUERY = "token" } } diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/filter/RequestSanitizeFilter.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/filter/RequestSanitizeFilter.kt new file mode 100644 index 0000000..f9fc085 --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/filter/RequestSanitizeFilter.kt @@ -0,0 +1,119 @@ +package com.rush.gateway.filter + +import com.rush.gateway.error.ErrorResponseWriter +import com.rush.gateway.security.ClientIpResolver +import org.springframework.core.Ordered +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono + +/** + * 요청을 서비스로 넘기기 전에 정화합니다. + * + * 1. 형식이 잘못된 요청(경로 조작, 제어문자 헤더)은 400으로 막습니다. + * 2. 게이트웨이가 값을 책임지는 헤더는 클라이언트가 보낸 값을 지우고 올바른 값으로 다시 씁니다. + * X-User-Id는 JwtAuthenticationFilter가, X-Request-Id는 AccessLogFilter가 다시 씁니다. + * + * AccessLogFilter 바로 뒤, Spring Security(-100) 앞에서 실행되어 400 응답도 접근 로그에 남습니다. + */ +@Component +class RequestSanitizeFilter( + private val clientIpResolver: ClientIpResolver, + private val errorResponseWriter: ErrorResponseWriter, +) : WebFilter, + Ordered { + override fun filter( + exchange: ServerWebExchange, + chain: WebFilterChain, + ): Mono { + val request = exchange.request + if (!RequestValidator.isValidPath(request.uri.rawPath) || !RequestValidator.isValidHeaders(request.headers)) { + return errorResponseWriter.write(exchange, HttpStatus.BAD_REQUEST) + } + + val clientIp = clientIpResolver.resolve(request) + val fromTrustedProxy = clientIpResolver.isFromTrustedProxy(request) + val forwarded = forwardedValues(exchange, fromTrustedProxy) + + val sanitized = + request + .mutate() + .headers { headers -> + headers.keys + .filter { name -> OWNED_HEADERS.any { it.equals(name, true) } || name.startsWith(INTERNAL_PREFIX, true) } + .forEach(headers::remove) + headers.set(X_REAL_IP, clientIp) + headers.set(ClientIpResolver.X_FORWARDED_FOR, clientIp) + headers.set(X_FORWARDED_PROTO, forwarded.proto) + headers.set(X_FORWARDED_HOST, forwarded.host) + headers.set(X_FORWARDED_PORT, forwarded.port) + }.build() + return chain.filter(exchange.mutate().request(sanitized).build()) + } + + // 신뢰 프록시를 거친 요청은 프록시가 보낸 값을, 그 외에는 게이트웨이가 받은 요청 값을 씁니다. + private fun forwardedValues( + exchange: ServerWebExchange, + fromTrustedProxy: Boolean, + ): Forwarded { + val uri = exchange.request.uri + val headers = exchange.request.headers + val proto = uri.scheme ?: "http" + val own = + Forwarded( + proto = proto, + host = headers.getFirst(HttpHeaders.HOST)?.substringBefore(':') ?: uri.host.orEmpty(), + port = + ( + if (uri.port != -1) { + uri.port + } else if (proto == "https") { + 443 + } else { + 80 + } + ).toString(), + ) + if (!fromTrustedProxy) return own + + return Forwarded( + proto = headers.getFirst(X_FORWARDED_PROTO) ?: own.proto, + host = headers.getFirst(X_FORWARDED_HOST) ?: own.host, + port = headers.getFirst(X_FORWARDED_PORT) ?: own.port, + ) + } + + private data class Forwarded( + val proto: String, + val host: String, + val port: String, + ) + + override fun getOrder() = ORDER + + companion object { + const val ORDER = Ordered.HIGHEST_PRECEDENCE + 10 + const val X_REAL_IP = "X-Real-IP" + const val X_FORWARDED_PROTO = "X-Forwarded-Proto" + const val X_FORWARDED_HOST = "X-Forwarded-Host" + const val X_FORWARDED_PORT = "X-Forwarded-Port" + private const val INTERNAL_PREFIX = "X-Internal-" + + // 클라이언트 값을 믿지 않고 항상 지우는 헤더 + private val OWNED_HEADERS = + listOf( + JwtAuthenticationFilter.USER_ID_HEADER, + X_REAL_IP, + ClientIpResolver.X_FORWARDED_FOR, + X_FORWARDED_PROTO, + X_FORWARDED_HOST, + X_FORWARDED_PORT, + "X-Forwarded-Prefix", + "Forwarded", + ) + } +} diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/filter/RequestValidator.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/filter/RequestValidator.kt new file mode 100644 index 0000000..247abda --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/filter/RequestValidator.kt @@ -0,0 +1,25 @@ +package com.rush.gateway.filter + +import org.springframework.http.HttpHeaders + +/** + * 서비스로 넘기면 안 되는 형식의 요청을 골라냅니다. + * 경로 조작(.., 인코딩된 /, \ 등)과 제어문자가 섞인 헤더를 거부합니다. + */ +object RequestValidator { + // 디코딩 후 경로 구분자나 상위 경로로 바뀔 수 있는 인코딩, NUL + private val forbiddenEncodings = listOf("%2e", "%2f", "%5c", "%00") + + fun isValidPath(rawPath: String): Boolean { + val lower = rawPath.lowercase() + if (forbiddenEncodings.any { it in lower }) return false + if ("//" in rawPath || '\\' in rawPath) return false + return rawPath.split('/').none { it == ".." || it == "." } + } + + fun isValidHeaders(headers: HttpHeaders): Boolean = + headers.all { (name, values) -> isValidHeaderValue(name) && values.all(::isValidHeaderValue) } + + // 탭을 제외한 제어문자(CR, LF, NUL 등)와 DEL을 거부합니다. + fun isValidHeaderValue(value: String): Boolean = value.none { (it < ' ' && it != '\t') || it == '\u007f' } +} diff --git a/rush-gateway/src/main/resources/application.yml b/rush-gateway/src/main/resources/application.yml index e8b5137..0a5c8c0 100644 --- a/rush-gateway/src/main/resources/application.yml +++ b/rush-gateway/src/main/resources/application.yml @@ -1,5 +1,7 @@ server: port: 8080 + # 이보다 큰 요청 헤더는 Netty가 431로 거부합니다. + max-http-request-header-size: 8KB spring: application: @@ -18,6 +20,11 @@ spring: webflux: httpclient: connect-timeout: 3000 + # X-Forwarded-*는 RequestSanitizeFilter만 씁니다. SCG가 값을 덧붙이지 않도록 끕니다. + x-forwarded: + enabled: false + forwarded: + enabled: false routes: # ---- Spring 서비스: Eureka에서 찾아갑니다 (lb://서비스이름) ---- - id: rush-auth diff --git a/rush-gateway/src/test/kotlin/com/rush/gateway/filter/RequestSanitizeTest.kt b/rush-gateway/src/test/kotlin/com/rush/gateway/filter/RequestSanitizeTest.kt new file mode 100644 index 0000000..ebfb0d2 --- /dev/null +++ b/rush-gateway/src/test/kotlin/com/rush/gateway/filter/RequestSanitizeTest.kt @@ -0,0 +1,156 @@ +package com.rush.gateway.filter + +import com.sun.net.httpserver.HttpServer +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.web.reactive.server.WebTestClient +import java.net.InetSocketAddress +import java.net.Socket +import java.net.URI +import java.util.concurrent.atomic.AtomicReference + +private const val SECRET = "test-secret-key-for-gateway-sanitize-test-0123456789" + +// remoteAddress가 필요하므로 @AutoConfigureWebTestClient(mock 바인딩) 대신 실제 서버로 요청합니다. +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.cloud.config.enabled=false", + "eureka.client.enabled=false", + "jwt.secret=$SECRET", + ], +) +class RequestSanitizeTest { + @Autowired + lateinit var client: WebTestClient + + @LocalServerPort + var port: Int = 0 + + @Test + fun `클라이언트가 위조한 게이트웨이 소유 헤더는 올바른 값으로 바뀐다`() { + client + .get() + .uri("/user/ping") + .header("Authorization", "Bearer ${token("42")}") + .header("X-User-Id", "1") + .header("X-Forwarded-For", "10.0.0.1") + .header("X-Real-IP", "10.0.0.1") + .header("X-Forwarded-Host", "evil.example.com") + .header("X-Internal-Role", "admin") + .header("X-Request-Id", "forged") + .exchange() + .expectStatus() + .isOk + + val headers = received.get().headers + assertThat(headers.getFirst("X-User-Id")).isEqualTo("42") + assertThat(headers["X-Forwarded-For"]).containsExactly("127.0.0.1") + assertThat(headers.getFirst("X-Real-IP")).isEqualTo("127.0.0.1") + assertThat(headers.getFirst("X-Forwarded-Host")).isEqualTo("localhost") + assertThat(headers.getFirst("X-Forwarded-Port")).isEqualTo(port.toString()) + assertThat(headers.getFirst("X-Forwarded-Proto")).isEqualTo("http") + assertThat(headers.containsKey("X-Internal-Role")).isFalse() + assertThat(headers.getFirst("X-Request-Id")).isNotEqualTo("forged") + } + + @Test + fun `쿼리로 받은 토큰은 서비스에 넘기지 않는다`() { + client + .get() + .uri("/user/ping?token=${token("7")}&room=1") + .exchange() + .expectStatus() + .isOk + + assertThat(received.get().query).isEqualTo("room=1") + assertThat(received.get().headers.getFirst("X-User-Id")).isEqualTo("7") + } + + @Test + fun `경로 조작 요청은 400 공통 응답을 준다`() { + client + .get() + .uri(URI.create("http://localhost:$port/shop/%2e%2e/admin")) + .exchange() + .expectStatus() + .isBadRequest + .expectBody() + .jsonPath("$.code") + .isEqualTo("BAD_REQUEST") + } + + @Test + fun `제어문자가 든 헤더는 400으로 막는다`() { + // HTTP 클라이언트는 제어문자 헤더를 보내기 전에 막으므로 소켓으로 직접 보냅니다. + val response = + Socket("localhost", port).use { socket -> + socket.getOutputStream().write( + "GET /user/ping HTTP/1.1\r\nHost: localhost\r\nX-Test: a\u0001b\r\nConnection: close\r\n\r\n".toByteArray(), + ) + socket.getInputStream().bufferedReader().readLine() + } + + assertThat(response).startsWith("HTTP/1.1 400") + } + + @Test + fun `너무 큰 헤더는 431로 거부한다`() { + client + .get() + .uri("/user/ping") + .header("X-Large", "a".repeat(10 * 1024)) + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.REQUEST_HEADER_FIELDS_TOO_LARGE) + } + + private data class Received( + val headers: org.springframework.http.HttpHeaders, + val query: String?, + ) + + companion object { + private val received = AtomicReference() + + private val echoServer = + HttpServer.create(InetSocketAddress("localhost", 0), 0).apply { + createContext("/") { exchange -> + val headers = org.springframework.http.HttpHeaders() + exchange.requestHeaders.forEach { (name, values) -> headers.addAll(name, values) } + received.set(Received(headers, exchange.requestURI.rawQuery)) + exchange.sendResponseHeaders(200, -1) + exchange.close() + } + start() + } + + @JvmStatic + @DynamicPropertySource + fun routes(registry: DynamicPropertyRegistry) { + registry.add("USER_URL") { "http://localhost:${echoServer.address.port}" } + } + + @JvmStatic + @AfterAll + fun stopServer() { + echoServer.stop(0) + } + + private fun token(subject: String): String = + Jwts + .builder() + .subject(subject) + .signWith(Keys.hmacShaKeyFor(SECRET.toByteArray())) + .compact() + } +} diff --git a/rush-gateway/src/test/kotlin/com/rush/gateway/filter/RequestValidatorTest.kt b/rush-gateway/src/test/kotlin/com/rush/gateway/filter/RequestValidatorTest.kt new file mode 100644 index 0000000..a59e037 --- /dev/null +++ b/rush-gateway/src/test/kotlin/com/rush/gateway/filter/RequestValidatorTest.kt @@ -0,0 +1,37 @@ +package com.rush.gateway.filter + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class RequestValidatorTest { + @ParameterizedTest + @ValueSource( + strings = [ + "/shop/../admin", "/shop/%2e%2e/admin", "/shop/%2E%2E/admin", "/shop%2fadmin", + "/shop/%5cadmin", "//shop", "/shop/./a", "/shop/a%00", + ], + ) + fun `경로 조작이 있으면 거부한다`(path: String) { + assertFalse(RequestValidator.isValidPath(path)) + } + + @ParameterizedTest + @ValueSource(strings = ["/shop/ping", "/shop/items/1", "/user/v3/api-docs", "/shop/a.b/c..d"]) + fun `정상 경로는 통과한다`(path: String) { + assertTrue(RequestValidator.isValidPath(path)) + } + + @ParameterizedTest + @ValueSource(strings = ["a\rb", "a\nb", "a\u0000b", "a\u007fb", "a\u0001b"]) + fun `제어문자가 든 헤더 값은 거부한다`(value: String) { + assertFalse(RequestValidator.isValidHeaderValue(value)) + } + + @ParameterizedTest + @ValueSource(strings = ["Bearer abc.def", "a\tb", "한글 값"]) + fun `정상 헤더 값은 통과한다`(value: String) { + assertTrue(RequestValidator.isValidHeaderValue(value)) + } +} From 82cab6ed76924819548daf1bc457c4ea3c0d6039 Mon Sep 17 00:00:00 2001 From: jyx-07 Date: Wed, 4 Nov 2026 08:18:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(gateway):=20=EC=9D=B8=EC=BD=94=EB=94=A9?= =?UTF-8?q?=EB=90=9C=20=ED=82=A4=EC=9D=98=20=EC=BF=BC=EB=A6=AC=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=A0=9C=EA=B1=B0=EC=99=80=20IPv6=20=ED=98=B8?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A0=84=EB=8B=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gateway/filter/JwtAuthenticationFilter.kt | 15 ++++++++-- .../gateway/filter/RequestSanitizeFilter.kt | 4 +-- .../gateway/filter/RequestSanitizeTest.kt | 28 +++++++++++++++++++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/filter/JwtAuthenticationFilter.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/filter/JwtAuthenticationFilter.kt index 723d525..28b563b 100644 --- a/rush-gateway/src/main/kotlin/com/rush/gateway/filter/JwtAuthenticationFilter.kt +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/filter/JwtAuthenticationFilter.kt @@ -13,6 +13,8 @@ import org.springframework.web.server.ServerWebExchange import org.springframework.web.util.UriComponentsBuilder import reactor.core.publisher.Mono import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets /** * 게이트웨이에서 JWT를 한 번만 검증하고, 검증된 사용자 ID를 X-User-Id 헤더로 뒤쪽 서비스에 넘깁니다. @@ -71,12 +73,19 @@ class JwtAuthenticationFilter( return chain.filter(exchange.mutate().request(authed).build()) } - private fun withoutTokenQuery(uri: URI): URI = - UriComponentsBuilder + // 인증은 디코딩된 키로 토큰을 읽으므로, ?%74oken=처럼 인코딩된 키도 디코딩한 뒤 비교해 지웁니다. + private fun withoutTokenQuery(uri: URI): URI { + val rawQuery = uri.rawQuery ?: return uri + val kept = rawQuery.split('&').filterNot { decodedKey(it) == TOKEN_QUERY } + return UriComponentsBuilder .fromUri(uri) - .replaceQueryParam(TOKEN_QUERY) + .replaceQuery(kept.joinToString("&").ifEmpty { null }) .build(true) .toUri() + } + + private fun decodedKey(param: String): String? = + runCatching { URLDecoder.decode(param.substringBefore('='), StandardCharsets.UTF_8) }.getOrNull() private fun unauthorized(exchange: ServerWebExchange): Mono = errorResponseWriter.write(exchange, HttpStatus.UNAUTHORIZED) diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/filter/RequestSanitizeFilter.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/filter/RequestSanitizeFilter.kt index f9fc085..11942f3 100644 --- a/rush-gateway/src/main/kotlin/com/rush/gateway/filter/RequestSanitizeFilter.kt +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/filter/RequestSanitizeFilter.kt @@ -3,7 +3,6 @@ package com.rush.gateway.filter import com.rush.gateway.error.ErrorResponseWriter import com.rush.gateway.security.ClientIpResolver import org.springframework.core.Ordered -import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import org.springframework.web.server.ServerWebExchange @@ -66,7 +65,8 @@ class RequestSanitizeFilter( val own = Forwarded( proto = proto, - host = headers.getFirst(HttpHeaders.HOST)?.substringBefore(':') ?: uri.host.orEmpty(), + // IPv6 Host([::1]:8080)도 대괄호를 포함한 호스트만 남도록 URI에서 읽습니다. + host = uri.host.orEmpty(), port = ( if (uri.port != -1) { diff --git a/rush-gateway/src/test/kotlin/com/rush/gateway/filter/RequestSanitizeTest.kt b/rush-gateway/src/test/kotlin/com/rush/gateway/filter/RequestSanitizeTest.kt index ebfb0d2..9d15501 100644 --- a/rush-gateway/src/test/kotlin/com/rush/gateway/filter/RequestSanitizeTest.kt +++ b/rush-gateway/src/test/kotlin/com/rush/gateway/filter/RequestSanitizeTest.kt @@ -76,6 +76,34 @@ class RequestSanitizeTest { assertThat(received.get().headers.getFirst("X-User-Id")).isEqualTo("7") } + @Test + fun `키를 인코딩해 보낸 쿼리 토큰도 서비스에 넘기지 않는다`() { + client + .get() + .uri(URI.create("http://localhost:$port/user/ping?%74oken=${token("7")}&room=1")) + .exchange() + .expectStatus() + .isOk + + assertThat(received.get().query).isEqualTo("room=1") + assertThat(received.get().headers.getFirst("X-User-Id")).isEqualTo("7") + } + + @Test + fun `IPv6 Host 헤더는 대괄호를 포함한 호스트로 전달한다`() { + val response = + Socket("localhost", port).use { socket -> + socket.getOutputStream().write( + "GET /user/ping HTTP/1.1\r\nHost: [::1]:$port\r\nAuthorization: Bearer ${token("42")}\r\nConnection: close\r\n\r\n" + .toByteArray(), + ) + socket.getInputStream().bufferedReader().readLine() + } + + assertThat(response).startsWith("HTTP/1.1 200") + assertThat(received.get().headers.getFirst("X-Forwarded-Host")).isEqualTo("[::1]") + } + @Test fun `경로 조작 요청은 400 공통 응답을 준다`() { client