Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions rush-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 허용 대역 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ data class ErrorResponse(

private fun messageOf(status: HttpStatus) =
when (status) {
HttpStatus.BAD_REQUEST -> "잘못된 요청입니다."
HttpStatus.UNAUTHORIZED -> "인증이 필요합니다."
HttpStatus.FORBIDDEN -> "접근 권한이 없습니다."
HttpStatus.NOT_FOUND -> "요청한 경로를 찾을 수 없습니다."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ 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
import java.net.URLDecoder
import java.nio.charset.StandardCharsets

/**
* 게이트웨이에서 JWT를 한 번만 검증하고, 검증된 사용자 ID를 X-User-Id 헤더로 뒤쪽 서비스에 넘깁니다.
Expand All @@ -27,6 +31,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,
Expand All @@ -35,13 +42,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 =
Expand All @@ -55,15 +63,36 @@ 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())
}

// 인증은 디코딩된 키로 토큰을 읽으므로, ?%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)
.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<Void> = 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"
}
}
Original file line number Diff line number Diff line change
@@ -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.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<Void> {
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,
// IPv6 Host([::1]:8080)도 대괄호를 포함한 호스트만 남도록 URI에서 읽습니다.
host = 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",
)
}
}
Original file line number Diff line number Diff line change
@@ -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' }
}
7 changes: 7 additions & 0 deletions rush-gateway/src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
server:
port: 8080
# 이보다 큰 요청 헤더는 Netty가 431로 거부합니다.
max-http-request-header-size: 8KB

spring:
application:
Expand All @@ -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
Expand Down
Loading
Loading