fix: reject absolute paths in directory env vars#148
Conversation
…XPORT_DIR, NOVELS_DIR, LOGS_DIR) Agent-Logs-Url: https://github.com/CyberSecDef/NovelForge/sessions/f34e38e7-05d1-4794-beb6-ad4b72be76ae Co-authored-by: CyberSecDef <17597068+CyberSecDef@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens NovelForge’s configuration by preventing directory-related environment variables from being set to absolute paths, avoiding Path join behavior that can redirect storage/log/session directories outside the project root.
Changes:
- Added a
_resolve_dir(env_var, default)helper innovelforge/config.pyto reject absolute paths and resolve directory env vars relative toPROJECT_ROOT. - Updated
SESSION_FILE_DIR,EXPORT_DIR,NOVELS_DIR, andLOGS_DIRto use_resolve_dir. - Added unit tests for
_resolve_dirbehavior intests/test_validate_config.py.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
novelforge/config.py |
Introduces _resolve_dir and routes directory configuration through it to prevent absolute-path env var overrides. |
tests/test_validate_config.py |
Adds unit tests validating _resolve_dir relative resolution and absolute-path rejection. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| def _resolve_dir(env_var: str, default: str) -> str: | ||
| """Return the absolute path for a directory env var, anchored to PROJECT_ROOT. | ||
|
|
||
| Raises: | ||
| ValueError: if the env var is set to an absolute path. All directory | ||
| env vars must be relative to *PROJECT_ROOT* to prevent arbitrary | ||
| filesystem access. | ||
| """ | ||
| raw = os.environ.get(env_var, default) | ||
| if os.path.isabs(raw): | ||
| raise ValueError( | ||
| f"{env_var} must be a relative path (got {raw!r}). " | ||
| "Directory env vars are resolved relative to the project root." | ||
| ) |
There was a problem hiding this comment.
Raising ValueError here happens at import time (these constants are set during module import). Since novelforge/init.py only catches ConfigurationError around validate_config(), an absolute dir env var will crash app startup with an uncaught exception and bypass the structured validation/logging path. Consider recording this as a config-parse error (similar to get_env_int) and surfacing it from validate_config() as ConfigurationError, or otherwise ensuring create_app/startup handles it cleanly.
| def _resolve_dir(env_var: str, default: str) -> str: | |
| """Return the absolute path for a directory env var, anchored to PROJECT_ROOT. | |
| Raises: | |
| ValueError: if the env var is set to an absolute path. All directory | |
| env vars must be relative to *PROJECT_ROOT* to prevent arbitrary | |
| filesystem access. | |
| """ | |
| raw = os.environ.get(env_var, default) | |
| if os.path.isabs(raw): | |
| raise ValueError( | |
| f"{env_var} must be a relative path (got {raw!r}). " | |
| "Directory env vars are resolved relative to the project root." | |
| ) | |
| # Configuration parse problems detected during module import. Callers can | |
| # surface these later via the normal validation/startup path. | |
| _CONFIG_PARSE_ERRORS: list[str] = [] | |
| def _resolve_dir(env_var: str, default: str) -> str: | |
| """Return the absolute path for a directory env var, anchored to PROJECT_ROOT. | |
| Absolute paths are rejected because directory env vars must stay relative | |
| to *PROJECT_ROOT*. To avoid crashing at import time, invalid values are | |
| recorded and logged, and the safe default is used instead. | |
| """ | |
| raw = os.environ.get(env_var, default) | |
| if os.path.isabs(raw): | |
| message = ( | |
| f"{env_var} must be a relative path (got {raw!r}). " | |
| "Directory env vars are resolved relative to the project root." | |
| ) | |
| _CONFIG_PARSE_ERRORS.append(message) | |
| logging.getLogger(__name__).error(message) | |
| raw = default |
| f"{env_var} must be a relative path (got {raw!r}). " | ||
| "Directory env vars are resolved relative to the project root." | ||
| ) | ||
| return str(PROJECT_ROOT / raw) |
There was a problem hiding this comment.
This still allows path traversal via relative values like "../.." (e.g., PROJECT_ROOT / "../etc"), which can escape PROJECT_ROOT while remaining non-absolute. If the goal is to prevent redirecting these dirs to arbitrary filesystem locations, normalize the joined path (e.g., resolve(strict=False)) and verify it stays within PROJECT_ROOT (Path.is_relative_to), rejecting anything that escapes.
| monkeypatch.setenv("NF_TEST_DIR", "/etc/passwd") | ||
| with pytest.raises(ValueError, match="NF_TEST_DIR"): | ||
| cfg._resolve_dir("NF_TEST_DIR", "default") | ||
|
|
There was a problem hiding this comment.
Tests cover absolute-path rejection, but they don’t cover traversal/escape cases such as "../outside" (which is relative but can resolve outside PROJECT_ROOT). Adding a regression test for that case would lock in the intended security property if _resolve_dir is tightened to enforce PROJECT_ROOT containment.
| def test_raises_for_relative_path_traversal_outside_project_root(self, monkeypatch): | |
| monkeypatch.setenv("NF_TEST_DIR", "../outside") | |
| with pytest.raises(ValueError, match="NF_TEST_DIR"): | |
| cfg._resolve_dir("NF_TEST_DIR", "default") |
Path.__truediv__silently discards the left operand when the right side is absolute (Path("/app") / "/etc/passwd"→/etc/passwd), allowingNOVELS_DIR,EXPORT_DIR,LOGS_DIR, andSESSION_FILE_DIRto be redirected to arbitrary filesystem locations.Changes
novelforge/config.py: Added_resolve_dir(env_var, default)helper that callsos.path.isabs()on the raw env var value and raisesValueErrorbefore any path join occurs. All four directory variables now use this helper instead of the inlinePROJECT_ROOT / os.environ.get(...)pattern.tests/test_validate_config.py: AddedTestResolveDircovering relative default, relative override, absolute path rejection (/etc/passwd,/), error message content, and return type.Warning
Firewall rules blocked me from connecting to one or more addresses (expand for details)
I tried to connect to the following addresses, but was blocked by firewall rules:
api.openai.com/usr/bin/python python -m pytest --tb=short -q(dns block)/usr/bin/python python -m pytest --tb=line -q(dns block)/usr/bin/python python -m pytest(dns block)If you need me to access, download, or install something from one of these locations, you can either: