mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 16:47:01 +09:00
fix(notifications): scope chat credential re-encryption to supplied fields
Follow-up to PR #94. ChatWebhookService.update() decrypted every stored credential and re-encrypted the lot on any credential-bearing request. decrypt_secret_safe() returns its input unchanged when a value will not decrypt, so a wrong-key or not-yet-migrated row got wrapped a second time and became unrecoverable -- and it happened to fields the request never named. It now patches the ciphertext dict directly and encrypts only what it assigns, so untouched credentials survive byte-identical. Also tolerate an unchanged is_default in a round-tripped body (to_dict() always emits it, so GET -> edit -> PUT rejected an untouched field), and record the delivery failure reason in the server log. The reason stays out of the HTTP response: transport errors embed the full webhook URL and any Telegram bot token. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7a091d440d
commit
5bbd4533f1
3 files changed
+76
-13
No files matched your search
@@ -104,7 +104,7 @@ class ChatWebhookService:
|
||||
kind = (data.get('kind') or '').strip().lower()
|
||||
if kind != conn.kind:
|
||||
raise ValueError('connection kind cannot be changed')
|
||||
if 'is_default' in data:
|
||||
if 'is_default' in data and bool(data.get('is_default')) != bool(conn.is_default):
|
||||
raise ValueError('use the default endpoint to change is_default')
|
||||
|
||||
new_name = conn.name
|
||||
@@ -132,40 +132,41 @@ class ChatWebhookService:
|
||||
field in data for field in ('secret', 'chat_id', 'bot_token')
|
||||
)
|
||||
if credential_supplied:
|
||||
credentials = conn.credentials()
|
||||
# Work on the stored ciphertext so untouched credentials are carried
|
||||
# across byte-identical. Decrypting them first would re-wrap values
|
||||
# that ``decrypt_secret_safe`` handed back unchanged (wrong key, or
|
||||
# a not-yet-migrated plaintext row), corrupting them permanently.
|
||||
credentials = conn.raw_credentials()
|
||||
if conn.kind == 'telegram':
|
||||
if 'chat_id' in data:
|
||||
chat_id = str(data.get('chat_id') or '').strip()
|
||||
if not chat_id:
|
||||
raise ValueError('telegram connection requires a chat_id')
|
||||
credentials['chat_id'] = chat_id
|
||||
credentials['chat_id'] = encrypt_secret(chat_id)
|
||||
if 'bot_token' in data:
|
||||
bot_token = data.get('bot_token')
|
||||
if bot_token in (None, ''):
|
||||
credentials.pop('bot_token', None)
|
||||
else:
|
||||
credentials['bot_token'] = str(bot_token)
|
||||
credentials['bot_token'] = encrypt_secret(str(bot_token))
|
||||
else:
|
||||
if url_supplied:
|
||||
url_value = data.get('url') if 'url' in data else data.get('webhook_url')
|
||||
url = str(url_value or '').strip()
|
||||
if not url:
|
||||
raise ValueError(f'{conn.kind} connection requires a url')
|
||||
credentials['url'] = url
|
||||
credentials['url'] = encrypt_secret(url)
|
||||
if 'secret' in data:
|
||||
secret = data.get('secret')
|
||||
if secret in (None, ''):
|
||||
credentials.pop('secret', None)
|
||||
else:
|
||||
credentials['secret'] = str(secret)
|
||||
credentials['secret'] = encrypt_secret(str(secret))
|
||||
|
||||
required = 'chat_id' if conn.kind == 'telegram' else 'url'
|
||||
if not credentials.get(required): # pragma: no cover - corrupt legacy row
|
||||
raise ValueError(f'{conn.kind} connection requires a {required}')
|
||||
new_credentials_json = json.dumps({
|
||||
key: encrypt_secret(str(value))
|
||||
for key, value in credentials.items()
|
||||
})
|
||||
new_credentials_json = json.dumps(credentials)
|
||||
|
||||
conn.name = new_name
|
||||
conn.categories_json = new_categories_json
|
||||
@@ -277,6 +278,7 @@ class ChatWebhookService:
|
||||
)
|
||||
notification.set_data({'message': message})
|
||||
|
||||
delivery_result = None
|
||||
try:
|
||||
credentials = conn.credentials()
|
||||
if conn.kind == 'webhook':
|
||||
@@ -287,6 +289,11 @@ class ChatWebhookService:
|
||||
conn, credentials, notification)
|
||||
success = delivery_result.ok
|
||||
except Exception: # pragma: no cover - defensive formatter guard
|
||||
logger.exception(
|
||||
'Chat connection test raised (id=%s, kind=%s)',
|
||||
conn.id,
|
||||
conn.kind,
|
||||
)
|
||||
success = False
|
||||
|
||||
conn.last_tested_at = datetime.utcnow()
|
||||
@@ -295,10 +302,13 @@ class ChatWebhookService:
|
||||
|
||||
if success:
|
||||
return {'success': True, 'message': 'Test notification sent'}
|
||||
# The reason stays server-side: transport errors embed the full webhook
|
||||
# URL (secret path segment included) and Telegram bot tokens.
|
||||
logger.warning(
|
||||
'Chat connection test failed (id=%s, kind=%s)',
|
||||
'Chat connection test failed (id=%s, kind=%s, reason=%s)',
|
||||
conn.id,
|
||||
conn.kind,
|
||||
getattr(delivery_result, 'error', None),
|
||||
)
|
||||
return {'success': False, 'error': 'Test notification failed'}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
3234
|
||||
3262
|
||||
@@ -16,7 +16,7 @@ from app.models import User
|
||||
from app.models.chat_webhook import ChatWebhookConnection
|
||||
from app.models.notification_preferences import NotificationPreferences
|
||||
from app.notifications.consumer import process_message
|
||||
from app.notifications.models import NotificationDelivery
|
||||
from app.notifications.models import Notification, NotificationDelivery
|
||||
from app.notifications.service import GROUP_SLUG, QUEUE_SLUG, NotificationBusService
|
||||
from app.queue_bus.service import QueueBusService
|
||||
from app.services.chat_webhook_service import ChatWebhookService
|
||||
@@ -114,6 +114,57 @@ class TestCrud:
|
||||
assert updated.raw_credentials()['url'] != 'https://hooks.example/new'
|
||||
assert updated.raw_credentials()['secret'] != 'new-secret'
|
||||
|
||||
def test_update_carries_untouched_credential_across_untranslated(self, app):
|
||||
"""An untouched credential keeps its exact stored ciphertext.
|
||||
|
||||
Fernet is non-deterministic, so re-encrypting an unchanged plaintext
|
||||
would visibly rewrite the column. Rewriting is how a value that failed
|
||||
to decrypt (wrong key, or a legacy plaintext row) gets double-wrapped
|
||||
and lost, so the byte-for-byte check is the guard against that.
|
||||
"""
|
||||
conn = ChatWebhookService.add({
|
||||
'kind': 'webhook',
|
||||
'name': 'Ops',
|
||||
'url': 'https://hooks.example/old',
|
||||
'secret': 'keep-me',
|
||||
})
|
||||
secret_before = conn.raw_credentials()['secret']
|
||||
|
||||
updated = ChatWebhookService.update(conn.id, {
|
||||
'url': 'https://hooks.example/new',
|
||||
})
|
||||
|
||||
assert updated.raw_credentials()['secret'] == secret_before
|
||||
assert updated.credentials() == {
|
||||
'url': 'https://hooks.example/new',
|
||||
'secret': 'keep-me',
|
||||
}
|
||||
|
||||
def test_update_accepts_a_round_tripped_unchanged_is_default(self, app):
|
||||
"""to_dict() always emits is_default, so GET -> edit -> PUT sends it."""
|
||||
conn = ChatWebhookService.add({
|
||||
'kind': 'webhook',
|
||||
'name': 'Ops',
|
||||
'url': 'https://hooks.example/ops',
|
||||
})
|
||||
body = conn.to_dict()
|
||||
body['name'] = 'Ops renamed'
|
||||
|
||||
updated = ChatWebhookService.update(conn.id, body)
|
||||
|
||||
assert updated.name == 'Ops renamed'
|
||||
assert updated.is_default is True
|
||||
|
||||
def test_update_still_rejects_an_actual_is_default_change(self, app):
|
||||
conn = ChatWebhookService.add({
|
||||
'kind': 'webhook',
|
||||
'name': 'Ops',
|
||||
'url': 'https://hooks.example/ops',
|
||||
})
|
||||
|
||||
with pytest.raises(ValueError, match='default endpoint'):
|
||||
ChatWebhookService.update(conn.id, {'is_default': False})
|
||||
|
||||
def test_update_clears_explicitly_empty_optional_credential(self, app):
|
||||
conn = ChatWebhookService.add({
|
||||
'kind': 'telegram',
|
||||
@@ -309,6 +360,8 @@ class TestConnectionTesting:
|
||||
)
|
||||
assert conn.last_tested_at is not None
|
||||
assert conn.last_test_ok is True
|
||||
# The test notification is transient: it must never reach the bell feed.
|
||||
assert Notification.query.count() == 0
|
||||
|
||||
def test_failed_connection_test_persists_failure(self, app, monkeypatch):
|
||||
class _Resp:
|
||||
|
||||
Reference in new issue
Block a user