From b5fc803149f3dea0651e8b8d81391d5562f4faa1 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Thu, 15 Oct 2026 14:52:22 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EC=BD=9C=EB=B0=B1=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EA=B8=B0=EB=B3=B8=EA=B0=92=EC=9D=84=20=EC=97=86?= =?UTF-8?q?=EC=95=A0=EA=B3=A0=20=ED=95=84=EC=88=98=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=EC=9D=84=20=EA=B8=B0=EB=8F=99=20=EC=8B=9C=20?= =?UTF-8?q?=EB=A7=89=EC=9D=8C(N-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.callback-base-url의 기본값이 개발자 ngrok 주소라, 운영에서 CALLBACK_BASE_URL을 빠뜨려도 서버가 떠서 분석·채팅 서버의 결과가 그 주소로 갔다. 접수는 202로 정상처럼 보이고 실패는 시간 초과로만 드러났다. - callback-base-url 기본값 제거. 비었거나 형식이 틀리면(http/https 아님, 호스트 없음, 사용자 정보·쿼리·조각·끝 슬래시) 기동 실패. 끝 슬래시는 "//api/..."가 되어 콜백이 모두 400으로 막힌다 - prod 프로필이면 https만 받고 ngrok·localhost·loopback 주소를 받지 않는다. 네트워크 연결 검사는 하지 않는다 - INTERNAL_AUTH_REQUIRED(app.internal-auth.required, 기본 false) 추가. true인데 INTERNAL_AUTH_TOKEN이 비면 기동 실패. 기존 내부 인증 동작은 그대로이고, 오류 메시지에 인증값을 싣지 않는다 - 테스트는 src/test/resources/config/application.yaml에서 콜백 주소를 명시적으로 채운다 운영 env에 CALLBACK_BASE_URL이 반드시 있어야 한다. prod 검사를 쓰려면 SPRING_PROFILES_ACTIVE=prod를 넣는다. INTERNAL_AUTH_REQUIRED=true는 Chat·Analysis가 헤더를 보내는 배포를 마친 뒤 마지막에 켠다. Co-Authored-By: Claude Opus 5.5 --- .../global/config/RequiredSettingsCheck.java | 88 +++++++++++++++++++ src/main/resources/application.yaml | 8 +- .../config/RequiredSettingsCheckTest.java | 85 ++++++++++++++++++ src/test/resources/config/application.yaml | 4 + 4 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java create mode 100644 src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java create mode 100644 src/test/resources/config/application.yaml diff --git a/src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java b/src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java new file mode 100644 index 00000000..b6629b4a --- /dev/null +++ b/src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java @@ -0,0 +1,88 @@ +package repit.repit_api_server.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; + +/** + * 빠지면 서버가 떠도 제대로 일하지 못하는 설정을 기동할 때 확인한다. 어기면 서버가 뜨지 않는다. + * + *

콜백 주소가 틀리면 분석·채팅 서버의 결과가 엉뚱한 곳으로 가거나 아예 오지 않는다. 그런데 요청은 202로 접수돼 + * 겉으로는 정상처럼 보이고, 실패는 몇 분 뒤 시간 초과로만 드러난다. 블루/그린 배포라 새 서버가 뜨지 않으면 이전 서버가 + * 그대로 남으므로, 잘못된 설정은 배포 시점에 막는 편이 낫다. 연결까지 시험하지는 않는다 — 외부 서버가 잠깐 멈췄다고 + * 기동을 막으면 안 된다. + */ +@Component +class RequiredSettingsCheck { + + // 이 프로필이 켜져 있으면 운영으로 본다. 개발용 주소(ngrok, localhost)를 콜백 주소로 받지 않는다. + static final String PRODUCTION_PROFILE = "prod"; + + RequiredSettingsCheck(@Value("${app.callback-base-url:}") String callbackBaseUrl, + @Value("${app.internal-auth.required:false}") boolean internalAuthRequired, + @Value("${app.internal-auth.token:}") String internalAuthToken, + Environment environment) { + verifyCallbackBaseUrl(callbackBaseUrl, environment.matchesProfiles(PRODUCTION_PROFILE)); + verifyInternalAuth(internalAuthRequired, internalAuthToken); + } + + /** + * 분석·채팅 서버가 결과를 보낼 이 서버의 외부 주소. 뒤에 콜백 경로를 바로 붙이므로 쿼리·조각·끝 슬래시가 없어야 한다. + * 끝 슬래시가 있으면 "//api/..."가 되어 시큐리티 방화벽이 콜백을 모두 400으로 막는다. + */ + static void verifyCallbackBaseUrl(String value, boolean production) { + if (value == null || value.isBlank()) { + throw new IllegalStateException("CALLBACK_BASE_URL(app.callback-base-url)이 비어 있습니다. " + + "분석·채팅 서버가 결과를 보낼 이 서버의 외부 주소를 넣어 주세요. 예: https://api.repit.example"); + } + URI uri; + try { + uri = new URI(value.trim()); + } catch (URISyntaxException e) { + throw invalid(value, "주소 형식이 아닙니다"); + } + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + if (!scheme.equals("http") && !scheme.equals("https")) { + throw invalid(value, "http 또는 https 주소여야 합니다"); + } + if (uri.getHost() == null || uri.getUserInfo() != null || uri.getRawQuery() != null + || uri.getRawFragment() != null || value.trim().endsWith("/")) { + throw invalid(value, "호스트가 있고 사용자 정보·쿼리·조각·끝 슬래시가 없어야 합니다"); + } + if (!production) { + return; + } + String host = uri.getHost().toLowerCase(Locale.ROOT); + if (!scheme.equals("https")) { + throw invalid(value, "운영에서는 https 주소여야 합니다"); + } + if (host.contains("ngrok") || isLoopback(host)) { + throw invalid(value, "운영에서는 ngrok이나 localhost 같은 개발용 주소를 쓸 수 없습니다"); + } + } + + /** + * 내부 인증을 필수로 켰는데 인증값이 비어 있으면 막는다. 비어 있으면 콜백을 인증 없이 받는 지금 동작으로 조용히 + * 돌아가, 켰다고 믿는 동안 아무나 콜백을 보낼 수 있다. 인증값은 오류 메시지에 싣지 않는다. + */ + static void verifyInternalAuth(boolean required, String token) { + if (required && (token == null || token.isBlank())) { + throw new IllegalStateException("INTERNAL_AUTH_REQUIRED가 true인데 INTERNAL_AUTH_TOKEN(app.internal-auth.token)이 " + + "비어 있습니다. 분석·채팅 서버와 같은 값을 넣거나, 아직 헤더를 보내지 않는다면 INTERNAL_AUTH_REQUIRED를 끄세요."); + } + } + + private static boolean isLoopback(String host) { + return host.equals("localhost") || host.endsWith(".localhost") || host.startsWith("127.") + || host.equals("[::1]") || host.equals("0.0.0.0"); + } + + private static IllegalStateException invalid(String value, String reason) { + return new IllegalStateException("CALLBACK_BASE_URL(app.callback-base-url)이 올바르지 않습니다: " + value + + " — " + reason + "."); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 95fb880d..9054194b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -88,7 +88,13 @@ app: # 비어 있으면 콜백을 인증 없이 받는다. 부르는 쪽이 헤더를 보내도록 배포한 뒤에 채워 강제한다. # 영상 분석 콜백은 예외다 -- 비어 있으면 401로 받지 않는다(CallbackPaths.ALWAYS_AUTHENTICATED). token: ${INTERNAL_AUTH_TOKEN:} - callback-base-url: ${CALLBACK_BASE_URL:https://wildcat-startle-rope.ngrok-free.dev} + # true면 token이 비어 있을 때 서버가 뜨지 않는다. 비어 있는 채로 인증 없이 받는 상태로 조용히 돌아가는 것을 막는다. + # 분석·채팅 서버가 헤더를 보내도록 배포를 마친 뒤 마지막에 켠다. + required: ${INTERNAL_AUTH_REQUIRED:false} + # 분석·채팅 서버가 결과를 보낼 이 서버의 외부 주소. 기본값이 없다 -- 비었거나 형식이 틀리면 서버가 뜨지 않는다. + # 끝에 슬래시를 붙이지 않는다. prod 프로필에서는 https만 받고 ngrok·localhost 같은 개발용 주소를 받지 않는다. + # 로컬 개발은 .env에 넣는다(예: ngrok 주소). 테스트는 src/test/resources/config/application.yaml이 채운다. + callback-base-url: ${CALLBACK_BASE_URL:} feedback: # 이 시간을 넘도록 분석 서버 콜백이 오지 않으면 실패로 처리하고 재요청을 허용한다. pending-timeout: 10m diff --git a/src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java b/src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java new file mode 100644 index 00000000..6e4ccb7c --- /dev/null +++ b/src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java @@ -0,0 +1,85 @@ +package repit.repit_api_server.global.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** 빠지면 서버가 제대로 일하지 못하는 설정을 기동 시점에 막는지 본다(N-6). */ +class RequiredSettingsCheckTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withUserConfiguration(RequiredSettingsCheck.class); + + @Test + void 콜백_주소가_없거나_비면_기동하지_않는다() { + runner.run(context -> assertThat(context).hasFailed() + .getFailure().rootCause().hasMessageContaining("CALLBACK_BASE_URL")); + runner.withPropertyValues("app.callback-base-url= ") + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void 형식이_틀린_콜백_주소는_받지_않는다() { + for (String url : List.of("api.repit.example", "ftp://api.repit.example", "https://", + "https://user:pw@api.repit.example", "https://api.repit.example?env=dev", + "https://api.repit.example#top", "https://api.repit.example/", "https://api repit.example")) { + assertThatThrownBy(() -> RequiredSettingsCheck.verifyCallbackBaseUrl(url, false)) + .as(url) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("CALLBACK_BASE_URL"); + } + } + + /** 로컬은 ngrok이나 localhost로 개발한다. 운영 프로필이 아닐 때는 받는다. */ + @Test + void 운영이_아니면_개발용_주소를_받는다() { + for (String url : List.of("https://wildcat-startle-rope.ngrok-free.dev", "http://localhost:8080", + "http://127.0.0.1:8080", "https://api.repit.example/base")) { + assertThatCode(() -> RequiredSettingsCheck.verifyCallbackBaseUrl(url, false)).as(url) + .doesNotThrowAnyException(); + } + } + + @Test + void 운영에서는_https가_아니거나_개발용_주소면_받지_않는다() { + for (String url : List.of("https://wildcat-startle-rope.ngrok-free.dev", "https://abc.ngrok.io", + "https://localhost", "https://api.localhost", "https://127.0.0.1", "https://[::1]", + "http://api.repit.example")) { + assertThatThrownBy(() -> RequiredSettingsCheck.verifyCallbackBaseUrl(url, true)).as(url) + .isInstanceOf(IllegalStateException.class); + } + assertThatCode(() -> RequiredSettingsCheck.verifyCallbackBaseUrl("https://api.repit.example", true)) + .doesNotThrowAnyException(); + } + + @Test + void 운영_프로필에서_ngrok_주소면_기동하지_않는다() { + runner.withPropertyValues("spring.profiles.active=prod", + "app.callback-base-url=https://wildcat-startle-rope.ngrok-free.dev") + .run(context -> assertThat(context).hasFailed()); + runner.withPropertyValues("spring.profiles.active=prod", "app.callback-base-url=https://api.repit.example") + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void 내부_인증을_필수로_켰는데_인증값이_없으면_기동하지_않는다() { + runner.withPropertyValues("app.callback-base-url=https://api.repit.example", + "app.internal-auth.required=true", "app.internal-auth.token= ") + .run(context -> assertThat(context).hasFailed() + .getFailure().rootCause().hasMessageContaining("INTERNAL_AUTH_TOKEN")); + } + + @Test + void 내부_인증이_필수가_아니면_인증값_없이도_기동한다() { + runner.withPropertyValues("app.callback-base-url=https://api.repit.example") + .run(context -> assertThat(context).hasNotFailed()); + runner.withPropertyValues("app.callback-base-url=https://api.repit.example", + "app.internal-auth.required=true", "app.internal-auth.token=secret-token-value") + .run(context -> assertThat(context).hasNotFailed()); + } +} diff --git a/src/test/resources/config/application.yaml b/src/test/resources/config/application.yaml new file mode 100644 index 00000000..1420a3db --- /dev/null +++ b/src/test/resources/config/application.yaml @@ -0,0 +1,4 @@ +# 테스트 전용 설정. classpath:/config/ 파일이라 기본 application.yaml 위에 겹쳐 읽힌다. +app: + # 테스트는 외부 서버를 부르지 않는다. 기본값이 없어진 콜백 주소를 명시적으로 채워 기동 검사를 통과시킨다. + callback-base-url: https://api.repit.test From d9946f210b39609a87e6eb0735a5bf89a563ac80 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Thu, 15 Oct 2026 15:03:06 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EC=BD=9C=EB=B0=B1=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EA=B2=80=EC=A6=9D=20=EC=98=A4=EB=A5=98=EC=97=90=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=20=EC=9B=90=EB=AC=B8=EC=9D=84=20=EC=8B=A3?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 정보·쿼리가 붙은 주소를 막는 검사인데 오류 메시지에 원문을 넣어, 그 안의 자격 증명이 기동 실패 로그에 그대로 남을 수 있었다(PR #100 리뷰). 검증 항목과 호스트만 싣는다. Co-Authored-By: Claude Opus 5.5 --- .../global/config/RequiredSettingsCheck.java | 20 +++++++++++-------- .../config/RequiredSettingsCheckTest.java | 11 ++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java b/src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java index b6629b4a..11033e68 100644 --- a/src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java +++ b/src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java @@ -43,25 +43,25 @@ static void verifyCallbackBaseUrl(String value, boolean production) { try { uri = new URI(value.trim()); } catch (URISyntaxException e) { - throw invalid(value, "주소 형식이 아닙니다"); + throw invalid(null, "주소 형식이 아닙니다"); } String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); if (!scheme.equals("http") && !scheme.equals("https")) { - throw invalid(value, "http 또는 https 주소여야 합니다"); + throw invalid(uri.getHost(), "http 또는 https 주소여야 합니다"); } if (uri.getHost() == null || uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null || value.trim().endsWith("/")) { - throw invalid(value, "호스트가 있고 사용자 정보·쿼리·조각·끝 슬래시가 없어야 합니다"); + throw invalid(uri.getHost(), "호스트가 있고 사용자 정보·쿼리·조각·끝 슬래시가 없어야 합니다"); } if (!production) { return; } String host = uri.getHost().toLowerCase(Locale.ROOT); if (!scheme.equals("https")) { - throw invalid(value, "운영에서는 https 주소여야 합니다"); + throw invalid(host, "운영에서는 https 주소여야 합니다"); } if (host.contains("ngrok") || isLoopback(host)) { - throw invalid(value, "운영에서는 ngrok이나 localhost 같은 개발용 주소를 쓸 수 없습니다"); + throw invalid(host, "운영에서는 ngrok이나 localhost 같은 개발용 주소를 쓸 수 없습니다"); } } @@ -81,8 +81,12 @@ private static boolean isLoopback(String host) { || host.equals("[::1]") || host.equals("0.0.0.0"); } - private static IllegalStateException invalid(String value, String reason) { - return new IllegalStateException("CALLBACK_BASE_URL(app.callback-base-url)이 올바르지 않습니다: " + value - + " — " + reason + "."); + /** + * 주소 원문은 싣지 않는다. 막는 이유가 사용자 정보·쿼리이기도 해서, 원문을 남기면 그 안의 자격 증명이 기동 실패 로그에 + * 그대로 남는다. 어느 설정인지 짚을 수 있도록 검증 항목과 호스트만 싣는다. + */ + private static IllegalStateException invalid(String host, String reason) { + return new IllegalStateException("CALLBACK_BASE_URL(app.callback-base-url)이 올바르지 않습니다 — " + reason + + ". 호스트: " + (host == null ? "(읽을 수 없음)" : host)); } } diff --git a/src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java b/src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java index 6e4ccb7c..c60d3f38 100644 --- a/src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java +++ b/src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java @@ -82,4 +82,15 @@ class RequiredSettingsCheckTest { "app.internal-auth.required=true", "app.internal-auth.token=secret-token-value") .run(context -> assertThat(context).hasNotFailed()); } + + /** 사용자 정보·쿼리를 막는 검사라, 원문을 오류에 실으면 그 안의 자격 증명이 기동 실패 로그에 남는다. */ + @Test + void 오류_메시지에_주소_원문을_싣지_않는다() { + assertThatThrownBy(() -> RequiredSettingsCheck.verifyCallbackBaseUrl( + "https://user:secret-password@api.repit.example?token=query-secret", false)) + .isInstanceOf(IllegalStateException.class) + .message() + .doesNotContain("secret-password", "query-secret", "user:") + .contains("api.repit.example"); + } } From 07e6af97196afbf822951525a97d2fcb2a6cf786 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Thu, 15 Oct 2026 15:13:22 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=9A=B4=EC=98=81=20=EC=BD=9C?= =?UTF-8?q?=EB=B0=B1=20=EC=A3=BC=EC=86=8C=EB=8A=94=20=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=83=9D=EB=9E=B5=ED=95=98=EA=B1=B0=EB=82=98=20443?= =?UTF-8?q?=EB=A7=8C=20=EB=B0=9B=EA=B2=8C=20=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 음성·영상 분석 요청의 CallbackUrls.require()는 포트를 생략했거나 443인 주소만 받는데, 기동 검사는 https://api.example.com:8443을 통과시켜 실제 분석 요청이 422로 실패할 수 있었다(PR #100 리뷰). prod 프로필 검사에 같은 포트 제한을 넣는다. Co-Authored-By: Claude Opus 5.5 --- .../global/config/RequiredSettingsCheck.java | 4 ++++ .../global/config/RequiredSettingsCheckTest.java | 11 +++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java b/src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java index 11033e68..64040eb1 100644 --- a/src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java +++ b/src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java @@ -60,6 +60,10 @@ static void verifyCallbackBaseUrl(String value, boolean production) { if (!scheme.equals("https")) { throw invalid(host, "운영에서는 https 주소여야 합니다"); } + // 분석 서버는 기본 포트가 아닌 콜백 주소를 422로 거부한다(CallbackUrls.require와 같은 제한). + if (uri.getPort() != -1 && uri.getPort() != 443) { + throw invalid(host, "운영에서는 포트를 생략하거나 443이어야 합니다"); + } if (host.contains("ngrok") || isLoopback(host)) { throw invalid(host, "운영에서는 ngrok이나 localhost 같은 개발용 주소를 쓸 수 없습니다"); } diff --git a/src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java b/src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java index c60d3f38..7cc6e379 100644 --- a/src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java +++ b/src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java @@ -46,15 +46,18 @@ class RequiredSettingsCheckTest { } @Test - void 운영에서는_https가_아니거나_개발용_주소면_받지_않는다() { + void 운영에서는_https_기본_포트가_아니거나_개발용_주소면_받지_않는다() { for (String url : List.of("https://wildcat-startle-rope.ngrok-free.dev", "https://abc.ngrok.io", "https://localhost", "https://api.localhost", "https://127.0.0.1", "https://[::1]", - "http://api.repit.example")) { + "http://api.repit.example", "https://api.repit.example:8443")) { assertThatThrownBy(() -> RequiredSettingsCheck.verifyCallbackBaseUrl(url, true)).as(url) .isInstanceOf(IllegalStateException.class); } - assertThatCode(() -> RequiredSettingsCheck.verifyCallbackBaseUrl("https://api.repit.example", true)) - .doesNotThrowAnyException(); + // 분석 서버는 포트를 생략했거나 443인 주소만 받는다. + for (String url : List.of("https://api.repit.example", "https://api.repit.example:443")) { + assertThatCode(() -> RequiredSettingsCheck.verifyCallbackBaseUrl(url, true)).as(url) + .doesNotThrowAnyException(); + } } @Test