-
Notifications
You must be signed in to change notification settings - Fork 0
fix: reject absolute paths in directory env vars #148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -233,17 +233,35 @@ def _parse_llm_providers() -> list[ProviderConfig]: | |
| # Flask secret key – override via SECRET_KEY environment variable in production | ||
| SECRET_KEY = os.environ.get("SECRET_KEY", "change-me-in-production") | ||
|
|
||
|
|
||
| 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." | ||
| ) | ||
| return str(PROJECT_ROOT / raw) | ||
|
||
|
|
||
|
|
||
| # Directory where Flask-Session stores server-side session files | ||
| SESSION_FILE_DIR = str(PROJECT_ROOT / os.environ.get("SESSION_FILE_DIR", "sessions/flask")) | ||
| SESSION_FILE_DIR = _resolve_dir("SESSION_FILE_DIR", "sessions/flask") | ||
|
|
||
| # Directory where exported novel files are stored temporarily | ||
| EXPORT_DIR = str(PROJECT_ROOT / os.environ.get("EXPORT_DIR", "exports")) | ||
| EXPORT_DIR = _resolve_dir("EXPORT_DIR", "exports") | ||
|
|
||
| # Directory where novel session JSON files are stored | ||
| NOVELS_DIR = str(PROJECT_ROOT / os.environ.get("NOVELS_DIR", "sessions/novels")) | ||
| NOVELS_DIR = _resolve_dir("NOVELS_DIR", "sessions/novels") | ||
|
|
||
| # Directory for log files | ||
| LOGS_DIR = str(PROJECT_ROOT / os.environ.get("LOGS_DIR", "logs")) | ||
| LOGS_DIR = _resolve_dir("LOGS_DIR", "logs") | ||
|
|
||
|
|
||
| class ConfigurationError(Exception): | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,50 @@ | |||||||||||||
| import novelforge.config as cfg | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| # --------------------------------------------------------------------------- | ||||||||||||||
| # _resolve_dir helper | ||||||||||||||
| # --------------------------------------------------------------------------- | ||||||||||||||
|
|
||||||||||||||
| class TestResolveDir: | ||||||||||||||
| """Unit tests for the _resolve_dir() helper.""" | ||||||||||||||
|
|
||||||||||||||
| def test_returns_project_root_relative_path_when_unset(self, monkeypatch): | ||||||||||||||
| monkeypatch.delenv("NF_TEST_DIR", raising=False) | ||||||||||||||
| result = cfg._resolve_dir("NF_TEST_DIR", "my/subdir") | ||||||||||||||
| assert result == str(cfg.PROJECT_ROOT / "my/subdir") | ||||||||||||||
|
|
||||||||||||||
| def test_returns_project_root_relative_path_for_relative_env_var(self, monkeypatch): | ||||||||||||||
| monkeypatch.setenv("NF_TEST_DIR", "custom/path") | ||||||||||||||
| result = cfg._resolve_dir("NF_TEST_DIR", "default/path") | ||||||||||||||
| assert result == str(cfg.PROJECT_ROOT / "custom/path") | ||||||||||||||
|
|
||||||||||||||
| def test_raises_for_absolute_path(self, monkeypatch): | ||||||||||||||
| monkeypatch.setenv("NF_TEST_DIR", "/etc/passwd") | ||||||||||||||
| with pytest.raises(ValueError, match="NF_TEST_DIR"): | ||||||||||||||
| cfg._resolve_dir("NF_TEST_DIR", "default") | ||||||||||||||
|
|
||||||||||||||
|
||||||||||||||
| 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") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.