diff --git a/server/controllers/api/auth/user.py b/server/controllers/api/auth/user.py index 6481f3f0..d7248d23 100644 --- a/server/controllers/api/auth/user.py +++ b/server/controllers/api/auth/user.py @@ -14,12 +14,14 @@ allow_when_password_required, api_authenticated, no_live_session, + redact_data_paths, require_admin, ) @ApiRoute("auth/create", ApiVersion.V1) class UserCreateController(BaseAPIController): + @redact_data_paths(paths=["/password", "/confirmPassword"]) async def post(self): with self.make_session() as session: # If there are no users, allow creation without authentication, otherwise require admin. @@ -139,6 +141,7 @@ async def post(self): @ApiRoute("auth/login", ApiVersion.V1) class LoginHandler(BaseAPIController): + @redact_data_paths(paths=["/password"]) async def post(self): data = escape.json_decode(self.request.body) @@ -274,6 +277,7 @@ class PasswordChangeController(BaseAPIController): @api_authenticated @allow_when_password_required + @redact_data_paths(paths=["/old_password", "/new_password"]) async def patch(self): """ Change authenticated user's password. diff --git a/server/digi_server/settings.py b/server/digi_server/settings.py index b23b49d6..2f582c50 100644 --- a/server/digi_server/settings.py +++ b/server/digi_server/settings.py @@ -230,6 +230,14 @@ def __init__(self, application: DigiScriptServer, settings_path=None): self._application.regen_logging, display_name="Log Backups", ) + self.define( + "log_redaction", + bool, + False, + True, + display_name="Enable Log Redaction", + help_text="When enabled, potentially sensitive information will be redacted from logs.", + ) self.define( "db_log_enabled", bool, diff --git a/server/requirements.txt b/server/requirements.txt index cb7fec8a..51709168 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -11,4 +11,5 @@ marshmallow<5 pyjwt[crypto]==2.11.0 setuptools==80.10.2 xkcdpass==1.30.0 -zeroconf==0.148.0 \ No newline at end of file +zeroconf==0.148.0 +python-jsonpath==2.0.2 \ No newline at end of file diff --git a/server/utils/web/base_controller.py b/server/utils/web/base_controller.py index 8fff65c8..3ecd84f7 100644 --- a/server/utils/web/base_controller.py +++ b/server/utils/web/base_controller.py @@ -1,5 +1,6 @@ from __future__ import annotations +from copy import deepcopy from typing import TYPE_CHECKING, Any, Awaitable, Optional import bcrypt @@ -179,14 +180,25 @@ def on_finish(self): log_method = get_logger().debug if self.request.body: + method_name = self.request.method.lower() + handler_method = getattr(self, method_name, None) + redacted_data_paths = getattr(handler_method, "_redacted_data_paths", None) try: - log_method( - f"{self.request.method} " - f"{self.request.path} " - f"{escape.json_decode(self.request.body)}" - ) + body = escape.json_decode(self.request.body) except BaseException: get_logger().debug( f"{self.request.method} {self.request.path} {self.request.body}" ) + else: + if ( + redacted_data_paths + and self.application.digi_settings.settings[ + "log_redaction" + ].get_value() + ): + body = deepcopy(body) + redacted_data_paths.apply(body) + + log_method(f"{self.request.method} {self.request.path} {body}") + super().on_finish() diff --git a/server/utils/web/web_decorators.py b/server/utils/web/web_decorators.py index bdd9d877..0ba3862d 100644 --- a/server/utils/web/web_decorators.py +++ b/server/utils/web/web_decorators.py @@ -1,6 +1,7 @@ import functools -from typing import Awaitable, Callable, Optional +from typing import Awaitable, Callable, List, Optional +from jsonpath import JSONPatch from tornado.web import HTTPError from utils.web.base_controller import BaseController @@ -77,3 +78,27 @@ def wrapper(self: BaseController, *args, **kwargs) -> Optional[Awaitable[None]]: # Mark the wrapper with an attribute so prepare() can detect it wrapper._allow_when_password_required = True # type: ignore return wrapper + + +def redact_data_paths( + paths: List[str], +) -> Callable[ + [Callable[..., Optional[Awaitable[None]]]], Callable[..., Optional[Awaitable[None]]] +]: + patch = None + if paths: + patch = JSONPatch() + for path in paths: + patch.replace(path, "<-- REDACTED -->") + + def decorator( + method: Callable[..., Optional[Awaitable[None]]], + ) -> Callable[..., Optional[Awaitable[None]]]: + @functools.wraps(method) + def wrapper(self: BaseController, *args, **kwargs) -> Optional[Awaitable[None]]: + return method(self, *args, **kwargs) + + wrapper._redacted_data_paths = patch + return wrapper + + return decorator