diff --git a/.jules/sentinel.md b/.jules/sentinel.md index c1caaeb..cd88ac0 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -6,3 +6,8 @@ **Vulnerability:** The custom `SafeStaticFiles` middleware in `src/audioformation/server/app.py` intended to block access to sensitive directories (like `00_CONFIG` and `.git`). However, it used `p = Path(path).lower()`, which raises an `AttributeError` because `pathlib.Path` objects lack a `.lower()` method. This effectively broke static file serving entirely (causing 500 errors) and represented a malformed security check. If such errors were ever 'swallowed' without raising an HTTP exception, it could result in 'failing open' and allowing access to sensitive files. **Learning:** Security checks that rely on path manipulation or normalization must be carefully tested for runtime exceptions. An unhandled exception in a security gate can either block legitimate traffic (Denial of Service) or, if caught improperly elsewhere, fail open. Always normalize the string representation of paths before converting them to `Path` objects. **Prevention:** Thoroughly test security middleware endpoints for both valid and invalid access attempts. Ensure that path string normalizations like `.lower()` are applied directly to the string before instantiating `Path(str(path).lower())`. + +## 2024-05-27 - Fail Closed When Handling `None` paths and Hidden Files in StaticFiles +**Vulnerability:** The custom `SafeStaticFiles` implementation used for static file serving was explicitly checking for specific sensitive files/directories (like `.env`, `.git`) but not generally blocking *all* hidden files or resolving when a file name might implicitly have a dot prefix. In addition, when path resolution handlers like `validate_path_within` received `None` (for example, if a path component was missing or malformed), they might throw unexpected `TypeError` or `AttributeError` exceptions instead of properly failing closed and raising `False` or an HTTP 400. +**Learning:** Hardcoding a blocklist of hidden files (e.g. `.env`, `.git`) is a losing battle because it does not cover other sensitive hidden artifacts (e.g. `.htpasswd`, `.DS_Store`, `.ssh`). Additionally, failure to catch type-level exceptions (`TypeError`, `AttributeError`) on `Path` object construction or resolution from unexpected external inputs (like `None` or integers instead of strings) can bypass standard path traversal or allow-list checks. +**Prevention:** Always use `any(part.startswith('.') for part in p.parts)` in `pathlib` parts to block all hidden files comprehensively. Ensure that path validation and security check functions (like `validate_path_within`) explicitly handle all unexpected data types (catching `TypeError`, `AttributeError`) to fail securely (`return False`) rather than crashing or failing open. diff --git a/src/audioformation/server/app.py b/src/audioformation/server/app.py index 54243ef..4c56772 100644 --- a/src/audioformation/server/app.py +++ b/src/audioformation/server/app.py @@ -23,9 +23,12 @@ class SafeStaticFiles(StaticFiles): """ async def get_response(self, path: str, scope) -> Response: + if path is None: + raise HTTPException(status_code=400, detail="Path cannot be None") + # Normalize path for check p = Path(str(path).lower()) - if "00_config" in p.parts or p.name.startswith(".env") or ".git" in p.parts: + if "00_config" in p.parts or any(part.startswith(".") for part in p.parts): raise HTTPException( status_code=403, detail="Access denied to sensitive resource" ) diff --git a/src/audioformation/utils/security.py b/src/audioformation/utils/security.py index abcdc30..6a437cd 100644 --- a/src/audioformation/utils/security.py +++ b/src/audioformation/utils/security.py @@ -81,7 +81,7 @@ def validate_path_within(path: Path, root: Path) -> bool: return resolved_path.is_relative_to(resolved_root) return False - except (ValueError, RuntimeError, OSError): + except (TypeError, ValueError, RuntimeError, AttributeError, OSError): return False diff --git a/uv.lock b/uv.lock index e34eac4..7970295 100644 --- a/uv.lock +++ b/uv.lock @@ -270,7 +270,7 @@ requires-dist = [ { name = "jsonschema", specifier = ">=4.21,<5" }, { name = "midiutil", marker = "extra == 'midi'", specifier = ">=1.2,<2" }, { name = "mutagen", marker = "extra == 'm4b'", specifier = ">=1.47,<2" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0,<2" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0,<3" }, { name = "numpy", specifier = ">=1.26,<3" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.6,<5" }, { name = "pydub", specifier = ">=0.25,<1" }, @@ -280,7 +280,7 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0,<8" }, { name = "python-dotenv", marker = "extra == 'cloud'", specifier = ">=1.0,<2" }, { name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.27,<1" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.9" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.1" }, { name = "silero-vad", marker = "extra == 'vad'", specifier = ">=6.0,<7" }, { name = "soundfile", specifier = ">=0.12,<1" }, { name = "transformers", specifier = ">=4.44,<6" },