feat(security): SSRF rebinding 완화 + 업로드 저장루트 static 트리 밖 이전

W3 후속 하드닝 2건.

- b1: SsrfSafeFetcher @PostConstruct networkaddress.cache.ttl=30 고정
  — 검증~connect 동일 lookup 재사용으로 DNS rebinding TOCTOU 창 최소화.
  런타임 반영은 best-effort(InetAddressCachePolicy lazy init); 결정적
  보장은 JVM 레벨 java.security 가 정본임을 주석 명시.
- b2: app.upload.game-storage-path=${user.home}/.bibimbap/uploads
  — 업로드물을 static 서빙 트리 밖으로 이전(웹서버 직접 서빙 차단,
  컨트롤러 권한게이트 경유). gameRoot/profile 자연 정합, @Value 기본값은
  IDE 로컬 부작용 회피로 유지(명시 properties 오버라이드).
- 테스트 1건 신규(ttl 값 검증). L1 353/353 GREEN, 회귀 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD
This commit is contained in:
이정수 2026-06-29 14:45:53 +09:00
parent 3d10449e67
commit 9041bb7c70
3 changed files with 32 additions and 1 deletions

View File

@ -42,7 +42,8 @@ import org.springframework.stereotype.Component;
* *
* <p>잔여 TOCTOU(검증 시점 IP HttpClient 실제 connect 하는 IP 불일치 가능성) JVM * <p>잔여 TOCTOU(검증 시점 IP HttpClient 실제 connect 하는 IP 불일치 가능성) JVM
* positive DNS 캐시({@code networkaddress.cache.ttl}) 동일 lookup 재사용되도록 두어 창을 * positive DNS 캐시({@code networkaddress.cache.ttl}) 동일 lookup 재사용되도록 두어 창을
* 최소화한다. 추가로 connect 직전 재검증을 수행한다. * 최소화한다 (@PostConstruct 설정; best-effort 결정적 보장은 JVM 레벨 {@code networkaddress.cache.ttl=30} 정본).
* 추가로 connect 직전 재검증을 수행한다.
*/ */
@Component @Component
public class SsrfSafeFetcher { public class SsrfSafeFetcher {
@ -71,6 +72,22 @@ public class SsrfSafeFetcher {
.connectTimeout(CONNECT_TIMEOUT) .connectTimeout(CONNECT_TIMEOUT)
.build(); .build();
/**
* positive DNS 캐시 TTL 30초로 고정한다 (DNS rebinding TOCTOU 최소화).
*
* <p><b>best-effort 한계</b>: {@code sun.net.InetAddressCachePolicy} 최초 {@link InetAddress}
* 조회 Security property lazy 하게 읽는다. @PostConstruct DNS 조회 전에 실행되면
* 반영되지만, 다른 빈이 먼저 DNS 조회를 했다면 런타임 반영이 보장되지 않는다.
* 결정적 보장이 필요하면 JVM 레벨({@code $JAVA_HOME/conf/security/java.security}
* {@code networkaddress.cache.ttl=30}) 정본이다.
*/
@jakarta.annotation.PostConstruct
void initDnsCachePolicy() {
// 검증 시점 IP connect 시점 IP 동일 lookup 재사용하도록 positive 캐시를 양수로 고정
// (DNS rebinding TOCTOU 최소화). 미설정 JDK 기본은 구현의존이라 결정적이지 않다.
java.security.Security.setProperty("networkaddress.cache.ttl", "30");
}
/** /**
* 외부 URL SSRF 방어 하에 가져온다. * 외부 URL SSRF 방어 하에 가져온다.
* *

View File

@ -22,6 +22,9 @@ spring.config.import=optional:classpath:${spring.profiles.active}/db.properties,
app.webgl.asset-origin= app.webgl.asset-origin=
app.webgl.frame-ancestors=self app.webgl.frame-ancestors=self
# upload storage (static 트리 밖 — 웹서버 직접 서빙 차단, 컨트롤러 권한게이트 경유)
app.upload.game-storage-path=${user.home}/.bibimbap/uploads
# log # log
mybatis.configuration.log-impl=org.apache.ibatis.logging.slf4j.Slf4jImpl mybatis.configuration.log-impl=org.apache.ibatis.logging.slf4j.Slf4jImpl
logging.level.org.apache.ibatis=TRACE logging.level.org.apache.ibatis=TRACE

View File

@ -324,6 +324,17 @@ class SsrfSafeFetcherTest {
assertEquals(html, new String(result.get().body(), StandardCharsets.UTF_8)); assertEquals(html, new String(result.get().body(), StandardCharsets.UTF_8));
} }
// b1: initDnsCachePolicy
@Test
void initDnsCachePolicySetsPositiveTtl() {
// @PostConstruct 호출 Security property "networkaddress.cache.ttl" "30" 인지 검증.
// (런타임 InetAddress 반영 여부는 단위테스트 범위 설정만 검증.)
SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
fetcher.initDnsCachePolicy();
assertEquals("30", java.security.Security.getProperty("networkaddress.cache.ttl"));
}
// helpers // helpers
private void startServer(HttpHandler handler) throws IOException { private void startServer(HttpHandler handler) throws IOException {