diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 000000000..e0b8ee367 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2024-05-23 - Secure File Permissions +**Vulnerability:** Sensitive files (`cookies.jar`, `settings.json`, `log.txt`, `dump.dat`) were created with default umask permissions (often `0o664`), allowing other users on the system to read potentially sensitive data (like session tokens) or modify them (RCE risk with `pickle` in `cookies.jar`). +**Learning:** `aiohttp.CookieJar` uses `pickle` for serialization by default, which is an RCE vector if the file is writable by others. Default file creation in Python follows the system umask, which is often permissive. +**Prevention:** Explicitly restrict file permissions to `0o600` (Read/Write by Owner only) for sensitive files immediately after creation or before access. Use `os.chmod` on POSIX systems. diff --git a/main.py b/main.py index d924f3474..136114dd6 100644 --- a/main.py +++ b/main.py @@ -26,7 +26,7 @@ from settings import Settings from version import __version__ from exceptions import CaptchaRequired - from utils import lock_file, resource_path, set_root_icon + from utils import lock_file, resource_path, set_root_icon, set_secure_permissions from constants import LOGGING_LEVELS, SELF_PATH, FILE_FORMATTER, LOG_PATH, LOCK_PATH if TYPE_CHECKING: @@ -146,6 +146,7 @@ async def main(): logger.setLevel(settings.logging_level) if settings.log: handler = logging.FileHandler(LOG_PATH) + set_secure_permissions(LOG_PATH) handler.setFormatter(FILE_FORMATTER) logger.addHandler(handler) logging.getLogger("TwitchDrops.gql").setLevel(settings.debug_gql) diff --git a/twitch.py b/twitch.py index 082c0931d..f42ccab75 100644 --- a/twitch.py +++ b/twitch.py @@ -39,6 +39,7 @@ RateLimiter, AwaitableValue, ExponentialBackoff, + set_secure_permissions, ) from constants import ( CALL, @@ -461,6 +462,7 @@ async def get_session(self) -> aiohttp.ClientSession: cookie_jar = aiohttp.CookieJar() try: if COOKIES_PATH.exists(): + set_secure_permissions(COOKIES_PATH) cookie_jar.load(COOKIES_PATH) except Exception: # if loading in the cookies file ends up in an error, just ignore it @@ -507,6 +509,7 @@ async def shutdown(self) -> None: if not cookie: del cookie_jar._cookies[cookie_key] cookie_jar.save(COOKIES_PATH) + set_secure_permissions(COOKIES_PATH) await self._session.close() self._session = None self._drops.clear() @@ -588,6 +591,7 @@ async def run(self): with open(DUMP_PATH, 'w', encoding="utf8"): # replace the existing file with an empty one pass + set_secure_permissions(DUMP_PATH) while True: try: await self._run() diff --git a/utils.py b/utils.py index 02c3f2b6d..056c6fa27 100644 --- a/utils.py +++ b/utils.py @@ -249,9 +249,21 @@ def json_load(path: Path, defaults: _JSON_T, *, merge: bool = True) -> _JSON_T: return cast(_JSON_T, combined) +def set_secure_permissions(path: Path) -> None: + """ + Sets file permissions to 600 (read/write by owner only) on POSIX systems. + """ + if os.name == "posix": + try: + path.chmod(0o600) + except OSError: + logger.warning(f"Failed to set secure permissions on {path}") + + def json_save(path: Path, contents: Mapping[Any, Any], *, sort: bool = False) -> None: with open(path, 'w', encoding="utf8") as file: json.dump(contents, file, default=_serialize, sort_keys=sort, indent=4) + set_secure_permissions(path) def webopen(url: URL | str):