Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/anthias_server/api/tests/test_v2_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -612,3 +612,57 @@ def test_display_power_returns_503_when_no_cec_adapter(
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
set_display_power_mock.assert_not_called()
assert 'adapter' in response.data['message']


@pytest.mark.django_db
@mock.patch('anthias_server.api.views.v2.settings')
def test_patch_device_settings_password_mismatch_is_not_logged_as_error(
settings_mock: Any,
api_client: APIClient,
device_settings_url: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A mismatched-password PATCH is operator input validation, not a
server bug. It must return 400 with the operator-friendly message
AND be logged at WARNING (no traceback) so Sentry's logging
integration doesn't turn it into an event (ANTHIAS-3D)."""
import logging

user = _make_operator(username='testuser', pwd=_FIXTURE_PASSWORD)
api_client.force_authenticate(user=user)

settings_mock.load = mock.MagicMock()
settings_mock.save = mock.MagicMock()
settings_mock.__getitem__.side_effect = lambda key: {
'auth_backend': 'auth_basic',
}[key]
settings_mock.__setitem__ = mock.MagicMock()

data = {
'auth_backend': 'auth_basic',
'current_password': _FIXTURE_PASSWORD,
'username': 'testuser',
'password': 'brand-new-password', # NOSONAR
'password_2': 'does-not-match', # NOSONAR
}

with caplog.at_level(logging.WARNING):
response = api_client.patch(
device_settings_url, data=data, format='json'
)

assert response.status_code == status.HTTP_400_BAD_REQUEST
# The operator-friendly message is echoed, not a generic string.
assert 'do not match' in response.data['error']
# Nothing was persisted — the validation aborted before save().
settings_mock.save.assert_not_called()

save_records = [
r for r in caplog.records if 'Settings save' in r.getMessage()
]
assert save_records, 'expected a log line for the rejected save'
# The rejection logs at WARNING, never ERROR, and carries no
# exc_info — an ERROR record (logger.exception) is what becomes a
# Sentry event.
assert all(r.levelno == logging.WARNING for r in save_records)
assert all(r.exc_info is None for r in save_records)
11 changes: 11 additions & 0 deletions src/anthias_server/api/views/v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
save_active_assets_ordering,
)
from anthias_server.lib.auth import (
AuthSettingsError,
apply_auth_settings,
operator_username,
)
Expand Down Expand Up @@ -696,6 +697,16 @@
publisher.send_to_viewer('reload')

return Response({'message': 'Settings were successfully saved.'})
except AuthSettingsError as exc:
# Operator input the client should have caught — mismatched
# or incorrect password, a taken username, a too-weak
# password. Expected and self-correcting, so log at warning
# (no traceback) and echo the operator-friendly message
# instead of logger.exception + a generic error: the
# ERROR-level record is what Sentry's logging integration
# turns into an event (ANTHIAS-3D).
logger.warning('Settings save rejected: %s', exc)
return Response({'error': str(exc)}, status=400)

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.
Comment thread
vpetersson marked this conversation as resolved.
Dismissed
except Exception:
logger.exception('Settings save failed')
return Response(
Expand Down
10 changes: 10 additions & 0 deletions src/anthias_server/app/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from anthias_server.celery_tasks import reboot_anthias, shutdown_anthias
from anthias_server.lib import backup_helper, diagnostics
from anthias_server.lib.auth import (
AuthSettingsError,
apply_auth_settings,
authorized,
)
Expand Down Expand Up @@ -1265,6 +1266,15 @@ def settings_save(request: HttpRequest) -> HttpResponse:
ViewerPublisher.get_instance().send_to_viewer('reload')

messages.success(request, 'Settings were successfully saved.')
except AuthSettingsError as exc:
# Operator input the form should have caught — mismatched or
# incorrect password, a taken username, a too-weak password.
# Expected, self-correcting, and already surfaced to the
# operator, so log at warning (no traceback) instead of
# logger.exception; the ERROR-level record is what Sentry's
# logging integration turns into an event (ANTHIAS-3D).
logger.warning('Settings save rejected: %s', exc)
messages.error(request, str(exc) or 'Failed to save settings.')
except Exception as exc:
logger.exception('Settings save failed')
messages.error(request, str(exc) or 'Failed to save settings.')
Expand Down
17 changes: 17 additions & 0 deletions src/anthias_server/django_project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,22 @@ def _sentry_before_send(event: Event, hint: Hint) -> Event | None:
* ``asyncio.CancelledError`` — an HTTP client hanging up
mid-request under ASGI; Django/uvicorn cancel the handler by
design (Sentry ANTHIAS-N).
* ``AuthSettingsError`` — an operator-facing validation failure
from the settings-save flow (mismatched/incorrect password,
a taken username, a too-weak password). It is expected input
validation, not a bug: the message is already shown to the
operator and the next attempt self-corrects. The save views
now log it at warning rather than ``logger.exception`` so it
never reaches the logging integration in the first place; this
is the backstop for any other path that logs it as an error
(Sentry ANTHIAS-3D).
"""
# Imported lazily — this runs only when an event is about to send,
# well after Django is configured, and avoids an import cycle at
# settings-module load. ``lib.auth`` only pulls stdlib at import
# time, so the cost is a cached module lookup.
from anthias_server.lib.auth import AuthSettingsError

exc_info = hint.get('exc_info')
if not exc_info:
return event
Expand All @@ -134,6 +149,8 @@ def _sentry_before_send(event: Event, hint: Hint) -> Event | None:
return None
if isinstance(exc, transient_redis):
return None
if isinstance(exc, AuthSettingsError):
return None
return event


Expand Down
16 changes: 14 additions & 2 deletions src/anthias_server/lib/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,15 @@
import os.path
import re
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, ParamSpec, TypeVar, cast
from typing import (
TYPE_CHECKING,
Any,
Callable,
ParamSpec,
TypeAlias,
TypeVar,
cast,
)

if TYPE_CHECKING:
from django.contrib.auth.models import User
Expand All @@ -82,7 +90,11 @@
# Request wraps the underlying Django request and delegates
# ``.user``, so the body of these helpers handles both shapes
# the same way; the annotation just needs to admit either one.
AnyRequest = HttpRequest | DRFRequest
# Spelled as an explicit ``TypeAlias`` so mypy always resolves the
# forward-ref ``'AnyRequest'`` as a type rather than a module
# variable — the implicit form flipped to "not valid as a type"
# once settings.py started importing this module (ANTHIAS-3D).
AnyRequest: TypeAlias = HttpRequest | DRFRequest

P = ParamSpec('P')
R = TypeVar('R')
Expand Down
14 changes: 14 additions & 0 deletions tests/test_sentry.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,20 @@ def test_drops_cancelled_error(self) -> None:
hint = self._hint_for(asyncio.CancelledError())
assert _sentry_before_send({'event_id': 'x'}, hint) is None

def test_drops_auth_settings_error(self) -> None:
# AuthSettingsError is operator-facing input validation from
# the settings-save flow (mismatched password, taken username,
# weak password), not a bug. The save views log it at warning
# so it never reaches the logging integration; this is the
# backstop for any other path (Sentry ANTHIAS-3D).
from anthias_server.django_project.settings import (
_sentry_before_send,
)
from anthias_server.lib.auth import AuthSettingsError

hint = self._hint_for(AuthSettingsError('New passwords do not match!'))
assert _sentry_before_send({'event_id': 'x'}, hint) is None

def test_keeps_ordinary_exceptions(self) -> None:
from anthias_server.django_project.settings import (
_sentry_before_send,
Expand Down