Skip to content
Merged
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
56 changes: 55 additions & 1 deletion src/main/java/com/carecode/core/util/ClientIpResolver.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
package com.carecode.core.util;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicReference;

/**
* 클라이언트 IP 해석기. X-Forwarded-For 는 클라이언트가 임의로 붙일 수 있는 헤더다.
*
Expand All @@ -18,11 +24,33 @@
@Component
public class ClientIpResolver {

/** 경고를 이 간격에 한 번만 남긴다. 직접 노출이면 모든 요청이 해당되므로 로그가 쓸려 나간다. */
private static final Duration WARN_INTERVAL = Duration.ofMinutes(10);

private final boolean trustForwardedHeaders;

/**
* 프록시를 거치지 않고 도착한 요청 수.
*
* <p>XFF 를 신뢰하도록 설정했다는 것은 "프록시 뒤에 있다" 는 전제다. 그런데 XFF 가 없는
* 요청이 오면 그 요청은 <b>프록시를 거치지 않았다.</b> 즉 앱 포트가 직접 닿는 상태이고,
* 그러면 공격자가 XFF 를 직접 붙여 IP 기준 제한(로그인·인증코드 발송)을 우회할 수 있다.
*
* <p>보안그룹·방화벽 설정은 애플리케이션이 알 수 없다. 이 값은 그 전제가 깨졌는지를
* <b>런타임에 알 수 있는 유일한 신호</b>다. 0 이 아니면 8082 가 외부에 열려 있다는 뜻이다.
* 이슈 #90
*/
private final Counter unproxiedRequests;

private final AtomicReference<Instant> lastWarnedAt = new AtomicReference<>(Instant.EPOCH);

public ClientIpResolver(
@Value("${app.rate-limit.trust-forwarded-headers:false}") boolean trustForwardedHeaders) {
@Value("${app.rate-limit.trust-forwarded-headers:false}") boolean trustForwardedHeaders,
MeterRegistry meterRegistry) {
this.trustForwardedHeaders = trustForwardedHeaders;
this.unproxiedRequests = Counter.builder("carecode.ratelimit.unproxied.requests")
.description("XFF 를 신뢰하도록 설정했는데 XFF 없이 도착한 요청 수. 0 이 아니면 앱 포트가 직접 노출된 것이다")
.register(meterRegistry);
if (trustForwardedHeaders) {
log.info("X-Forwarded-For 헤더를 신뢰하도록 설정되었습니다. 반드시 신뢰 가능한 프록시 뒤에서만 사용하세요.");
}
Expand All @@ -35,11 +63,37 @@ public String resolve(HttpServletRequest request) {

String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded == null || forwarded.isBlank() || "unknown".equalsIgnoreCase(forwarded)) {
reportUnproxied(request);
return request.getRemoteAddr();
}

// "client, proxy1, proxy2" 형식에서 최초 클라이언트 주소를 사용한다.
String first = forwarded.split(",")[0].trim();
return first.isEmpty() ? request.getRemoteAddr() : first;
}

/**
* 프록시를 거치지 않은 요청을 센다.
*
* <p>루프백은 빼고 센다. 배포 스크립트가 서버 안에서 {@code 127.0.0.1:8082} 로 헬스체크를
* 48번까지 돌리므로, 포함하면 배포마다 경고가 쏟아져 신호가 묻힌다.
*/
private void reportUnproxied(HttpServletRequest request) {
String remote = request.getRemoteAddr();
if (remote == null || remote.startsWith("127.") || "::1".equals(remote) || "0:0:0:0:0:0:0:1".equals(remote)) {
return;
}

unproxiedRequests.increment();

Instant now = Instant.now();
Instant last = lastWarnedAt.get();
if (Duration.between(last, now).compareTo(WARN_INTERVAL) >= 0
&& lastWarnedAt.compareAndSet(last, now)) {
log.warn("프록시를 거치지 않은 요청이 도착했습니다 - remoteAddr={}, uri={}."
+ " XFF 를 신뢰하도록 설정했는데 앱 포트가 직접 닿는 상태라면,"
+ " 헤더를 위조해 IP 기준 제한을 우회할 수 있습니다. 보안그룹/방화벽을 확인하세요.",
remote, request.getRequestURI());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.data.redis.core.StringRedisTemplate;

/**
* 리프레시 토큰 저장소 선택.
*
* <p>{@code redis} 는 로그아웃 시 토큰을 실제로 폐기한다. {@code none}({@link NoopRefreshTokenStore})
* 은 아무것도 하지 않는다 — 로컬·CI 가 Redis 없이 뜨기 위한 선택지다.
*/
@Configuration
public class RefreshTokenStoreConfiguration {

Expand All @@ -21,9 +28,25 @@ public RefreshTokenStore redisRefreshTokenStore(
return new RedisRefreshTokenStore(stringRedisTemplate, refreshTtlMs);
}

/**
* Redis 를 쓰지 않을 때의 저장소. <b>운영에서는 기동을 막는다.</b>
*
* <p>이 구현이 운영에 올라가면 로그아웃이 <b>아무것도 폐기하지 못한다.</b> 응답은 200 이고
* 클라이언트는 로그아웃된 것으로 보이지만, 탈취된 리프레시 토큰은 만료(기본 30일) 전까지
* 계속 새 액세스 토큰을 받아갈 수 있다. 조용히 틀린 상태라 운영에서 알아차릴 방법이 없다.
*
* <p>운영 프로파일의 기본값은 {@code redis} 지만 {@code JWT_REFRESH_TOKEN_STORE} 로 덮을 수
* 있다. 그래서 기본값에 기대지 않고 여기서 막는다 — 로그아웃이 동작하지 않는 서버가 뜨는
* 것보다 뜨지 않는 편이 낫다. 이슈 #90
*/
@Bean
@ConditionalOnMissingBean(RefreshTokenStore.class)
public RefreshTokenStore noopRefreshTokenStore() {
public RefreshTokenStore noopRefreshTokenStore(Environment environment) {
if (environment.matchesProfiles("prod")) {
throw new IllegalStateException(
"운영에서는 리프레시 토큰 저장소가 필요합니다. jwt.refresh-token.store=redis 로 두세요"
+ " (현재 설정으로는 로그아웃이 토큰을 폐기하지 못합니다).");
}
return new NoopRefreshTokenStore();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class RateLimitInterceptorTest {

@BeforeEach
void setUp() {
interceptor = new RateLimitInterceptor(redisTemplate, new ClientIpResolver(false));
interceptor = new RateLimitInterceptor(redisTemplate, new ClientIpResolver(false, new io.micrometer.core.instrument.simple.SimpleMeterRegistry()));
response = new MockHttpServletResponse();
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(redisTemplate.getExpire(anyString())).thenReturn(60L);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ private static RateLimit limit(int requests) {

@BeforeEach
void setUp() throws Throwable {
aspect = new RateLimitingAspect(redisTemplate, new ClientIpResolver(false));
aspect = new RateLimitingAspect(redisTemplate, new ClientIpResolver(false, new io.micrometer.core.instrument.simple.SimpleMeterRegistry()));
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(joinPoint.getSignature()).thenReturn(signature);
when(signature.toShortString()).thenReturn("AuthController.login(..)");
Expand Down
66 changes: 62 additions & 4 deletions src/test/java/com/carecode/core/util/ClientIpResolverTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.springframework.mock.web.MockHttpServletRequest;

import static org.assertj.core.api.Assertions.assertThat;
Expand All @@ -13,7 +14,7 @@ class ClientIpResolverTest {
@Test
@DisplayName("프록시를 신뢰하지 않으면 X-Forwarded-For 를 무시한다")
void ignoresForwardedHeaderWhenNotTrusted() {
ClientIpResolver resolver = new ClientIpResolver(false);
ClientIpResolver resolver = new ClientIpResolver(false, new SimpleMeterRegistry());
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRemoteAddr("10.0.0.1");
request.addHeader("X-Forwarded-For", "1.2.3.4");
Expand All @@ -24,7 +25,7 @@ void ignoresForwardedHeaderWhenNotTrusted() {
@Test
@DisplayName("헤더를 위조해도 신뢰하지 않는 설정에서는 키가 달라지지 않는다")
void spoofedHeaderCannotChangeIdentity() {
ClientIpResolver resolver = new ClientIpResolver(false);
ClientIpResolver resolver = new ClientIpResolver(false, new SimpleMeterRegistry());

MockHttpServletRequest first = new MockHttpServletRequest();
first.setRemoteAddr("10.0.0.1");
Expand All @@ -40,7 +41,7 @@ void spoofedHeaderCannotChangeIdentity() {
@Test
@DisplayName("프록시를 신뢰하면 X-Forwarded-For 의 첫 주소를 사용한다")
void usesFirstForwardedAddressWhenTrusted() {
ClientIpResolver resolver = new ClientIpResolver(true);
ClientIpResolver resolver = new ClientIpResolver(true, new SimpleMeterRegistry());
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRemoteAddr("10.0.0.1");
request.addHeader("X-Forwarded-For", "1.2.3.4, 10.0.0.2, 10.0.0.3");
Expand All @@ -51,10 +52,67 @@ void usesFirstForwardedAddressWhenTrusted() {
@Test
@DisplayName("신뢰 설정이어도 헤더가 없으면 원격 주소를 사용한다")
void fallsBackToRemoteAddr() {
ClientIpResolver resolver = new ClientIpResolver(true);
ClientIpResolver resolver = new ClientIpResolver(true, new SimpleMeterRegistry());
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRemoteAddr("10.0.0.1");

assertThat(resolver.resolve(request)).isEqualTo("10.0.0.1");
}

/**
* 앱 포트가 직접 노출됐는지는 애플리케이션이 알 수 없다. 보안그룹·방화벽은 코드 밖이다.
*
* <p>알 수 있는 신호가 하나 있다 — XFF 를 신뢰하도록 설정했다는 것은 "프록시 뒤에 있다" 는
* 전제인데, XFF 가 없는 요청이 오면 그 요청은 프록시를 거치지 않았다. 그러면 공격자가 XFF 를
* 직접 붙여 IP 기준 제한을 우회할 수 있다. 그래서 그 수를 지표로 센다. 이슈 #90
*/
@Test
@DisplayName("프록시를 거치지 않은 요청을 지표로 센다")
void countsUnproxiedRequests() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
ClientIpResolver resolver = new ClientIpResolver(true, registry);

MockHttpServletRequest direct = new MockHttpServletRequest();
direct.setRemoteAddr("203.0.113.9"); // 외부에서 바로 들어온 요청 (XFF 없음)

assertThat(resolver.resolve(direct)).isEqualTo("203.0.113.9");
assertThat(registry.get("carecode.ratelimit.unproxied.requests").counter().count())
.as("0 이 아니면 앱 포트가 직접 열려 있다는 뜻이다")
.isEqualTo(1.0);
}

/**
* 배포 스크립트가 서버 안에서 127.0.0.1:8082 로 헬스체크를 48번까지 돌린다. 그것까지 세면
* 배포마다 지표가 튀어 신호가 묻힌다.
*/
@Test
@DisplayName("루프백 요청은 세지 않는다 - 배포 헬스체크가 그 경로다")
void doesNotCountLoopback() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
ClientIpResolver resolver = new ClientIpResolver(true, registry);

for (String loopback : new String[] {"127.0.0.1", "::1"}) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRemoteAddr(loopback);
resolver.resolve(request);
}

assertThat(registry.get("carecode.ratelimit.unproxied.requests").counter().count())
.isEqualTo(0.0);
}

@Test
@DisplayName("프록시를 거친 요청은 세지 않는다")
void doesNotCountProxiedRequests() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
ClientIpResolver resolver = new ClientIpResolver(true, registry);

MockHttpServletRequest proxied = new MockHttpServletRequest();
proxied.setRemoteAddr("10.0.0.1");
proxied.addHeader("X-Forwarded-For", "203.0.113.9");

assertThat(resolver.resolve(proxied)).isEqualTo("203.0.113.9");
assertThat(registry.get("carecode.ratelimit.unproxied.requests").counter().count())
.isEqualTo(0.0);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,17 @@ void trustFlagIsRedundantWhenForwardedFilterIsEnabled() throws Exception {

// 두 설정이 같은 결과를 낸다. rate limit 이 "프록시 IP 하나로 집계되는" 상황은
// forward-headers-strategy 가 꺼져 있을 때만 발생한다.
assertThat(new ClientIpResolver(false).resolve(wrapped)).isEqualTo(CLIENT_IP);
assertThat(new ClientIpResolver(true).resolve(wrapped)).isEqualTo(CLIENT_IP);
assertThat(new ClientIpResolver(false, new io.micrometer.core.instrument.simple.SimpleMeterRegistry()).resolve(wrapped)).isEqualTo(CLIENT_IP);
assertThat(new ClientIpResolver(true, new io.micrometer.core.instrument.simple.SimpleMeterRegistry()).resolve(wrapped)).isEqualTo(CLIENT_IP);
}

@Test
@DisplayName("필터가 없으면 trust 설정이 실제로 갈린다")
void withoutForwardedFilterTheFlagMatters() {
MockHttpServletRequest raw = requestBehindProxy();

assertThat(new ClientIpResolver(false).resolve(raw)).isEqualTo(PROXY_IP);
assertThat(new ClientIpResolver(true).resolve(raw)).isEqualTo(CLIENT_IP);
assertThat(new ClientIpResolver(false, new io.micrometer.core.instrument.simple.SimpleMeterRegistry()).resolve(raw)).isEqualTo(PROXY_IP);
assertThat(new ClientIpResolver(true, new io.micrometer.core.instrument.simple.SimpleMeterRegistry()).resolve(raw)).isEqualTo(CLIENT_IP);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.carecode.domain.user.config;

import com.carecode.domain.user.service.refreshtoken.NoopRefreshTokenStore;
import com.carecode.domain.user.service.refreshtoken.RefreshTokenStore;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
* 운영에서 리프레시 토큰 저장소 없이 뜨지 못하게 한다.
*
* <p>{@link NoopRefreshTokenStore} 가 운영에 올라가면 로그아웃이 <b>아무것도 폐기하지
* 못한다.</b> 응답은 200 이고 클라이언트는 로그아웃된 것으로 보이지만, 탈취된 리프레시 토큰은
* 만료(기본 30일) 전까지 계속 새 액세스 토큰을 받아갈 수 있다. 조용히 틀린 상태라 운영에서
* 알아차릴 방법이 없다.
*
* <p>운영 프로파일의 기본값은 {@code redis} 지만 {@code JWT_REFRESH_TOKEN_STORE} 로 덮을 수
* 있어서, 기본값에 기대지 않고 막는다. 이슈 #90
*/
@DisplayName("리프레시 토큰 저장소 전제")
class RefreshTokenStoreGuardTest {

private final RefreshTokenStoreConfiguration configuration = new RefreshTokenStoreConfiguration();

@Test
@DisplayName("운영에서 저장소가 없으면 기동하지 않는다")
void prodRefusesToStartWithoutStore() {
MockEnvironment prod = new MockEnvironment();
prod.setActiveProfiles("prod");

assertThatThrownBy(() -> configuration.noopRefreshTokenStore(prod))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("리프레시 토큰 저장소");
}

/** 로컬·CI 는 Redis 없이 떠야 한다. 막는 범위가 넓으면 개발이 멈춘다. */
@Test
@DisplayName("로컬·CI 는 저장소 없이 뜬다")
void nonProdStartsWithoutStore() {
MockEnvironment dev = new MockEnvironment();
dev.setActiveProfiles("dev");

RefreshTokenStore store = configuration.noopRefreshTokenStore(dev);

assertThat(store).isInstanceOf(NoopRefreshTokenStore.class);
}
}
Loading