From e9c8a3da77b629bdbcf7c33abf9cdc8eca5700bf Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 19:12:41 +0900 Subject: [PATCH 01/42] =?UTF-8?q?feat(app.env):=20`get()`=20-=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=EB=A5=BC=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=EC=9C=84=ED=95=9C=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=EB=A6=AC=ED=8B=B0=20=ED=95=A8=EC=88=98=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/env.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 app/app/env.py diff --git a/app/app/env.py b/app/app/env.py new file mode 100644 index 0000000..0e86c99 --- /dev/null +++ b/app/app/env.py @@ -0,0 +1,38 @@ +""" +환경 변수와 관련된 기능 혹은 유틸리티 모음. +""" + +import os +from typing import Optional + + +def get(key: str, default: Optional[str] = None, required: bool = False, strip: bool = True, blank: bool = False) -> Optional[str]: + """환경 변수 값을 가져옵니다. + + Args: + key: 환경 변수 이름 + default: 기본값 (환경 변수가 없을 때 반환) + required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생) + strip: 값의 앞뒤 공백 제거 여부 (기본값: True) + blank: 빈 문자열 허용 여부 (False일 때 빈 문자열은 None으로 간주) + + Returns: + 환경 변수 값 또는 기본값 + + Raises: + ValueError: required=True이고 환경 변수가 설정되지 않은 경우 + """ + string_value = os.getenv(key, default) + + if strip and (string_value is not None): + string_value = string_value.strip() + + if not blank and (string_value == ''): + string_value = None + + if required and (string_value is None): + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return string_value From 98664192c9ec8f9e9b79f349a014312d262bc42a Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 19:17:50 +0900 Subject: [PATCH 02/42] =?UTF-8?q?test(app.env):=20`get()`=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/test_env.py | 78 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 app/app/test_env.py diff --git a/app/app/test_env.py b/app/app/test_env.py new file mode 100644 index 0000000..f97361e --- /dev/null +++ b/app/app/test_env.py @@ -0,0 +1,78 @@ +import os +from unittest.mock import patch + +from django.test import TestCase + +from app import env + + +class GetTest(TestCase): + @patch.dict(os.environ, {'TEST_VAR': 'test_value'}) + def test_returns_env_value(self): + """환경 변수가 설정되어 있을 때 해당 값을 그대로 반환하는지 확인한다.""" + self.assertEqual(env.get('TEST_VAR'), 'test_value') + + @patch.dict(os.environ, {}, clear=True) + def test_default_parameter(self): + """환경 변수가 설정되지 않았을 때 default 파라미터 값을 반환하는지 확인한다. + + default가 없으면 None을 반환한다. + """ + self.assertIsNone(env.get('MISSING_VAR')) + self.assertEqual(env.get('MISSING_VAR', default='default'), 'default') + + @patch.dict(os.environ, {}, clear=True) + def test_required_parameter(self): + """필수 환경 변수가 설정되지 않았을 때 ValueError를 발생시키는지 확인한다. + + required=True로 설정하면 환경 변수가 없을 때 예외를 발생시켜야 한다. + """ + with self.assertRaises(ValueError): + env.get('REQUIRED_VAR', required=True) + + @patch.dict(os.environ, {'STRIP_VAR': ' value '}) + def test_strip_parameter(self): + """환경 변수 값의 앞뒤 공백을 제거하는지 확인한다. + + 기본적으로 strip=True이며, strip=False로 설정하면 공백을 유지한다. + """ + self.assertEqual(env.get('STRIP_VAR'), 'value') + self.assertEqual(env.get('STRIP_VAR', strip=False), ' value ') + + @patch.dict(os.environ, {}, clear=True) + def test_strip_applies_to_default(self): + """환경 변수가 없어서 default 값을 사용할 때도 strip이 적용되는지 확인한다.""" + self.assertEqual(env.get('MISSING_VAR', default=' default '), + 'default') + + @patch.dict(os.environ, {'EMPTY_VAR': ''}) + def test_blank_parameter(self): + """빈 문자열 환경 변수를 None으로 처리하는지 확인한다. + + 기본적으로 blank=False이므로 빈 문자열은 None으로 변환된다. + blank=True로 설정하면 빈 문자열을 그대로 반환한다. + required와 함께 사용할 때도 blank=True면 빈 문자열을 허용한다. + """ + self.assertIsNone(env.get('EMPTY_VAR')) + self.assertEqual(env.get('EMPTY_VAR', blank=True), '') + with self.assertRaises(ValueError): + env.get('EMPTY_VAR', required=True) + self.assertEqual(env.get('EMPTY_VAR', required=True, blank=True), '') + + @patch.dict(os.environ, {'WHITESPACE_VAR': ' '}) + def test_blank_with_whitespace(self): + """공백만 있는 환경 변수가 strip 후 빈 문자열로 처리되는지 확인한다. + + strip이 먼저 적용되어 공백이 제거되고, 그 결과 빈 문자열이 되면 blank 처리 로직이 적용된다. + """ + self.assertIsNone(env.get('WHITESPACE_VAR')) + self.assertEqual(env.get('WHITESPACE_VAR', blank=True), '') + + @patch.dict(os.environ, {}, clear=True) + def test_blank_applies_to_default(self): + """환경 변수가 없어서 default 값을 사용할 때도 blank 처리가 적용되는지 확인한다. + + default로 빈 문자열을 제공하면 blank=False일 때 None으로 변환된다. + """ + self.assertIsNone(env.get('MISSING_VAR', default='')) + self.assertEqual(env.get('MISSING_VAR', default='', blank=True), '') From 8d2a53fdd4e1a762136b92f5b084841cbe8db1f4 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 19:13:18 +0900 Subject: [PATCH 03/42] chore: install dotenv (`python-dotenv`) --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index a2901f6..bec54d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ django djangorestframework pytest-django +python-dotenv From 19c0bd6c408e7ed95a6ceeec1a5f0220d0b2de63 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 19:13:45 +0900 Subject: [PATCH 04/42] =?UTF-8?q?feat(app.env):=20.env=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=EB=A5=BC=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=AC=20=EC=88=98=20=EC=9E=88=EB=8A=94=20`load()`?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/env.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/app/env.py b/app/app/env.py index 0e86c99..89b2fb1 100644 --- a/app/app/env.py +++ b/app/app/env.py @@ -3,8 +3,22 @@ """ import os +from pathlib import Path from typing import Optional +import dotenv + + +def load(dotenv_path: Optional[Path] = None): + """.env 파일이 있다면 .env 파일에서 환경 변수를 로드합니다. + + Args: + dotenv_path: .env 파일 경로 (기본값: None) + """ + # Load .env file if it exists + if (dotenv_path is not None) and dotenv_path.exists(): + dotenv.load_dotenv(dotenv_path=dotenv_path, override=True) + def get(key: str, default: Optional[str] = None, required: bool = False, strip: bool = True, blank: bool = False) -> Optional[str]: """환경 변수 값을 가져옵니다. From 902b3fe63d1046e02ce02c9efba9636533382786 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sun, 30 Nov 2025 21:05:14 +0900 Subject: [PATCH 05/42] chore: add sample .env file for development and testing --- app/.env.sample | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 app/.env.sample diff --git a/app/.env.sample b/app/.env.sample new file mode 100644 index 0000000..2e9cee3 --- /dev/null +++ b/app/.env.sample @@ -0,0 +1,9 @@ +# Sample .env file for development and testing purposes + +# Set DEBUG to true for development, false for production +DEBUG=false + +# Django secret key for cryptographic signing +# NOTE: The path below uses Unix-style separators ('/'). On Windows, adjust the path accordingly (e.g., '..\.secrets\secret_key.txt'). +SECRET_KEY_FILE=../.secrets/secret_key.txt +# SECRET_KEY='' From a8b1cb64138323d0a0ff93bc928989aa72db7d6f Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sun, 30 Nov 2025 02:29:46 +0900 Subject: [PATCH 06/42] =?UTF-8?q?feat(app.settings):=20.env=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=EB=A5=BC=20lazy=20load=20=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context (by Copilot of GitHub) #4 The load_dotenv() call at module level may cause issues when settings.py imports this module. Django settings files are imported very early in the application lifecycle (in manage.py, wsgi.py, asgi.py), and calling load_dotenv() at import time means it will be executed before Django is fully initialized. This could potentially cause timing issues with environment variable loading. Consider moving this call to a function or using lazy loading, or document that .env files should be loaded before Django starts (e.g., in manage.py before importing settings). --- app/app/settings.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/app/settings.py b/app/app/settings.py index e5a8992..77ff996 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -12,10 +12,15 @@ from pathlib import Path +from app import env + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +env.load(dotenv_path=BASE_DIR / '.env') + + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ From 86399eb403a2317e509bfc47079dd10e076ca9ac Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 19:23:45 +0900 Subject: [PATCH 07/42] =?UTF-8?q?feat(app.settings):=20`SECRET=5FKEY`=20?= =?UTF-8?q?=EB=A5=BC=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/app/settings.py b/app/app/settings.py index 77ff996..e7905a6 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -25,7 +25,7 @@ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-^&757(fcj_5idk9lfj5%mc#5ozvfoigka&#z73lxc_(!4j_e$o' +SECRET_KEY = env.get("SECRET_KEY", required=True) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True From 0a9a9dafe73f03c522977c0688cfeda408ca64e5 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 19:29:21 +0900 Subject: [PATCH 08/42] =?UTF-8?q?feat(app.env):=20`get=5Fbool()`=20-=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=EB=A1=9C=20=EB=B6=80?= =?UTF-8?q?=ED=84=B0=20truthy,=20falsy=20=ED=95=9C=20=EA=B0=92=EC=9D=84=20?= =?UTF-8?q?bool=20=ED=83=80=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EB=8A=94=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/env.py | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/app/app/env.py b/app/app/env.py index 89b2fb1..ade7238 100644 --- a/app/app/env.py +++ b/app/app/env.py @@ -8,6 +8,10 @@ import dotenv +# Define truthy and falsy values +_TRUTHY_VALUES = ('true', '1', 't', 'y', 'yes', 'on') +_FALSY_VALUES = ('false', '0', 'f', 'n', 'no', 'off') + def load(dotenv_path: Optional[Path] = None): """.env 파일이 있다면 .env 파일에서 환경 변수를 로드합니다. @@ -50,3 +54,89 @@ def get(key: str, default: Optional[str] = None, required: bool = False, strip: ) return string_value + + +def get_bool(key: str, default: Optional[bool] = None, strip: bool = True, required: bool = False) -> Optional[bool]: + """환경 변수를 불린 값으로 파싱합니다. + + Args: + key: 환경 변수 이름 + default: 기본값 (환경 변수가 없을 때 반환) + strip: 앞뒤 공백 제거 여부 (기본값: True) + required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생) + + Returns: + 불린 값 또는 기본값 + + Raises: + ValueError: 잘못된 불린 값이거나 required=True이고 환경 변수가 설정되지 않은 경우 + """ + raw_value = os.getenv(key) + boolean_value = default + + if raw_value is not None: + if _is_truthy(raw_value, strip=strip): + boolean_value = True + elif _is_falsy(raw_value, strip=strip): + boolean_value = False + else: + raise ValueError( + f'Environment variable "{key}" has invalid boolean value.' + ) + + if required and (boolean_value is None): + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return boolean_value + + +def _is_truthy(value: Optional[str], strip: bool = True) -> bool: + """문자열이 참 값인지 확인합니다. + + Args: + value: 확인할 문자열 + strip: 앞뒤 공백 제거 여부 (기본값: True) + + Returns: + 참 값 여부 (true, 1, t, y, yes, on 중 하나인 경우 True) + """ + if strip and (value is not None): + value = value.strip() + + if value is None: + return False + + value = value.lower() + + for item in _TRUTHY_VALUES: + if item == value: + return True + + return False + + +def _is_falsy(value: Optional[str], strip: bool = True) -> bool: + """문자열이 거짓 값인지 확인합니다. + + Args: + value: 확인할 문자열 + strip: 앞뒤 공백 제거 여부 (기본값: True) + + Returns: + 거짓 값 여부 (false, 0, f, n, no, off 중 하나인 경우 True) + """ + if strip and (value is not None): + value = value.strip() + + if value is None: + return False + + value = value.lower() + + for item in _FALSY_VALUES: + if item == value: + return True + + return False From f62251b42babd0cc44c94e973c293821992eb1e2 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 15:34:46 +0900 Subject: [PATCH 09/42] refactor(app.env): simplify truthy and falsy value checks --- app/app/env.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/app/app/env.py b/app/app/env.py index ade7238..b3e2164 100644 --- a/app/app/env.py +++ b/app/app/env.py @@ -108,13 +108,7 @@ def _is_truthy(value: Optional[str], strip: bool = True) -> bool: if value is None: return False - value = value.lower() - - for item in _TRUTHY_VALUES: - if item == value: - return True - - return False + return value.lower() in _TRUTHY_VALUES def _is_falsy(value: Optional[str], strip: bool = True) -> bool: @@ -133,10 +127,4 @@ def _is_falsy(value: Optional[str], strip: bool = True) -> bool: if value is None: return False - value = value.lower() - - for item in _FALSY_VALUES: - if item == value: - return True - - return False + return value.lower() in _FALSY_VALUES From af50d2ab1bd74f0f37cb7fa2974515a3c2800d6e Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 19:31:21 +0900 Subject: [PATCH 10/42] =?UTF-8?q?test(app.env):=20`get=5Fbool()`=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/test_env.py | 118 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/app/app/test_env.py b/app/app/test_env.py index f97361e..426f75d 100644 --- a/app/app/test_env.py +++ b/app/app/test_env.py @@ -76,3 +76,121 @@ def test_blank_applies_to_default(self): """ self.assertIsNone(env.get('MISSING_VAR', default='')) self.assertEqual(env.get('MISSING_VAR', default='', blank=True), '') + + +class GetBoolTest(TestCase): + def test_parses_truthy_values(self): + """다양한 참 값 문자열을 True로 파싱하는지 확인한다. + + 대소문자 구분 없이 'true', '1', 't', 'y', 'yes', 'on'을 True로 인식해야 한다. + """ + for value in ('true', '1', 't', 'y', 'yes', 'on', 'TRUE', 'True', 'YES'): + with self.subTest(value=value): + with patch.dict(os.environ, {'BOOL_VAR': value}): + self.assertTrue(env.get_bool('BOOL_VAR')) + + def test_parses_falsy_values(self): + """다양한 거짓 값 문자열을 False로 파싱하는지 확인한다. + + 대소문자 구분 없이 'false', '0', 'f', 'n', 'no', 'off'를 False로 인식해야 한다. + """ + for value in ('false', '0', 'f', 'n', 'no', 'off', 'FALSE', 'False', 'NO'): + with self.subTest(value=value): + with patch.dict(os.environ, {'BOOL_VAR': value}): + self.assertFalse(env.get_bool('BOOL_VAR')) + + @patch.dict(os.environ, {}, clear=True) + def test_default_parameter(self): + """환경 변수가 설정되지 않았을 때 default 파라미터 값을 반환하는지 확인한다. + + default가 없으면 None을 반환한다. + """ + self.assertIsNone(env.get_bool('MISSING_VAR')) + self.assertTrue(env.get_bool('MISSING_VAR', default=True)) + self.assertFalse(env.get_bool('MISSING_VAR', default=False)) + + @patch.dict(os.environ, {}, clear=True) + def test_required_parameter(self): + """필수 환경 변수가 설정되지 않았을 때 ValueError를 발생시키는지 확인한다.""" + with self.assertRaises(ValueError): + env.get_bool('REQUIRED_VAR', required=True) + + @patch.dict(os.environ, {'BOOL_VAR': 'invalid'}) + def test_raises_for_invalid_value(self): + """불린으로 파싱할 수 없는 값에 대해 ValueError를 발생시키는지 확인한다. + + 보안상 잘못된 값을 조용히 무시하지 않고 명시적으로 에러를 발생시켜야 한다. + """ + with self.assertRaises(ValueError): + env.get_bool('BOOL_VAR') + + @patch.dict(os.environ, {'BOOL_VAR': ''}) + def test_raises_for_empty_string(self): + """빈 문자열에 대해 ValueError를 발생시키는지 확인한다. + + 빈 문자열을 False로 해석하면 보안 문제가 발생할 수 있으므로 에러를 발생시켜야 한다. + """ + with self.assertRaises(ValueError): + env.get_bool('BOOL_VAR') + + @patch.dict(os.environ, {'BOOL_VAR': ' true '}) + def test_strip_parameter(self): + """공백이 포함된 불린 값에서 strip 파라미터 동작을 확인한다. + + 기본적으로 strip=True이므로 앞뒤 공백을 제거하고 파싱한다. + strip=False로 설정하면 공백을 유지하여 파싱에 실패한다. + """ + self.assertTrue(env.get_bool('BOOL_VAR')) + with self.assertRaises(ValueError): + env.get_bool('BOOL_VAR', strip=False) + + @patch.dict(os.environ, {'BOOL_VAR': ' '}) + def test_raises_for_whitespace_only(self): + """공백만 있는 값에 대해 ValueError를 발생시키는지 확인한다. + + 공백 제거 후 빈 문자열이 되면 에러를 발생시켜야 한다. + """ + with self.assertRaises(ValueError): + env.get_bool('BOOL_VAR') + + @patch.dict(os.environ, {}, clear=True) + def test_required_with_default(self): + """required와 default를 함께 사용할 때 default 값을 반환하는지 확인한다. + + 환경 변수가 없어도 default가 있으면 required=True여도 에러가 발생하지 않는다. + """ + self.assertTrue( + env.get_bool('MISSING_VAR', default=True, required=True) + ) + self.assertFalse( + env.get_bool('MISSING_VAR', default=False, required=True) + ) + + @patch.dict(os.environ, {'BOOL_VAR': '0x1'}) + def test_raises_for_hex_value(self): + """유사 형식의 값에 대해 ValueError를 발생시키는지 확인한다. + + 보안상 '0x1', '0b1' 같은 유사 형식을 허용하지 않아야 한다. + """ + with self.assertRaises(ValueError): + env.get_bool('BOOL_VAR') + + @patch.dict(os.environ, {'BOOL_VAR': 'True1'}) + def test_raises_for_partial_match(self): + """부분 일치 값에 대해 ValueError를 발생시키는지 확인한다. + + 보안상 'True1', '1true' 같은 부분 일치를 허용하지 않아야 한다. + """ + with self.assertRaises(ValueError): + env.get_bool('BOOL_VAR') + + def test_raises_for_null_like_strings(self): + """null 유사 문자열에 대해 ValueError를 발생시키는지 확인한다. + + 보안상 'null', 'none', 'undefined' 같은 값을 False로 해석하지 않아야 한다. + """ + for value in ('null', 'none', 'undefined', 'NULL', 'None', 'UNDEFINED'): + with self.subTest(value=value): + with patch.dict(os.environ, {'BOOL_VAR': value}): + with self.assertRaises(ValueError): + env.get_bool('BOOL_VAR') From 98648fa2fcc71ee73b9696c70893e3db9f267b24 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 19:32:30 +0900 Subject: [PATCH 11/42] =?UTF-8?q?feat(app.settings):=20`DEBUG`=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/app/settings.py b/app/app/settings.py index e7905a6..9efc0ca 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -28,7 +28,7 @@ SECRET_KEY = env.get("SECRET_KEY", required=True) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = env.get_bool('DEBUG', default=False) ALLOWED_HOSTS = [] From 1eb40ef7ba52fda9d17e5e4ffe31eba46c447732 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 19:37:15 +0900 Subject: [PATCH 12/42] =?UTF-8?q?feat(app.env):=20`get=5Fjson()`=20-=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=EC=9D=98=20JSON=20?= =?UTF-8?q?=EA=B0=92=EC=9D=84=20=ED=8C=8C=EC=8B=B1=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=ED=95=98=EB=8A=94=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/env.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/app/app/env.py b/app/app/env.py index b3e2164..9fa8713 100644 --- a/app/app/env.py +++ b/app/app/env.py @@ -2,9 +2,10 @@ 환경 변수와 관련된 기능 혹은 유틸리티 모음. """ +import json import os from pathlib import Path -from typing import Optional +from typing import Dict, List, Optional, Union import dotenv @@ -12,6 +13,10 @@ _TRUTHY_VALUES = ('true', '1', 't', 'y', 'yes', 'on') _FALSY_VALUES = ('false', '0', 'f', 'n', 'no', 'off') +# Define JSON type alias +_JSON_SCALAR = Union[str, int, float, bool, None] +_JSON = Union[Dict[str, '_JSON'], List['_JSON'], _JSON_SCALAR] + def load(dotenv_path: Optional[Path] = None): """.env 파일이 있다면 .env 파일에서 환경 변수를 로드합니다. @@ -92,6 +97,39 @@ def get_bool(key: str, default: Optional[bool] = None, strip: bool = True, requi return boolean_value +def get_json(key: str, default: Optional[_JSON] = None, required: bool = False) -> Optional[_JSON]: + """환경 변수 값을 JSON으로 파싱하여 반환하거나 기본값을 반환합니다. + + Args: + key: 환경 변수 이름 + default: 기본값 (환경 변수가 없을 때 반환) + required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생) + + Returns: + JSON으로 파싱된 값 또는 기본값 + + Raises: + ValueError: 환경 변수 값이 올바른 JSON이 아니거나 required=True이고 환경 변수가 설정되지 않은 경우 + """ + raw_value = os.getenv(key) + json_value = default + + if raw_value is not None: + try: + json_value = json.loads(raw_value) + except ValueError as e: + raise ValueError( + f'Environment variable "{key}" has invalid JSON value.' + ) from e + + if required and (json_value is None): + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return json_value + + def _is_truthy(value: Optional[str], strip: bool = True) -> bool: """문자열이 참 값인지 확인합니다. From 7557506e1b5eed6b93788d8b2d68755c046498e6 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 19:38:53 +0900 Subject: [PATCH 13/42] =?UTF-8?q?test(app.env):=20`env.get=5Fjson()`=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/test_env.py | 95 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/app/app/test_env.py b/app/app/test_env.py index 426f75d..b0ad6e6 100644 --- a/app/app/test_env.py +++ b/app/app/test_env.py @@ -194,3 +194,98 @@ def test_raises_for_null_like_strings(self): with patch.dict(os.environ, {'BOOL_VAR': value}): with self.assertRaises(ValueError): env.get_bool('BOOL_VAR') + + +class GetJsonTest(TestCase): + @patch.dict(os.environ, {'JSON_VAR': '{"key": "value"}'}) + def test_parses_dict(self): + """JSON 객체 문자열을 딕셔너리로 파싱하는지 확인한다.""" + self.assertEqual(env.get_json('JSON_VAR'), {'key': 'value'}) + + @patch.dict(os.environ, {'JSON_VAR': '[1, 2, 3]'}) + def test_parses_list(self): + """JSON 배열 문자열을 리스트로 파싱하는지 확인한다.""" + self.assertEqual(env.get_json('JSON_VAR'), [1, 2, 3]) + + @patch.dict(os.environ, {'JSON_VAR': '"string"'}) + def test_parses_string(self): + """JSON 문자열을 파싱하는지 확인한다.""" + self.assertEqual(env.get_json('JSON_VAR'), 'string') + + @patch.dict(os.environ, {'JSON_VAR': 'null'}) + def test_parses_null(self): + """JSON null 값을 None으로 파싱하는지 확인한다.""" + self.assertIsNone(env.get_json('JSON_VAR')) + + @patch.dict(os.environ, {'JSON_VAR': 'true'}) + def test_parses_boolean(self): + """JSON 불린 값을 파싱하는지 확인한다.""" + self.assertTrue(env.get_json('JSON_VAR')) + with patch.dict(os.environ, {'JSON_VAR': 'false'}): + self.assertFalse(env.get_json('JSON_VAR')) + + @patch.dict(os.environ, {'JSON_VAR': '123'}) + def test_parses_number(self): + """JSON 숫자 값을 파싱하는지 확인한다.""" + self.assertEqual(env.get_json('JSON_VAR'), 123) + with patch.dict(os.environ, {'JSON_VAR': '123.45'}): + self.assertEqual(env.get_json('JSON_VAR'), 123.45) + + @patch.dict(os.environ, {}, clear=True) + def test_default_parameter(self): + """환경 변수가 설정되지 않았을 때 default 파라미터 값을 반환하는지 확인한다. + + default가 없으면 None을 반환한다. + """ + self.assertIsNone(env.get_json('MISSING_VAR')) + self.assertEqual(env.get_json('MISSING_VAR', default={'default': True}), + {'default': True}) + + @patch.dict(os.environ, {}, clear=True) + def test_required_parameter(self): + """필수 환경 변수가 설정되지 않았을 때 ValueError를 발생시키는지 확인한다.""" + with self.assertRaises(ValueError): + env.get_json('REQUIRED_VAR', required=True) + + @patch.dict(os.environ, {'JSON_VAR': 'invalid json'}) + def test_raises_for_invalid_json(self): + """잘못된 JSON 형식에 대해 ValueError를 발생시키는지 확인한다. + + 보안상 잘못된 JSON을 조용히 무시하지 않고 명시적으로 에러를 발생시켜야 한다. + """ + with self.assertRaises(ValueError): + env.get_json('JSON_VAR') + + @patch.dict(os.environ, {'JSON_VAR': ''}) + def test_raises_for_empty_string(self): + """빈 문자열에 대해 ValueError를 발생시키는지 확인한다. + + 보안상 빈 문자열을 조용히 무시하지 않고 명시적으로 에러를 발생시켜야 한다. + """ + with self.assertRaises(ValueError): + env.get_json('JSON_VAR') + + @patch.dict(os.environ, {}, clear=True) + def test_required_with_default(self): + """required와 default를 함께 사용할 때 default 값을 반환하는지 확인한다. + + 환경 변수가 없어도 default가 있으면 required=True여도 에러가 발생하지 않는다. + """ + self.assertEqual(env.get_json('MISSING_VAR', default={'key': 'value'}, required=True), + {'key': 'value'}) + + @patch.dict(os.environ, {'JSON_VAR': '{"nested": {"key": [1, 2, 3]}}'}) + def test_parses_nested_structure(self): + """중첩된 JSON 구조를 올바르게 파싱하는지 확인한다.""" + self.assertEqual( + env.get_json('JSON_VAR'), + {'nested': {'key': [1, 2, 3]}}, + ) + + @patch.dict(os.environ, {'JSON_VAR': '{"key": null}'}) + def test_parses_null_in_object(self): + """객체 내부의 null 값을 올바르게 파싱하는지 확인한다. + + 환경 변수 자체가 null인 경우와 객체 내부에 null이 있는 경우를 구분해야 한다. + """ + self.assertEqual(env.get_json('JSON_VAR'), {'key': None}) From 3db62499c561435981a863b95d956237f8937062 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 20:36:29 +0900 Subject: [PATCH 14/42] =?UTF-8?q?refactor(app.env):=20JSON=20=EA=B0=92?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=83=80=EC=9E=85=20=ED=9E=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note: 기존 재귀형 타입힌트는 제대로 동작하지 않을 가능성이 있다. Context by Copilot #4: The type annotation for _JSON is incorrect. The forward reference '_JSON' in the Union is a string, but it should reference the actual type alias. This creates a recursive type definition that won't work properly. --- app/app/env.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/app/env.py b/app/app/env.py index 9fa8713..bb17f53 100644 --- a/app/app/env.py +++ b/app/app/env.py @@ -5,7 +5,7 @@ import json import os from pathlib import Path -from typing import Dict, List, Optional, Union +from typing import Any, Optional import dotenv @@ -13,10 +13,6 @@ _TRUTHY_VALUES = ('true', '1', 't', 'y', 'yes', 'on') _FALSY_VALUES = ('false', '0', 'f', 'n', 'no', 'off') -# Define JSON type alias -_JSON_SCALAR = Union[str, int, float, bool, None] -_JSON = Union[Dict[str, '_JSON'], List['_JSON'], _JSON_SCALAR] - def load(dotenv_path: Optional[Path] = None): """.env 파일이 있다면 .env 파일에서 환경 변수를 로드합니다. @@ -97,7 +93,7 @@ def get_bool(key: str, default: Optional[bool] = None, strip: bool = True, requi return boolean_value -def get_json(key: str, default: Optional[_JSON] = None, required: bool = False) -> Optional[_JSON]: +def get_json(key: str, default: Optional[Any] = None, required: bool = False) -> Optional[Any]: """환경 변수 값을 JSON으로 파싱하여 반환하거나 기본값을 반환합니다. Args: From c9d50233e20fed7ec2931633af848d87c7eafbb0 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 19:43:14 +0900 Subject: [PATCH 15/42] =?UTF-8?q?feat(app.settings):=20`ALLOWED=5FHOSTS`?= =?UTF-8?q?=EB=A5=BC=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=80=ED=84=B0=20=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 타입 오류시에는 Django의 ImproperlyConfigured 예외가 발생하므로 따로 검사하는 로직은 추가하지 않겠습니다. --- app/app/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/app/settings.py b/app/app/settings.py index 9efc0ca..2672701 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -30,7 +30,8 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env.get_bool('DEBUG', default=False) -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', + default=['localhost', '127.0.0.1']) # Application definition From 8990a0f353888f7c8489884d79a7940a43eb2587 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 19:53:45 +0900 Subject: [PATCH 16/42] =?UTF-8?q?feat(app.env):=20`get=5Ffile=5Fcontent()`?= =?UTF-8?q?=20-=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=EC=97=90=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C=EB=90=9C=20=ED=8C=8C=EC=9D=BC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B0=92=EC=9D=84=20=EC=9D=BD=EC=96=B4=EC=98=A4?= =?UTF-8?q?=EB=8A=94=20=ED=95=A8=EC=88=98=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/env.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/app/app/env.py b/app/app/env.py index bb17f53..eae7970 100644 --- a/app/app/env.py +++ b/app/app/env.py @@ -5,7 +5,7 @@ import json import os from pathlib import Path -from typing import Any, Optional +from typing import Any, Optional, Union import dotenv @@ -126,6 +126,83 @@ def get_json(key: str, default: Optional[Any] = None, required: bool = False) -> return json_value +def get_file_content(key: str, default: Optional[str] = None, required: bool = False, strip: bool = True, blank: bool = False, relative_to: Optional[Union[Path, str]] = None, encoding: str = 'utf-8') -> Optional[str]: + """환경 변수에 지정된 파일 경로의 내용을 읽어옵니다. + + Args: + key: 파일 경로가 저장된 환경 변수 이름 + default: 기본값 (환경 변수가 없을 때 반환) + required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생) + strip: 값의 앞뒤 공백 제거 여부 (기본값: True) + blank: 파일 내용에 대하여 빈 문자열 허용 여부 (False일 때 빈 문자열은 None으로 간주) + relative_to: 파일 탐색을 허용할 최상위 경로. + 이 값이 None이면 모든 경로가 허용됩니다. + (기본값: None) + encoding: 파일 인코딩 (기본값: 'utf-8') + + Returns: + 파일 내용 또는 기본값 + + Raises: + ValueError: 파일을 읽을 수 없거나 required=True이고 환경 변수가 설정되지 않은 경우 + """ + raw_path = os.getenv(key) + content = default + + if raw_path is not None: + try: + path = Path(raw_path).resolve() + except OSError as e: + raise ValueError( + f'Environment variable "{key}" has invalid file path.' + ) from e + + # 파일 경로가 허용 탐색 범위 이내인지 검사 + if relative_to is not None: + relative_to_path = Path(relative_to).resolve() + try: + if not relative_to_path.is_dir(): + raise ValueError + path.relative_to(relative_to_path) + except ValueError: + raise ValueError( + f'Path specified in environment variable "{key}" is outside ' + f'the allowed directory, or the "relative_to" path is not a directory.' + ) + + # 파일 내용 읽어오기 + fread_error_msg = None + + try: + content = path.read_text(encoding=encoding) + except FileNotFoundError: + fread_error_msg = f'File specified in environment variable "{key}" not found.' + except PermissionError: + fread_error_msg = f'Permission denied to read file specified in environment variable "{key}".' + except OSError: + fread_error_msg = f'Error reading file specified in environment variable "{key}".' + except UnicodeDecodeError: + fread_error_msg = f'Error decoding file specified in environment variable "{key}" with encoding "{encoding}".' + except Exception: + fread_error_msg = f'Unexpected error while reading file specified in environment variable "{key}".' + + if fread_error_msg: + raise ValueError(fread_error_msg) + + if strip and (content is not None): + content = content.strip() + + if not blank and (content == ''): + content = None + + if required and (content is None): + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return content + + def _is_truthy(value: Optional[str], strip: bool = True) -> bool: """문자열이 참 값인지 확인합니다. From d8b0d2328421465353bfa662f17593bc92a7ed20 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 19:54:00 +0900 Subject: [PATCH 17/42] =?UTF-8?q?test(app.env):=20`get=5Ffile=5Fcontent()`?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/test_env.py | 134 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/app/app/test_env.py b/app/app/test_env.py index b0ad6e6..7b621a5 100644 --- a/app/app/test_env.py +++ b/app/app/test_env.py @@ -1,6 +1,9 @@ import os from unittest.mock import patch +from pathlib import Path +from django.conf import settings +from django.core.files.temp import NamedTemporaryFile from django.test import TestCase from app import env @@ -289,3 +292,134 @@ def test_parses_null_in_object(self): 환경 변수 자체가 null인 경우와 객체 내부에 null이 있는 경우를 구분해야 한다. """ self.assertEqual(env.get_json('JSON_VAR'), {'key': None}) + + +class GetFileContentTest(TestCase): + def _create_temp_file(self) -> Path: + """임시 디렉토리와 파일을 생성하고 (dirname, filename) 튜플을 반환하는 헬퍼 함수. + """ + with NamedTemporaryFile(mode='w+', delete=False) as f: + self.addCleanup(os.unlink, f.name) + return Path(f.name).resolve() + + def test_returns_file_content(self): + """파일 내용을 읽어서 반환하는지 확인한다.""" + file = self._create_temp_file() + file.write_text('test content') + with patch.dict(os.environ, {'FILE_VAR': str(file)}): + self.assertEqual(env.get_file_content('FILE_VAR'), 'test content') + + @patch.dict(os.environ, {}, clear=True) + def test_default_parameter(self): + """환경 변수가 설정되지 않았을 때 default 파라미터 값을 반환하는지 확인한다. + + default가 없으면 None을 반환한다. + """ + self.assertIsNone(env.get_file_content('MISSING_VAR')) + self.assertEqual(env.get_file_content('MISSING_VAR', default='default'), + 'default') + + @patch.dict(os.environ, {}, clear=True) + def test_required_parameter(self): + """필수 환경 변수가 설정되지 않았을 때 ValueError를 발생시키는지 확인한다.""" + with self.assertRaises(ValueError): + env.get_file_content('REQUIRED_VAR', required=True) + + @patch.dict(os.environ, {'FILE_VAR': '/nonexistent/path'}) + def test_raises_for_nonexistent_file(self): + """존재하지 않는 파일에 대해 ValueError를 발생시키는지 확인한다.""" + with self.assertRaises(ValueError): + env.get_file_content('FILE_VAR') + + def test_raises_for_invalid_relative_to(self): + """relative_to가 디렉토리가 아닌 경우 ValueError를 발생시키는지 확인한다.""" + file = self._create_temp_file() + file.write_text('content') + with patch.dict(os.environ, {'FILE_VAR': str(file)}): + with self.assertRaises(ValueError): + env.get_file_content('FILE_VAR', relative_to=file) + + def test_strip_parameter(self): + """파일 내용의 앞뒤 공백을 제거하는지 확인한다. + + 기본적으로 strip=True이며, strip=False로 설정하면 공백을 유지한다. + """ + file = self._create_temp_file() + file.write_text(' content ') + with patch.dict(os.environ, {'FILE_VAR': str(file)}): + self.assertEqual(env.get_file_content('FILE_VAR'), + 'content') + self.assertEqual(env.get_file_content('FILE_VAR', strip=False), + ' content ') + + def test_raises_for_invalid_encoding(self): + """잘못된 인코딩으로 파일을 읽을 때 ValueError를 발생시키는지 확인한다.""" + file = self._create_temp_file() + file.write_bytes(b'\xff\xfe') + with patch.dict(os.environ, {'FILE_VAR': str(file)}): + with self.assertRaises(ValueError): + env.get_file_content('FILE_VAR', encoding='utf-8') + + def test_blank_parameter(self): + """빈 파일 내용을 None으로 처리하는지 확인한다. + + 기본적으로 blank=False이므로 빈 문자열은 None으로 변환된다. + blank=True로 설정하면 빈 문자열을 그대로 반환한다. + required와 함께 사용할 때도 blank=True면 빈 문자열을 허용한다. + """ + file = self._create_temp_file() + file.write_text('') + with patch.dict(os.environ, {'FILE_VAR': str(file)}): + self.assertIsNone(env.get_file_content('FILE_VAR')) + self.assertEqual(env.get_file_content('FILE_VAR', blank=True), '') + with self.assertRaises(ValueError): + env.get_file_content('FILE_VAR', required=True) + self.assertEqual(env.get_file_content('FILE_VAR', required=True, blank=True), + '') + + def test_blank_with_whitespace(self): + """공백만 있는 파일이 strip 후 빈 문자열로 처리되는지 확인한다. + + strip이 먼저 적용되어 공백이 제거되고, 그 결과 빈 문자열이 되면 blank 처리 로직이 적용된다. + """ + file = self._create_temp_file() + file.write_text(' ') + with patch.dict(os.environ, {'FILE_VAR': str(file)}): + self.assertIsNone(env.get_file_content('FILE_VAR')) + self.assertEqual(env.get_file_content('FILE_VAR', blank=True), '') + + @patch.dict(os.environ, {}, clear=True) + def test_required_with_default(self): + """required와 default를 함께 사용할 때 default 값을 반환하는지 확인한다. + + 환경 변수가 없어도 default가 있으면 required=True여도 에러가 발생하지 않는다. + """ + self.assertEqual(env.get_file_content('MISSING_VAR', default='default', required=True), + 'default') + + def test_relative_to_allows_file_in_directory(self): + """허용된 디렉토리 내부의 파일은 정상적으로 읽을 수 있는지 확인한다.""" + file = self._create_temp_file() + file.write_text('allowed content') + with patch.dict(os.environ, {'FILE_VAR': str(file)}): + self.assertEqual(env.get_file_content('FILE_VAR', relative_to=str(file.parent)), + 'allowed content') + + def test_does_not_leak_file_existence_outside_relative_to(self): + """relative_to 외부 경로 접근 시 파일 존재 여부를 노출하지 않는지 확인한다. + + 보안상 파일 존재 여부, 파일/디렉토리 구분을 노출하지 않고 동일한 에러를 발생시켜야 한다. + """ + file = self._create_temp_file() + file.write_text('secret') + relative_to = settings.BASE_DIR / 'nonexistent_dir' + for case, filename in [ + ('existing_file', str(file)), + ('nonexistent_file', f'{file.parent}/nonexistent_file_12345.txt'), + ('directory', str(file.parent)), + ]: + with self.subTest(case=case): + with patch.dict(os.environ, {'FILE_VAR': filename}): + with self.assertRaises(ValueError): + env.get_file_content('FILE_VAR', + relative_to=relative_to) From fa942c08a92f72a0e09a0baf2dd2bdeda9ad4e55 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 19:58:27 +0900 Subject: [PATCH 18/42] =?UTF-8?q?feat(app.settings):=20`SECRET=5FKEY`?= =?UTF-8?q?=EA=B0=80=20=EC=84=A4=EC=A0=95=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9D=80=20=EA=B2=BD=EC=9A=B0=20`SECRET=5FKEY=5FFILE`=EC=97=90?= =?UTF-8?q?=20=EB=AA=85=EC=8B=9C=EB=90=9C=20=ED=8C=8C=EC=9D=BC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9D=BD=EC=96=B4=EC=98=A4=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/app/settings.py b/app/app/settings.py index 2672701..831f887 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -25,7 +25,9 @@ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = env.get("SECRET_KEY", required=True) +SECRET_KEY = env.get("SECRET_KEY", + default=env.get_file_content('SECRET_KEY_FILE'), + required=True) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env.get_bool('DEBUG', default=False) From 3ebafc3750aa500dab7d47ca7249685f7cf8ba0d Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sun, 30 Nov 2025 21:06:13 +0900 Subject: [PATCH 19/42] =?UTF-8?q?refactor(app.settings):=20`SECRET=5FKEY`?= =?UTF-8?q?=20=ED=98=B9=EC=9D=80=20`SECRET=5FKEY=5FFILE`=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=84=A4=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=ED=95=98=EB=8A=94=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=AA=85=EB=A3=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/settings.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/app/settings.py b/app/app/settings.py index 831f887..ce227a8 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -25,9 +25,18 @@ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = env.get("SECRET_KEY", - default=env.get_file_content('SECRET_KEY_FILE'), - required=True) +secret_key = env.get("SECRET_KEY") +secret_key_file = env.get("SECRET_KEY_FILE") + +if secret_key and secret_key_file: + raise ValueError("Cannot set both SECRET_KEY and SECRET_KEY_FILE") +elif secret_key: + SECRET_KEY = secret_key +elif secret_key_file: + SECRET_KEY = env.get_file_content("SECRET_KEY_FILE") +else: + raise ValueError("Either SECRET_KEY or SECRET_KEY_FILE must be set") + # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env.get_bool('DEBUG', default=False) From d84c9cbf7a548521af50b7875fbdb7a0cb4f430a Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 20:01:44 +0900 Subject: [PATCH 20/42] =?UTF-8?q?doc:=20README.md=20=EC=9D=98=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=84=B9=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=84=A4=EB=AA=85=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index e916131..4509807 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,16 @@ TLE(Time Limit Exceeded)의 마이크로서비스 아키텍처를 위한 인증 - **회원가입/로그인**: 사용자 계정 관리 - **API 문서화**: Swagger UI 제공 - **마이크로서비스**: 다른 서비스와의 JWT 토큰 공유 + +## 개발 가이드 + +### 환경 변수 + +Docker compose를 사용하지 않고 직접 컨테이너를 실행할 경우 필요한 환경 변수입니다. + +| 변수명 | 설명 | 기본값 | 필수 | +| ----------------- | -------------------------------------------------- | ---------------------------- | -------------------------------- | +| `SECRET_KEY` | Django 암호화 키 | | `SECRET_KEY_FILE` 미설정 시 필수 | +| `SECRET_KEY_FILE` | Django 암호화 키가 저장된 파일 | | `SECRET_KEY` 미설정 시 필수 | +| `DEBUG` | 디버그 모드 활성 여부 | `False` | Optional | +| `ALLOWED_HOSTS` | 허용된 호스트명 (JSON 배열, 예: `["example.com"]`) | `["localhost", "127.0.0.1"]` | Optional | From b0a569f905bbc3b6408ca09086d39fea2ebfc32b Mon Sep 17 00:00:00 2001 From: Hepheir Date: Wed, 3 Dec 2025 20:29:01 +0900 Subject: [PATCH 21/42] refactor(app.env): deprecate `get_file_content()` --- app/app/env.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/app/env.py b/app/app/env.py index eae7970..dc06b4c 100644 --- a/app/app/env.py +++ b/app/app/env.py @@ -4,6 +4,7 @@ import json import os +import warnings from pathlib import Path from typing import Any, Optional, Union @@ -129,6 +130,10 @@ def get_json(key: str, default: Optional[Any] = None, required: bool = False) -> def get_file_content(key: str, default: Optional[str] = None, required: bool = False, strip: bool = True, blank: bool = False, relative_to: Optional[Union[Path, str]] = None, encoding: str = 'utf-8') -> Optional[str]: """환경 변수에 지정된 파일 경로의 내용을 읽어옵니다. + .. deprecated:: + 이 함수는 deprecated 되었습니다. + 대신 직접 Path().resolve(), is_relative_to(), read_text()를 사용하세요. + Args: key: 파일 경로가 저장된 환경 변수 이름 default: 기본값 (환경 변수가 없을 때 반환) @@ -146,6 +151,12 @@ def get_file_content(key: str, default: Optional[str] = None, required: bool = F Raises: ValueError: 파일을 읽을 수 없거나 required=True이고 환경 변수가 설정되지 않은 경우 """ + warnings.warn( + 'get_file_content() is deprecated. ' + 'Use Path().resolve(), is_relative_to(), and read_text() directly in settings.py instead.', + DeprecationWarning, + stacklevel=2 + ) raw_path = os.getenv(key) content = default From 6f0b39b7656baaba6864164266b27f4da5e576de Mon Sep 17 00:00:00 2001 From: Hepheir Date: Wed, 3 Dec 2025 21:08:24 +0900 Subject: [PATCH 22/42] =?UTF-8?q?feat(app.env):=20`get=5Fpath()`=20-=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=EB=A1=9C=20=EB=B6=80?= =?UTF-8?q?=ED=84=B0=20=ED=8C=8C=EC=9D=BC=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=ED=95=98=EC=97=AC=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/env.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/app/app/env.py b/app/app/env.py index dc06b4c..0a90753 100644 --- a/app/app/env.py +++ b/app/app/env.py @@ -127,6 +127,65 @@ def get_json(key: str, default: Optional[Any] = None, required: bool = False) -> return json_value +def get_path(key: str, default: Optional[Path] = None, required: bool = False, relative_to: Optional[Union[Path, str]] = None) -> Optional[Path]: + """환경 변수에 지정된 파일 경로를 Path 객체로 반환합니다. + + Args: + key: 파일 경로가 저장된 환경 변수 이름 + default: 기본값 (환경 변수가 없을 때 반환) + required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생) + relative_to: 파일 탐색을 허용할 최상위 경로. + 이 값이 None이면 모든 경로가 허용됩니다. + (기본값: None) + + Returns: + 파일 경로 또는 기본값 + + Raises: + ValueError: 환경 변수 값이 유효하지 않거나 required=True이고 환경 변수가 설정되지 않은 경우 + """ + raw_path = os.getenv(key) + path = default + + if raw_path is not None: + try: + path = Path(raw_path).resolve() + except OSError as e: + raise ValueError( + f'Environment variable "{key}" has invalid file path.' + ) from e + + # 파일 경로가 허용 탐색 범위 이내인지 검사 + if relative_to and (path is not None): + try: + relative_to_path = Path(relative_to).resolve() + except OSError as e: + raise ValueError( + f'Environment variable "{key}" has invalid "relative_to" path.' + ) from e + + # 디렉토리인지 검증 + if not relative_to_path.is_dir(): + raise ValueError( + f'"relative_to" path for environment variable "{key}" is not a directory.' + ) + + try: + path.relative_to(relative_to_path) + except ValueError: + raise ValueError( + f'Path specified in environment variable "{key}" is outside ' + f'the allowed directory "{relative_to_path}".' + ) + + if required and (path is None): + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return path + + def get_file_content(key: str, default: Optional[str] = None, required: bool = False, strip: bool = True, blank: bool = False, relative_to: Optional[Union[Path, str]] = None, encoding: str = 'utf-8') -> Optional[str]: """환경 변수에 지정된 파일 경로의 내용을 읽어옵니다. From 59b02e33b30a771309f20e94f7c4d17d92e4610c Mon Sep 17 00:00:00 2001 From: Hepheir Date: Wed, 3 Dec 2025 21:36:14 +0900 Subject: [PATCH 23/42] =?UTF-8?q?test(app.env):=20`get=5Fpath()`=20?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/test_env.py | 105 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/app/app/test_env.py b/app/app/test_env.py index 7b621a5..31724b3 100644 --- a/app/app/test_env.py +++ b/app/app/test_env.py @@ -423,3 +423,108 @@ def test_does_not_leak_file_existence_outside_relative_to(self): with self.assertRaises(ValueError): env.get_file_content('FILE_VAR', relative_to=relative_to) + + +class GetPathTest(TestCase): + def _create_temp_file(self) -> Path: + """임시 파일을 생성하고 Path를 반환하는 헬퍼 함수.""" + with NamedTemporaryFile(mode='w+', delete=False) as f: + self.addCleanup(os.unlink, f.name) + return Path(f.name).resolve() + + @patch.dict(os.environ, {'PATH_VAR': '/tmp/test.txt'}) + def test_returns_resolved_path(self): + """환경 변수의 경로를 resolve된 Path 객체로 반환하는지 확인한다.""" + result = env.get_path('PATH_VAR') + self.assertIsInstance(result, Path) + self.assertEqual(result, Path('/tmp/test.txt').resolve()) + + @patch.dict(os.environ, {}, clear=True) + def test_default_parameter(self): + """환경 변수가 설정되지 않았을 때 default 파라미터 값을 반환하는지 확인한다.""" + self.assertIsNone(env.get_path('MISSING_VAR')) + default_path = Path('/default/path') + self.assertEqual(env.get_path('MISSING_VAR', default=default_path), + default_path) + + @patch.dict(os.environ, {}, clear=True) + def test_required_parameter(self): + """필수 환경 변수가 설정되지 않았을 때 ValueError를 발생시키는지 확인한다.""" + with self.assertRaises(ValueError): + env.get_path('REQUIRED_VAR', required=True) + + def test_relative_path_resolution(self): + """상대 경로를 절대 경로로 resolve하는지 확인한다.""" + with patch.dict(os.environ, {'PATH_VAR': './relative/path'}): + result = env.get_path('PATH_VAR') + self.assertTrue(result.is_absolute()) + + def test_symlink_resolution(self): + """심볼릭 링크를 실제 경로로 resolve하는지 확인한다.""" + file = self._create_temp_file() + symlink = file.parent / 'symlink_test' + self.addCleanup(lambda: symlink.unlink(missing_ok=True)) + symlink.symlink_to(file) + with patch.dict(os.environ, {'PATH_VAR': str(symlink)}): + result = env.get_path('PATH_VAR') + self.assertEqual(result, file) + + def test_relative_to_allows_path_in_directory(self): + """relative_to로 지정된 디렉토리 내부의 경로를 허용하는지 확인한다.""" + file = self._create_temp_file() + with patch.dict(os.environ, {'PATH_VAR': str(file)}): + result = env.get_path('PATH_VAR', relative_to=file.parent) + self.assertEqual(result, file) + + def test_relative_to_rejects_path_outside_directory(self): + """relative_to로 지정된 디렉토리 외부의 경로를 거부하는지 확인한다. + + 경로 탐색 공격(path traversal)을 방지하기 위한 보안 검증이다. + """ + file = self._create_temp_file() + other_dir = settings.BASE_DIR / 'other_dir' + with patch.dict(os.environ, {'PATH_VAR': str(file)}): + with self.assertRaises(ValueError): + env.get_path('PATH_VAR', relative_to=other_dir) + + def test_relative_to_rejects_invalid_directory(self): + """relative_to가 디렉토리가 아닌 경우 ValueError를 발생시키는지 확인한다.""" + file = self._create_temp_file() + with patch.dict(os.environ, {'PATH_VAR': str(file)}): + with self.assertRaises(ValueError): + env.get_path('PATH_VAR', relative_to=file) + + def test_relative_to_with_symlink_attack(self): + """심볼릭 링크를 이용한 경로 탐색 공격을 방어하는지 확인한다. + + 허용된 디렉토리 내부의 심볼릭 링크가 외부를 가리킬 때 이를 차단해야 한다. + """ + file = self._create_temp_file() + allowed_dir = file.parent / 'allowed' + symlink = allowed_dir / 'symlink' + # Note: LIFO 구조이므로 아래와 같이 clean up 순서를 구성해야함. + self.addCleanup( + lambda: allowed_dir.rmdir() if allowed_dir.exists() else None + ) + self.addCleanup(lambda: symlink.unlink(missing_ok=True)) + allowed_dir.mkdir(exist_ok=True) + symlink.symlink_to(file) + with patch.dict(os.environ, {'PATH_VAR': str(symlink)}): + with self.assertRaises(ValueError): + env.get_path('PATH_VAR', relative_to=allowed_dir) + + @patch.dict(os.environ, {'PATH_VAR': '../../../etc/passwd'}) + def test_path_traversal_attack_prevention(self): + """경로 탐색 공격 시도를 방어하는지 확인한다. + + 상대 경로를 사용한 상위 디렉토리 접근 시도를 차단해야 한다. + """ + with self.assertRaises(ValueError): + env.get_path('PATH_VAR', relative_to=settings.BASE_DIR) + + @patch.dict(os.environ, {}, clear=True) + def test_required_with_default(self): + """required와 default를 함께 사용할 때 default 값을 반환하는지 확인한다.""" + default_path = Path('/default') + self.assertEqual(env.get_path('MISSING_VAR', default=default_path, required=True), + default_path) From 3a7259fbf2a5f07824f7c0356514661dbb49b1d8 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Wed, 3 Dec 2025 20:25:51 +0900 Subject: [PATCH 24/42] =?UTF-8?q?refactor(app.settings):=20`env.get=5Ffile?= =?UTF-8?q?=5Fcontent()`=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/settings.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/app/settings.py b/app/app/settings.py index ce227a8..8d790f5 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -33,7 +33,14 @@ elif secret_key: SECRET_KEY = secret_key elif secret_key_file: - SECRET_KEY = env.get_file_content("SECRET_KEY_FILE") + secret_key_file_path = Path(secret_key_file).resolve() + if not secret_key_file_path.is_relative_to(BASE_DIR): + raise ValueError("SECRET_KEY_FILE path must be within the BASE_DIR") + if not secret_key_file_path.is_file(): + raise ValueError("SECRET_KEY_FILE path does not exist or is not a file") + SECRET_KEY = secret_key_file_path.read_text(encoding='utf-8').strip() + if not SECRET_KEY: + raise ValueError("SECRET_KEY_FILE must not be empty") else: raise ValueError("Either SECRET_KEY or SECRET_KEY_FILE must be set") From 618f3bb6e92e218c7d5e86af3c503af42d4d8e3a Mon Sep 17 00:00:00 2001 From: Hepheir Date: Wed, 3 Dec 2025 21:28:45 +0900 Subject: [PATCH 25/42] =?UTF-8?q?refactor(app.settings):=20`SECRET=5FKEY?= =?UTF-8?q?=5FFILE`=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=98=AC=20=EB=95=8C=20f?= =?UTF-8?q?ail-fast=20=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/settings.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/app/app/settings.py b/app/app/settings.py index 8d790f5..b8ea272 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -26,21 +26,20 @@ # SECURITY WARNING: keep the secret key used in production secret! secret_key = env.get("SECRET_KEY") -secret_key_file = env.get("SECRET_KEY_FILE") +secret_key_file = env.get_path("SECRET_KEY_FILE") if secret_key and secret_key_file: raise ValueError("Cannot set both SECRET_KEY and SECRET_KEY_FILE") elif secret_key: SECRET_KEY = secret_key elif secret_key_file: - secret_key_file_path = Path(secret_key_file).resolve() - if not secret_key_file_path.is_relative_to(BASE_DIR): - raise ValueError("SECRET_KEY_FILE path must be within the BASE_DIR") - if not secret_key_file_path.is_file(): - raise ValueError("SECRET_KEY_FILE path does not exist or is not a file") - SECRET_KEY = secret_key_file_path.read_text(encoding='utf-8').strip() - if not SECRET_KEY: - raise ValueError("SECRET_KEY_FILE must not be empty") + if not secret_key_file.exists(): + raise ValueError(f"SECRET_KEY_FILE does not exist.") + if not secret_key_file.is_file(): + raise ValueError(f"SECRET_KEY_FILE is not a file.") + + # Fail-fast, 명확한 에러 추적을 위해 파일읽기 중 오류는 예외처리를 하지 않음. + SECRET_KEY = secret_key_file.read_text().strip() else: raise ValueError("Either SECRET_KEY or SECRET_KEY_FILE must be set") From 9107090df5f8fa2f5ae3d6c7c4b34043579edd4f Mon Sep 17 00:00:00 2001 From: Hepheir Date: Wed, 3 Dec 2025 21:39:22 +0900 Subject: [PATCH 26/42] refactor(app.env): remove deprecated `get_file_content()` function and related tests --- app/app/env.py | 88 ----------------------------- app/app/test_env.py | 131 -------------------------------------------- 2 files changed, 219 deletions(-) diff --git a/app/app/env.py b/app/app/env.py index 0a90753..f465a83 100644 --- a/app/app/env.py +++ b/app/app/env.py @@ -4,7 +4,6 @@ import json import os -import warnings from pathlib import Path from typing import Any, Optional, Union @@ -186,93 +185,6 @@ def get_path(key: str, default: Optional[Path] = None, required: bool = False, r return path -def get_file_content(key: str, default: Optional[str] = None, required: bool = False, strip: bool = True, blank: bool = False, relative_to: Optional[Union[Path, str]] = None, encoding: str = 'utf-8') -> Optional[str]: - """환경 변수에 지정된 파일 경로의 내용을 읽어옵니다. - - .. deprecated:: - 이 함수는 deprecated 되었습니다. - 대신 직접 Path().resolve(), is_relative_to(), read_text()를 사용하세요. - - Args: - key: 파일 경로가 저장된 환경 변수 이름 - default: 기본값 (환경 변수가 없을 때 반환) - required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생) - strip: 값의 앞뒤 공백 제거 여부 (기본값: True) - blank: 파일 내용에 대하여 빈 문자열 허용 여부 (False일 때 빈 문자열은 None으로 간주) - relative_to: 파일 탐색을 허용할 최상위 경로. - 이 값이 None이면 모든 경로가 허용됩니다. - (기본값: None) - encoding: 파일 인코딩 (기본값: 'utf-8') - - Returns: - 파일 내용 또는 기본값 - - Raises: - ValueError: 파일을 읽을 수 없거나 required=True이고 환경 변수가 설정되지 않은 경우 - """ - warnings.warn( - 'get_file_content() is deprecated. ' - 'Use Path().resolve(), is_relative_to(), and read_text() directly in settings.py instead.', - DeprecationWarning, - stacklevel=2 - ) - raw_path = os.getenv(key) - content = default - - if raw_path is not None: - try: - path = Path(raw_path).resolve() - except OSError as e: - raise ValueError( - f'Environment variable "{key}" has invalid file path.' - ) from e - - # 파일 경로가 허용 탐색 범위 이내인지 검사 - if relative_to is not None: - relative_to_path = Path(relative_to).resolve() - try: - if not relative_to_path.is_dir(): - raise ValueError - path.relative_to(relative_to_path) - except ValueError: - raise ValueError( - f'Path specified in environment variable "{key}" is outside ' - f'the allowed directory, or the "relative_to" path is not a directory.' - ) - - # 파일 내용 읽어오기 - fread_error_msg = None - - try: - content = path.read_text(encoding=encoding) - except FileNotFoundError: - fread_error_msg = f'File specified in environment variable "{key}" not found.' - except PermissionError: - fread_error_msg = f'Permission denied to read file specified in environment variable "{key}".' - except OSError: - fread_error_msg = f'Error reading file specified in environment variable "{key}".' - except UnicodeDecodeError: - fread_error_msg = f'Error decoding file specified in environment variable "{key}" with encoding "{encoding}".' - except Exception: - fread_error_msg = f'Unexpected error while reading file specified in environment variable "{key}".' - - if fread_error_msg: - raise ValueError(fread_error_msg) - - if strip and (content is not None): - content = content.strip() - - if not blank and (content == ''): - content = None - - if required and (content is None): - raise ValueError( - f'Environment variable "{key}" is required but not set.' - ) - - return content - - def _is_truthy(value: Optional[str], strip: bool = True) -> bool: """문자열이 참 값인지 확인합니다. diff --git a/app/app/test_env.py b/app/app/test_env.py index 31724b3..406498f 100644 --- a/app/app/test_env.py +++ b/app/app/test_env.py @@ -294,137 +294,6 @@ def test_parses_null_in_object(self): self.assertEqual(env.get_json('JSON_VAR'), {'key': None}) -class GetFileContentTest(TestCase): - def _create_temp_file(self) -> Path: - """임시 디렉토리와 파일을 생성하고 (dirname, filename) 튜플을 반환하는 헬퍼 함수. - """ - with NamedTemporaryFile(mode='w+', delete=False) as f: - self.addCleanup(os.unlink, f.name) - return Path(f.name).resolve() - - def test_returns_file_content(self): - """파일 내용을 읽어서 반환하는지 확인한다.""" - file = self._create_temp_file() - file.write_text('test content') - with patch.dict(os.environ, {'FILE_VAR': str(file)}): - self.assertEqual(env.get_file_content('FILE_VAR'), 'test content') - - @patch.dict(os.environ, {}, clear=True) - def test_default_parameter(self): - """환경 변수가 설정되지 않았을 때 default 파라미터 값을 반환하는지 확인한다. - - default가 없으면 None을 반환한다. - """ - self.assertIsNone(env.get_file_content('MISSING_VAR')) - self.assertEqual(env.get_file_content('MISSING_VAR', default='default'), - 'default') - - @patch.dict(os.environ, {}, clear=True) - def test_required_parameter(self): - """필수 환경 변수가 설정되지 않았을 때 ValueError를 발생시키는지 확인한다.""" - with self.assertRaises(ValueError): - env.get_file_content('REQUIRED_VAR', required=True) - - @patch.dict(os.environ, {'FILE_VAR': '/nonexistent/path'}) - def test_raises_for_nonexistent_file(self): - """존재하지 않는 파일에 대해 ValueError를 발생시키는지 확인한다.""" - with self.assertRaises(ValueError): - env.get_file_content('FILE_VAR') - - def test_raises_for_invalid_relative_to(self): - """relative_to가 디렉토리가 아닌 경우 ValueError를 발생시키는지 확인한다.""" - file = self._create_temp_file() - file.write_text('content') - with patch.dict(os.environ, {'FILE_VAR': str(file)}): - with self.assertRaises(ValueError): - env.get_file_content('FILE_VAR', relative_to=file) - - def test_strip_parameter(self): - """파일 내용의 앞뒤 공백을 제거하는지 확인한다. - - 기본적으로 strip=True이며, strip=False로 설정하면 공백을 유지한다. - """ - file = self._create_temp_file() - file.write_text(' content ') - with patch.dict(os.environ, {'FILE_VAR': str(file)}): - self.assertEqual(env.get_file_content('FILE_VAR'), - 'content') - self.assertEqual(env.get_file_content('FILE_VAR', strip=False), - ' content ') - - def test_raises_for_invalid_encoding(self): - """잘못된 인코딩으로 파일을 읽을 때 ValueError를 발생시키는지 확인한다.""" - file = self._create_temp_file() - file.write_bytes(b'\xff\xfe') - with patch.dict(os.environ, {'FILE_VAR': str(file)}): - with self.assertRaises(ValueError): - env.get_file_content('FILE_VAR', encoding='utf-8') - - def test_blank_parameter(self): - """빈 파일 내용을 None으로 처리하는지 확인한다. - - 기본적으로 blank=False이므로 빈 문자열은 None으로 변환된다. - blank=True로 설정하면 빈 문자열을 그대로 반환한다. - required와 함께 사용할 때도 blank=True면 빈 문자열을 허용한다. - """ - file = self._create_temp_file() - file.write_text('') - with patch.dict(os.environ, {'FILE_VAR': str(file)}): - self.assertIsNone(env.get_file_content('FILE_VAR')) - self.assertEqual(env.get_file_content('FILE_VAR', blank=True), '') - with self.assertRaises(ValueError): - env.get_file_content('FILE_VAR', required=True) - self.assertEqual(env.get_file_content('FILE_VAR', required=True, blank=True), - '') - - def test_blank_with_whitespace(self): - """공백만 있는 파일이 strip 후 빈 문자열로 처리되는지 확인한다. - - strip이 먼저 적용되어 공백이 제거되고, 그 결과 빈 문자열이 되면 blank 처리 로직이 적용된다. - """ - file = self._create_temp_file() - file.write_text(' ') - with patch.dict(os.environ, {'FILE_VAR': str(file)}): - self.assertIsNone(env.get_file_content('FILE_VAR')) - self.assertEqual(env.get_file_content('FILE_VAR', blank=True), '') - - @patch.dict(os.environ, {}, clear=True) - def test_required_with_default(self): - """required와 default를 함께 사용할 때 default 값을 반환하는지 확인한다. - - 환경 변수가 없어도 default가 있으면 required=True여도 에러가 발생하지 않는다. - """ - self.assertEqual(env.get_file_content('MISSING_VAR', default='default', required=True), - 'default') - - def test_relative_to_allows_file_in_directory(self): - """허용된 디렉토리 내부의 파일은 정상적으로 읽을 수 있는지 확인한다.""" - file = self._create_temp_file() - file.write_text('allowed content') - with patch.dict(os.environ, {'FILE_VAR': str(file)}): - self.assertEqual(env.get_file_content('FILE_VAR', relative_to=str(file.parent)), - 'allowed content') - - def test_does_not_leak_file_existence_outside_relative_to(self): - """relative_to 외부 경로 접근 시 파일 존재 여부를 노출하지 않는지 확인한다. - - 보안상 파일 존재 여부, 파일/디렉토리 구분을 노출하지 않고 동일한 에러를 발생시켜야 한다. - """ - file = self._create_temp_file() - file.write_text('secret') - relative_to = settings.BASE_DIR / 'nonexistent_dir' - for case, filename in [ - ('existing_file', str(file)), - ('nonexistent_file', f'{file.parent}/nonexistent_file_12345.txt'), - ('directory', str(file.parent)), - ]: - with self.subTest(case=case): - with patch.dict(os.environ, {'FILE_VAR': filename}): - with self.assertRaises(ValueError): - env.get_file_content('FILE_VAR', - relative_to=relative_to) - - class GetPathTest(TestCase): def _create_temp_file(self) -> Path: """임시 파일을 생성하고 Path를 반환하는 헬퍼 함수.""" From a8a2a5e3639e7be68cd5c5e32da3fa565c8d4bf7 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Wed, 3 Dec 2025 21:53:29 +0900 Subject: [PATCH 27/42] chore(app.settings): add TODO comment to use PostgreSQL for production --- app/app/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/app/settings.py b/app/app/settings.py index b8ea272..b7739b2 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -97,6 +97,7 @@ # Database # https://docs.djangoproject.com/en/5.2/ref/settings/#databases +# TODO: Use PostgreSQL for production. DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', From d7f68b9ad9d5c1dac30491e8878455230e83c22c Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 4 Dec 2025 03:37:08 +0900 Subject: [PATCH 28/42] =?UTF-8?q?refactor(app.settings):=20=EB=94=94?= =?UTF-8?q?=EB=B2=84=EA=B9=85=EC=9D=84=20=EC=9C=84=ED=95=B4=20`SECRET=5FKE?= =?UTF-8?q?Y=5FFILE`=20=EC=98=A4=EB=A5=98=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=EC=97=90=20=EC=8B=A4=EC=A0=9C=20=ED=8C=8C=EC=9D=BC=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=9C=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/settings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/app/settings.py b/app/app/settings.py index b7739b2..a28092a 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -34,11 +34,11 @@ SECRET_KEY = secret_key elif secret_key_file: if not secret_key_file.exists(): - raise ValueError(f"SECRET_KEY_FILE does not exist.") + raise ValueError(f"SECRET_KEY_FILE does not exist: {secret_key_file}") if not secret_key_file.is_file(): - raise ValueError(f"SECRET_KEY_FILE is not a file.") + raise ValueError(f"SECRET_KEY_FILE is not a file: {secret_key_file}") - # Fail-fast, 명확한 에러 추적을 위해 파일읽기 중 오류는 예외처리를 하지 않음. + # Fail-fast, 명확한 에러 추적을 위해 파일 읽기 중 오류는 예외 처리를 하지 않음. SECRET_KEY = secret_key_file.read_text().strip() else: raise ValueError("Either SECRET_KEY or SECRET_KEY_FILE must be set") From 2843ba39571236310b3a3c9fe1c22d49e8badd27 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 01:58:07 +0900 Subject: [PATCH 29/42] =?UTF-8?q?test(app.env):=20`load()`=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/test_env.py | 58 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/app/app/test_env.py b/app/app/test_env.py index 406498f..bc5f169 100644 --- a/app/app/test_env.py +++ b/app/app/test_env.py @@ -9,6 +9,64 @@ from app import env +class LoadTest(TestCase): + def test_loads_env_file_when_exists(self): + """env 파일이 존재할 때 환경 변수를 올바르게 로드하는지 확인한다.""" + with NamedTemporaryFile(mode='w', suffix='.env', delete=False) as f: + f.write('TEST_LOAD_VAR=loaded_value\n') + env_path = Path(f.name) + self.addCleanup(os.unlink, env_path) + + env.load(dotenv_path=env_path) + self.assertEqual(os.getenv('TEST_LOAD_VAR'), 'loaded_value') + self.addCleanup(lambda: os.environ.pop('TEST_LOAD_VAR', None)) + + def test_does_not_raise_when_file_missing(self): + """env 파일이 존재하지 않을 때 에러를 발생시키지 않는지 확인한다.""" + with NamedTemporaryFile(delete=True) as f: + non_existent_path = Path(f.name) + env.load(dotenv_path=non_existent_path) + + def test_does_not_raise_when_path_is_none(self): + """dotenv_path가 None일 때 에러를 발생시키지 않는지 확인한다.""" + env.load(dotenv_path=None) + + @patch.dict(os.environ, {'TEST_OVERRIDE_VAR': 'original'}) + def test_overrides_existing_variables(self): + """기존 환경 변수를 override=True로 덮어쓰는지 확인한다.""" + with NamedTemporaryFile(mode='w', suffix='.env', delete=False) as f: + f.write('TEST_OVERRIDE_VAR=overridden\n') + env_path = Path(f.name) + self.addCleanup(os.unlink, env_path) + + env.load(dotenv_path=env_path) + self.assertEqual(os.getenv('TEST_OVERRIDE_VAR'), 'overridden') + + def test_loads_multiple_variables(self): + """env 파일에 여러 환경 변수가 있을 때 모두 로드하는지 확인한다.""" + with NamedTemporaryFile(mode='w', suffix='.env', delete=False) as f: + f.write('VAR1=value1\nVAR2=value2\nVAR3=value3\n') + env_path = Path(f.name) + self.addCleanup(os.unlink, env_path) + + env.load(dotenv_path=env_path) + self.assertEqual(os.getenv('VAR1'), 'value1') + self.assertEqual(os.getenv('VAR2'), 'value2') + self.assertEqual(os.getenv('VAR3'), 'value3') + self.addCleanup(lambda: [os.environ.pop(k, None) for k in ['VAR1', 'VAR2', 'VAR3']]) + + def test_loads_values_with_special_characters(self): + """특수 문자와 공백이 포함된 환경 변수 값을 올바르게 로드하는지 확인한다.""" + with NamedTemporaryFile(mode='w', suffix='.env', delete=False) as f: + f.write('SPECIAL_VAR="value with spaces"\n') + env_path = Path(f.name) + self.addCleanup(os.unlink, env_path) + + env.load(dotenv_path=env_path) + self.assertEqual(os.getenv('SPECIAL_VAR'), 'value with spaces') + self.addCleanup(lambda: os.environ.pop('SPECIAL_VAR', None)) + + class GetTest(TestCase): @patch.dict(os.environ, {'TEST_VAR': 'test_value'}) def test_returns_env_value(self): From 3e8a6ad1270195243efe1edab8e47634fbd73a7a Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 02:41:11 +0900 Subject: [PATCH 30/42] =?UTF-8?q?refactor(app.settings):=20`ALLOWED=5FHOST?= =?UTF-8?q?S`=20=EA=B0=80=20`DEBUG`=EA=B0=80=20=EC=95=84=EB=8B=90=EB=95=8C?= =?UTF-8?q?=EB=8A=94=20=ED=95=84=EC=88=98=EC=9D=B8=20=EA=B2=83=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 ++++++------ app/app/settings.py | 8 ++++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4509807..ea826f8 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ TLE(Time Limit Exceeded)의 마이크로서비스 아키텍처를 위한 인증 Docker compose를 사용하지 않고 직접 컨테이너를 실행할 경우 필요한 환경 변수입니다. -| 변수명 | 설명 | 기본값 | 필수 | -| ----------------- | -------------------------------------------------- | ---------------------------- | -------------------------------- | -| `SECRET_KEY` | Django 암호화 키 | | `SECRET_KEY_FILE` 미설정 시 필수 | -| `SECRET_KEY_FILE` | Django 암호화 키가 저장된 파일 | | `SECRET_KEY` 미설정 시 필수 | -| `DEBUG` | 디버그 모드 활성 여부 | `False` | Optional | -| `ALLOWED_HOSTS` | 허용된 호스트명 (JSON 배열, 예: `["example.com"]`) | `["localhost", "127.0.0.1"]` | Optional | +| 변수명 | 설명 | 기본값 | 필수 | +| ----------------- | -------------------------------------------------- | ------- | -------------------------------- | +| `SECRET_KEY` | Django 암호화 키 | | `SECRET_KEY_FILE` 미설정 시 필수 | +| `SECRET_KEY_FILE` | Django 암호화 키가 저장된 파일 | | `SECRET_KEY` 미설정 시 필수 | +| `DEBUG` | 디버그 모드 활성 여부 | `False` | Optional | +| `ALLOWED_HOSTS` | 허용된 호스트명 (JSON 배열, 예: `["example.com"]`) | | 디버그 모드가 아니면 필수 | diff --git a/app/app/settings.py b/app/app/settings.py index a28092a..1816142 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -47,8 +47,12 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env.get_bool('DEBUG', default=False) -ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', - default=['localhost', '127.0.0.1']) + +# Allow all hosts during development, require explicit hosts in production. +if DEBUG: + ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', default=[]) +else: + ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', required=True) # Application definition From aab2a3c80df2252b5e75abb4848761b99edb1a46 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 02:45:50 +0900 Subject: [PATCH 31/42] refactor(app.env): improve `get_path()` error messages --- app/app/env.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/app/env.py b/app/app/env.py index f465a83..2543b64 100644 --- a/app/app/env.py +++ b/app/app/env.py @@ -160,13 +160,13 @@ def get_path(key: str, default: Optional[Path] = None, required: bool = False, r relative_to_path = Path(relative_to).resolve() except OSError as e: raise ValueError( - f'Environment variable "{key}" has invalid "relative_to" path.' + f'Invalid "relative_to" parameter value.' ) from e # 디렉토리인지 검증 if not relative_to_path.is_dir(): raise ValueError( - f'"relative_to" path for environment variable "{key}" is not a directory.' + f'The "relative_to" parameter must be a directory, got: {relative_to_path}' ) try: From 08174f2c284d835c1b529a805b0d195ecd1004c7 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 03:45:42 +0900 Subject: [PATCH 32/42] =?UTF-8?q?refactor(app.settings):=20`SECRET=5FKEY?= =?UTF-8?q?=5FFILE`=20=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/settings.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/app/settings.py b/app/app/settings.py index 1816142..b9990f4 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -33,6 +33,9 @@ elif secret_key: SECRET_KEY = secret_key elif secret_key_file: + # NOTE: 명확한 에러 추적을 위해 파일 경로는 오류 메시지로 노출. + # 오류가 발생하면 애플리케이션 구동이 안되기에, 보안을 위해 키 값도 아닌, + # 경로 자체를 숨기는 것은 과하다고 판단함. if not secret_key_file.exists(): raise ValueError(f"SECRET_KEY_FILE does not exist: {secret_key_file}") if not secret_key_file.is_file(): @@ -40,6 +43,9 @@ # Fail-fast, 명확한 에러 추적을 위해 파일 읽기 중 오류는 예외 처리를 하지 않음. SECRET_KEY = secret_key_file.read_text().strip() + + if not SECRET_KEY: + raise ValueError(f"SECRET_KEY_FILE is empty: {secret_key_file}") else: raise ValueError("Either SECRET_KEY or SECRET_KEY_FILE must be set") From 3c189ff838d5be92e818b75aa9ef2ba5dbe15c26 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 03:49:58 +0900 Subject: [PATCH 33/42] =?UTF-8?q?chore(app.settings):=20`ALLOWED=5FHOSTS`?= =?UTF-8?q?=EC=9D=98=20default=EA=B0=92=EC=9D=84=20DEBUG=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=EC=97=90=EC=84=9C=EB=A7=8C=20'*'=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/app/settings.py b/app/app/settings.py index b9990f4..2275238 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -56,7 +56,7 @@ # Allow all hosts during development, require explicit hosts in production. if DEBUG: - ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', default=[]) + ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', default=['*']) else: ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', required=True) From 48e28d8d4d9da56ccb4794b3ecaa9276873c53a5 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 07:47:30 +0900 Subject: [PATCH 34/42] =?UTF-8?q?test(app.env):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=A4=91=20=EC=A0=88=EB=8C=80?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20OS=EC=97=90=20=EC=98=81?= =?UTF-8?q?=ED=96=A5=EC=9D=84=20=EB=B0=9B=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EA=B2=83=EC=9C=BC=EB=A1=9C=20=EB=8C=80=EC=B2=B4=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/test_env.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/app/test_env.py b/app/app/test_env.py index bc5f169..35816c1 100644 --- a/app/app/test_env.py +++ b/app/app/test_env.py @@ -359,12 +359,12 @@ def _create_temp_file(self) -> Path: self.addCleanup(os.unlink, f.name) return Path(f.name).resolve() - @patch.dict(os.environ, {'PATH_VAR': '/tmp/test.txt'}) + @patch.dict(os.environ, {'PATH_VAR': str(settings.BASE_DIR / 'test.txt')}) def test_returns_resolved_path(self): """환경 변수의 경로를 resolve된 Path 객체로 반환하는지 확인한다.""" result = env.get_path('PATH_VAR') self.assertIsInstance(result, Path) - self.assertEqual(result, Path('/tmp/test.txt').resolve()) + self.assertEqual(result, (settings.BASE_DIR / 'test.txt').resolve()) @patch.dict(os.environ, {}, clear=True) def test_default_parameter(self): @@ -452,6 +452,6 @@ def test_path_traversal_attack_prevention(self): @patch.dict(os.environ, {}, clear=True) def test_required_with_default(self): """required와 default를 함께 사용할 때 default 값을 반환하는지 확인한다.""" - default_path = Path('/default') + default_path = settings.BASE_DIR / 'default' self.assertEqual(env.get_path('MISSING_VAR', default=default_path, required=True), default_path) From 2b524767d08fb3dce111cf9a1c0476f7f594a14a Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 07:50:58 +0900 Subject: [PATCH 35/42] chore: update note on path handling for cross-platform compatibility for `.env.sample` --- app/.env.sample | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/.env.sample b/app/.env.sample index 2e9cee3..1b3cb24 100644 --- a/app/.env.sample +++ b/app/.env.sample @@ -4,6 +4,6 @@ DEBUG=false # Django secret key for cryptographic signing -# NOTE: The path below uses Unix-style separators ('/'). On Windows, adjust the path accordingly (e.g., '..\.secrets\secret_key.txt'). +# NOTE: Python handles forward slashes correctly on all platforms, including Windows. SECRET_KEY_FILE=../.secrets/secret_key.txt # SECRET_KEY='' From 70ee7f3e34d434ccceefbae6ebd89e2335cea354 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 07:47:53 +0900 Subject: [PATCH 36/42] refactor(app.settings): ensure `SECRET_KEY` is read with UTF-8 encoding from `SECRET_KEY_FILE` --- app/app/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/app/settings.py b/app/app/settings.py index 2275238..da0b1ac 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -42,7 +42,7 @@ raise ValueError(f"SECRET_KEY_FILE is not a file: {secret_key_file}") # Fail-fast, 명확한 에러 추적을 위해 파일 읽기 중 오류는 예외 처리를 하지 않음. - SECRET_KEY = secret_key_file.read_text().strip() + SECRET_KEY = secret_key_file.read_text(encoding='utf-8').strip() if not SECRET_KEY: raise ValueError(f"SECRET_KEY_FILE is empty: {secret_key_file}") From 70c95453ce8d7392a53926514a6a8b4e70eb51aa Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 07:48:02 +0900 Subject: [PATCH 37/42] refactor(app.settings): update `ALLOWED_HOSTS` default values for development to include localhost and IPv6 --- app/app/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/app/settings.py b/app/app/settings.py index da0b1ac..dc43ab8 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -56,7 +56,8 @@ # Allow all hosts during development, require explicit hosts in production. if DEBUG: - ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', default=['*']) + ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', + default=['localhost', '127.0.0.1', '[::1]']) else: ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', required=True) From e6fde3b7ae0c5832c1acd3f1d4546b73eeb1f261 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 07:59:08 +0900 Subject: [PATCH 38/42] =?UTF-8?q?test(app.env):=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20cleanup=EC=9D=84=20@patch.dict=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/test_env.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/app/test_env.py b/app/app/test_env.py index 35816c1..aab8672 100644 --- a/app/app/test_env.py +++ b/app/app/test_env.py @@ -10,6 +10,7 @@ class LoadTest(TestCase): + @patch.dict(os.environ, {}, clear=True) def test_loads_env_file_when_exists(self): """env 파일이 존재할 때 환경 변수를 올바르게 로드하는지 확인한다.""" with NamedTemporaryFile(mode='w', suffix='.env', delete=False) as f: @@ -19,7 +20,6 @@ def test_loads_env_file_when_exists(self): env.load(dotenv_path=env_path) self.assertEqual(os.getenv('TEST_LOAD_VAR'), 'loaded_value') - self.addCleanup(lambda: os.environ.pop('TEST_LOAD_VAR', None)) def test_does_not_raise_when_file_missing(self): """env 파일이 존재하지 않을 때 에러를 발생시키지 않는지 확인한다.""" @@ -42,6 +42,7 @@ def test_overrides_existing_variables(self): env.load(dotenv_path=env_path) self.assertEqual(os.getenv('TEST_OVERRIDE_VAR'), 'overridden') + @patch.dict(os.environ, {}, clear=True) def test_loads_multiple_variables(self): """env 파일에 여러 환경 변수가 있을 때 모두 로드하는지 확인한다.""" with NamedTemporaryFile(mode='w', suffix='.env', delete=False) as f: @@ -53,8 +54,8 @@ def test_loads_multiple_variables(self): self.assertEqual(os.getenv('VAR1'), 'value1') self.assertEqual(os.getenv('VAR2'), 'value2') self.assertEqual(os.getenv('VAR3'), 'value3') - self.addCleanup(lambda: [os.environ.pop(k, None) for k in ['VAR1', 'VAR2', 'VAR3']]) + @patch.dict(os.environ, {}, clear=True) def test_loads_values_with_special_characters(self): """특수 문자와 공백이 포함된 환경 변수 값을 올바르게 로드하는지 확인한다.""" with NamedTemporaryFile(mode='w', suffix='.env', delete=False) as f: @@ -64,7 +65,6 @@ def test_loads_values_with_special_characters(self): env.load(dotenv_path=env_path) self.assertEqual(os.getenv('SPECIAL_VAR'), 'value with spaces') - self.addCleanup(lambda: os.environ.pop('SPECIAL_VAR', None)) class GetTest(TestCase): From 2b9544613da95e8439c69662e5add3704c631808 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 10:07:05 +0900 Subject: [PATCH 39/42] chore: add comments for `ALLOWED_HOSTS` in sample .env file --- app/.env.sample | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/.env.sample b/app/.env.sample index 1b3cb24..07d5808 100644 --- a/app/.env.sample +++ b/app/.env.sample @@ -7,3 +7,6 @@ DEBUG=false # NOTE: Python handles forward slashes correctly on all platforms, including Windows. SECRET_KEY_FILE=../.secrets/secret_key.txt # SECRET_KEY='' + +# Allowed hosts for the Django application +# ALLOWED_HOSTS=["example.com", "www.example.com"] From 0ca9cdc13dbaf43a7953b0027a80975bffa72bea Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 15:58:42 +0900 Subject: [PATCH 40/42] =?UTF-8?q?docs:=20update=20`DEBUG`=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20for=20=EC=9D=BC=EA=B4=80=EC=84=B1=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=20"Optional"=20->=20"=EC=84=A0=ED=83=9D=20=EC=82=AC?= =?UTF-8?q?=ED=95=AD"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ea826f8..cea7e82 100644 --- a/README.md +++ b/README.md @@ -22,5 +22,5 @@ Docker compose를 사용하지 않고 직접 컨테이너를 실행할 경우 | ----------------- | -------------------------------------------------- | ------- | -------------------------------- | | `SECRET_KEY` | Django 암호화 키 | | `SECRET_KEY_FILE` 미설정 시 필수 | | `SECRET_KEY_FILE` | Django 암호화 키가 저장된 파일 | | `SECRET_KEY` 미설정 시 필수 | -| `DEBUG` | 디버그 모드 활성 여부 | `False` | Optional | +| `DEBUG` | 디버그 모드 활성 여부 | `False` | 선택 사항 | | `ALLOWED_HOSTS` | 허용된 호스트명 (JSON 배열, 예: `["example.com"]`) | | 디버그 모드가 아니면 필수 | From fff363dae38f00eed9be20015b938349e6f283de Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 16:04:01 +0900 Subject: [PATCH 41/42] =?UTF-8?q?choe(app.settings):=20`env.get=5Fpath()`?= =?UTF-8?q?=EC=97=90=20`relative=5Fto`=EB=A5=BC=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=9D=B4=EC=9C=A0=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/settings.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/app/settings.py b/app/app/settings.py index dc43ab8..00f70d5 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -27,6 +27,11 @@ # SECURITY WARNING: keep the secret key used in production secret! secret_key = env.get("SECRET_KEY") secret_key_file = env.get_path("SECRET_KEY_FILE") +# NOTE: path traversal attack에 대하여, +# Docker Compose를 구성할 경우, secret이 담긴 파일이 프로젝트 디렉터리 외부에 있는 경우도 있다. +# 따라서 relative_to 를 설정할 경우, "/run/secrets/*" 와 같은 경로에 접근을 못하게 될 가능성이 높다. +# 더군다나 위 코드는 최초 설정시에만 실행되기에 약점의 크기가 상당히 작다고 판단하여 +# path traversal attack을 허용할 여지가 있더라도, 더 좋은 방법을 찾기 전까지는 relative_to를 설정하지 않는다. if secret_key and secret_key_file: raise ValueError("Cannot set both SECRET_KEY and SECRET_KEY_FILE") From 98314eaf397e34f9c525fccda802cf5fd90447eb Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 5 Dec 2025 16:34:22 +0900 Subject: [PATCH 42/42] ci: add environment variables for `SECRET_KEY` and `ALLOWED_HOSTS` in CI workflow --- .github/workflows/django.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index aac3458..72731ea 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -26,6 +26,9 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt - name: Run Tests + env: + SECRET_KEY: "django-insecure-0000-temporal-secret-key-for-testing-0000" + ALLOWED_HOSTS: "[\"localhost\", \"127.0.0.1\", \"[::1]\"]" run: | cd app python manage.py test