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 diff --git a/README.md b/README.md index e916131..cea7e82 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` | 선택 사항 | +| `ALLOWED_HOSTS` | 허용된 호스트명 (JSON 배열, 예: `["example.com"]`) | | 디버그 모드가 아니면 필수 | diff --git a/app/.env.sample b/app/.env.sample new file mode 100644 index 0000000..07d5808 --- /dev/null +++ b/app/.env.sample @@ -0,0 +1,12 @@ +# 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: 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"] diff --git a/app/app/env.py b/app/app/env.py new file mode 100644 index 0000000..2543b64 --- /dev/null +++ b/app/app/env.py @@ -0,0 +1,223 @@ +""" +환경 변수와 관련된 기능 혹은 유틸리티 모음. +""" + +import json +import os +from pathlib import Path +from typing import Any, Optional, Union + +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 파일에서 환경 변수를 로드합니다. + + 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]: + """환경 변수 값을 가져옵니다. + + 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 + + +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 get_json(key: str, default: Optional[Any] = None, required: bool = False) -> Optional[Any]: + """환경 변수 값을 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 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'Invalid "relative_to" parameter value.' + ) from e + + # 디렉토리인지 검증 + if not relative_to_path.is_dir(): + raise ValueError( + f'The "relative_to" parameter must be a directory, got: {relative_to_path}' + ) + + 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 _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 + + return value.lower() in _TRUTHY_VALUES + + +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 + + return value.lower() in _FALSY_VALUES diff --git a/app/app/settings.py b/app/app/settings.py index e5a8992..00f70d5 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -12,20 +12,59 @@ 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/ # 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") +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") +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(): + raise ValueError(f"SECRET_KEY_FILE is not a file: {secret_key_file}") + + # Fail-fast, 명확한 에러 추적을 위해 파일 읽기 중 오류는 예외 처리를 하지 않음. + 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}") +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 = True +DEBUG = env.get_bool('DEBUG', default=False) + -ALLOWED_HOSTS = [] +# Allow all hosts during development, require explicit hosts in production. +if DEBUG: + ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', + default=['localhost', '127.0.0.1', '[::1]']) +else: + ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', required=True) # Application definition @@ -74,6 +113,7 @@ # Database # https://docs.djangoproject.com/en/5.2/ref/settings/#databases +# TODO: Use PostgreSQL for production. DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', diff --git a/app/app/test_env.py b/app/app/test_env.py new file mode 100644 index 0000000..aab8672 --- /dev/null +++ b/app/app/test_env.py @@ -0,0 +1,457 @@ +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 + + +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: + 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') + + 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') + + @patch.dict(os.environ, {}, clear=True) + 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') + + @patch.dict(os.environ, {}, clear=True) + 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') + + +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), '') + + +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') + + +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}) + + +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': 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, (settings.BASE_DIR / '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 = settings.BASE_DIR / 'default' + self.assertEqual(env.get_path('MISSING_VAR', default=default_path, required=True), + default_path) 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