* docs(security): document DNS-rebinding TOCTOU window in notification SSRF The notification URL validator (PR #3092 / #3311) resolves hostnames once at validation time and checks resolved IPs against private ranges, but Apprise re-resolves at send time -- a DNS-rebinding attacker can serve a public IP at validation and a private IP at send. Apprise exposes no DNS/Session hook to close this in code without fragile monkey-patching of its plugin internals. Given LDR's threat model (single-tenant local app, @login_required on settings routes, per-user encrypted SQLCipher DBs), the residual risk is acceptable as long as it's visible. This change makes it visible: - Updated the inline comment in NotificationURLValidator._is_private_ip to describe the TOCTOU window and recommend plugin schemes (discord://, slack://, ntfy://, etc.) over raw http(s):// webhooks. - Added a parallel comment in ssrf_validator.validate_url, since safe_requests has the same pattern. - Added a "Notification Webhook SSRF" subsection to SECURITY.md with the rebinding window, the rationale for not closing it in code, the threat-model factors that make it acceptable, and operator-side mitigations (prefer plugin schemes, restrict egress). No behavior change. * feat(notifications): default-off + env-only master switch (LDR_NOTIFICATIONS_ENABLED) Outbound notifications via Apprise carry a known DNS-rebinding TOCTOU window: the URL validator resolves once at config time, but Apprise re-resolves at send time, and a logged-in user with a controllable domain can rebind to internal services on the LDR server (e.g. 127.0.0.1:<internal-port>) or the local network. The window cannot be closed in code without fragile monkey-patching of Apprise's plugin internals (HTTPS-only, doesn't follow redirects). Since LDR is multi-user (per-user SQLCipher DBs behind @login_required), the right default is to keep outbound notifications off until the operator explicitly opts in -- a server-level decision, not something a logged-in user can flip via the settings API. Changes: - Add notifications.enabled env-only setting (default False), registered alongside notifications.allow_private_ips in env_definitions/security.py. Auto-mapped to LDR_NOTIFICATIONS_ENABLED. - NotificationManager reads the env at __init__ and gates send_notification before any other check; force=True bypasses per-user toggles only, never the operator switch. - NotificationService now takes enabled=False; test_service refuses with a clear error pointing at LDR_NOTIFICATIONS_ENABLED. The settings route /api/notifications/test-url passes the env-read value through. - Refresh inline TOCTOU comment in NotificationURLValidator._is_private_ip to reflect the new gate, and add a parallel comment near getaddrinfo in ssrf_validator.py for cross-cutting consistency (same TOCTOU pattern). - Rewrite the SECURITY.md "Notification Webhook SSRF" subsection: lead with "disabled by default", explain how to enable, document the residual risk operators are accepting when they flip the switch. - Tests: - tests/notifications/conftest.py autouse-enables the gate so existing tests exercising the inner logic still work. - TestMasterSwitchEnvGate covers the gate behavior explicitly: env unset => send_notification returns False (even with force=True), test_service returns a disabled error. - TestNotificationManager in test_notification_coverage.py gets a class-scoped autouse fixture for the same reason. - Existing NotificationService(...) calls in tests pass enabled=True so their inner-logic assertions keep working. This is a behavior change. Existing users with notifications working will need to set LDR_NOTIFICATIONS_ENABLED=true on upgrade. * fix(notifications): rename env gate to allow_outbound + clearer logs + docs Two issues with the previous commit: 1. Key collision. The env gate was named notifications.enabled, which is already a (currently dormant) per-user DB setting in default_settings.json. Renaming the env-only setting to notifications.allow_outbound (env: LDR_NOTIFICATIONS_ALLOW_OUTBOUND) keeps the two layers distinct. Symmetric with the existing notifications.allow_private_ips env-only setting. 2. Log levels. The gate-closed paths logged at DEBUG, which is invisible under default log configuration. An operator wondering why notifications aren't firing wouldn't see the actionable signal. Upgrade to WARNING with messages that explicitly name the env var and point at SECURITY.md. Also: - Regenerate docs/CONFIGURATION.md (auto-generated from env definitions + default_settings.json) so LDR_NOTIFICATIONS_ALLOW_OUTBOUND appears in the env-only table at line 52. - Add a "Server-Side Opt-In Required" section at the top of docs/NOTIFICATIONS.md, including the symptoms an operator would see when the gate is closed (so debugging "why isn't this working?" is a one-step lookup). - Rename NotificationService kwarg enabled -> outbound_allowed and the manager's self._notifications_enabled -> self._outbound_allowed for internal consistency with the new setting name. - Update tests + conftest accordingly. 507 tests pass, pre-commit clean. * fix(notifications): defense-in-depth gate in service.send() + module-scope test fixture Two non-blocker recommendations from code review on PR #3675: 1. service.send() did not enforce the outbound_allowed gate itself -- the manager always wraps it, but a future direct caller could bypass. Add the same WARNING-level guard at the top of send() that test_service already has, so the security boundary lives at the service layer (one place) instead of relying on call-chain discipline. 2. Promote tests/web/services/test_notification_coverage.py's autouse gate-opening fixture from class-scope (TestNotificationManager only) to module-scope, so any future test class added to the file picks it up automatically. Drop the now-redundant class-scoped duplicate. Tests: - TestSendOutboundGate in tests/notifications/test_service.py covers the new gate: outbound_allowed=False => send() returns False without touching Apprise (.notify and .add must not be called); gate open => the existing send path runs. - _make_service helper in test_service_extra_coverage.py now sets outbound_allowed=True so the SSRF/Apprise-failure tests exercise the inner logic, not the gate. 509 passed, 1 skipped, pre-commit clean.
15 KiB
Notifications System
The Local Deep Research (LDR) notifications system provides a flexible way to send notifications to various services when important events occur, such as research completion, failures, or subscription updates.
Overview
The notification system uses Apprise to support multiple notification services with a unified API. It allows users to configure comma-separated service URLs to receive notifications for different events.
Server-Side Opt-In Required
Outbound notifications are disabled by default. The deployment operator must explicitly enable them by setting an environment variable on the server:
LDR_NOTIFICATIONS_ALLOW_OUTBOUND=trueWithout this, every
send_notificationcall returnsFalseand the "Send Test Notification" button returns an error. This applies to all users on the deployment.
Why?
Notification webhooks have a known DNS-rebinding TOCTOU window that cannot be closed in code: LDR validates the URL once when it is configured, but the underlying Apprise library resolves the hostname again at send time, and Apprise exposes no DNS/Session hook to pin the resolved IP. A logged-in user with a controllable domain can serve a public IP at validation and a private IP at send time, causing the LDR server to make outbound HTTP requests to its own internal services (e.g. 127.0.0.1:<internal-port>) or the local network.
Because LDR is multi-user (per-user encrypted SQLCipher databases behind @login_required), the right default is to keep this feature off until the operator explicitly opts in — flipping the env var is the operator's acknowledgement of the residual risk. See SECURITY.md for the full rationale and operator-side mitigations (prefer plugin schemes over raw http(s)://, restrict egress).
Symptoms when the gate is closed
If you've configured a notification URL and aren't receiving messages, check the server logs first. You should see lines like:
WARNING Notification refused: outbound notifications are disabled at the
server level. Set LDR_NOTIFICATIONS_ALLOW_OUTBOUND=true to enable.
See SECURITY.md 'Notification Webhook SSRF' for the rationale and
residual risk. (event=research_completed, user=...)
The "Send Test Notification" UI button returns the same message inline.
Supported Services
The system supports all services that Apprise supports, including but not limited to:
- Discord (via webhooks)
- Slack (via webhooks)
- Telegram
- Email (SMTP)
- Pushover
- Gotify
- Many more...
For a complete list, refer to the Apprise documentation.
Configuration
Notifications are configured per-user via the settings system:
Service URL Setting
- Key:
notifications.service_url - Type: String (comma-separated list of service URLs)
- Example:
discord://webhook_id/webhook_token,mailto://user:password@smtp.gmail.com - Security: Service URLs containing credentials are encrypted at rest using SQLCipher (AES-256) in your per-user encrypted database. The encryption key is derived from your login password, ensuring zero-knowledge security.
Event-Specific Settings
notifications.on_research_completed- Enable notifications for completed research (default: true)notifications.on_research_failed- Enable notifications for failed research (default: true)notifications.on_research_queued- Enable notifications when research is queued (default: false)notifications.on_subscription_update- Enable notifications for subscription updates (default: true)notifications.on_subscription_error- Enable notifications for subscription errors (default: false)notifications.on_api_quota_warning- Enable notifications for API quota/rate limit warnings (default: false)notifications.on_auth_issue- Enable notifications for authentication failures (default: false)
Rate Limiting Settings
notifications.rate_limit_per_hour- Max notifications per hour (per user, default: 10)notifications.rate_limit_per_day- Max notifications per day (per user, default: 50)
Per-User Rate Limiting: Each user can configure their own rate limits via their settings. Rate limits are enforced independently per user, so one user hitting their limit does not affect other users. This ensures fair resource allocation in multi-user deployments.
Note on Multi-Worker Deployments: The current rate limiting implementation uses in-memory storage and is process-local. In multi-worker deployments (e.g., gunicorn with multiple workers), each worker process maintains its own rate limit counters. This means a user could potentially send up to N × max_per_hour notifications (where N = number of workers) by distributing requests across different workers. For single-worker deployments (the default for LDR), this is not a concern. If you're running a multi-worker production deployment, consider monitoring notification volumes or implementing Redis-based rate limiting.
URL Configuration
app.external_url- Public URL where your LDR instance is accessible (e.g.,https://ldr.example.com). Used to generate clickable links in notifications. If not set, defaults tohttp://localhost:5000or auto-constructs fromapp.hostandapp.port.
Service URL Format
Multiple service URLs can be configured by separating them with commas:
discord://webhook1_id/webhook1_token,slack://token1/token2/token3,mailto://user:password@smtp.gmail.com
Each URL follows the Apprise format for the specific service.
Available Event Types
Research Events
research_completed- When research completes successfullyresearch_failed- When research fails (error details are sanitized in notifications for security)research_queued- When research is added to the queue
Subscription Events
subscription_update- When a subscription completessubscription_error- When a subscription fails
System Events
api_quota_warning- When API quota or rate limits are exceededauth_issue- When authentication fails for API services
Testing Notifications
Use the test function to verify notification configuration:
from local_deep_research.notifications.manager import NotificationManager
# Create manager for testing (user_id is required)
notification_manager = NotificationManager(
settings_snapshot={},
user_id="test_user"
)
# Test a service URL
result = notification_manager.test_service("discord://webhook_id/webhook_token")
print(result) # {'success': True, 'message': 'Test notification sent successfully'}
Programmatic Usage
For detailed code examples, see the source files in src/local_deep_research/notifications/.
Basic Notification
from local_deep_research.notifications.manager import NotificationManager
from local_deep_research.notifications.templates import EventType
from local_deep_research.settings import SettingsManager
from local_deep_research.database.session_context import get_user_db_session
# Get settings snapshot
username = "your_username"
with get_user_db_session(username, password) as session:
settings_manager = SettingsManager(session)
settings_snapshot = settings_manager.get_settings_snapshot()
# Create notification manager with user_id for per-user rate limiting
notification_manager = NotificationManager(
settings_snapshot=settings_snapshot,
user_id=username # Enables per-user rate limit configuration
)
# Send notification (user_id already set in manager)
notification_manager.send_notification(
event_type=EventType.RESEARCH_COMPLETED,
context={"query": "...", "summary": "...", "url": "/research/123"},
)
Important: The user_id parameter is required when creating a NotificationManager. This ensures the user's configured rate limits from their settings are properly applied and enforces per-user isolation.
Building Full URLs
Use build_notification_url() to convert relative paths to full URLs for clickable links in notifications.
Architecture
The notification system consists of three main components:
- NotificationManager - High-level manager that handles rate limiting, settings, and user preferences
- NotificationService - Low-level service that uses Apprise to send notifications
- Settings Integration - User-specific configuration for services and event preferences
The system fetches service URLs from user settings when needed, rather than maintaining persistent channels, making it more efficient and secure.
Security & Privacy
- Encrypted Storage: All notification service URLs (including credentials like SMTP passwords or webhook tokens) are stored encrypted at rest in your per-user SQLCipher database using AES-256 encryption.
- Zero-Knowledge Architecture: The encryption key is derived from your login password using PBKDF2-SHA512. Your password is never stored, and notification settings cannot be recovered without it.
- URL Masking: Service URLs are automatically masked in logs to prevent credential exposure (e.g.,
discord://webhook_id/***). - Per-User Isolation: Each user's notification settings are completely isolated in their own encrypted database.
Performance Optimizations
- Temporary Apprise Instances: Temporary Apprise instances are created for each send operation and automatically garbage collected by Python. This simple approach avoids memory management complexity.
- Shared Rate Limiter with Per-User Limits: A single rate limiter instance is shared across all NotificationManager instances for efficiency, while maintaining separate rate limit configurations and counters for each user. This provides both memory efficiency (~24 bytes per user for limit storage) and proper per-user isolation.
- Thread-Safe: The rate limiter uses threading locks for safe concurrent access within a single process.
- Exponential Backoff Retry: Failed notifications are retried up to 3 times with exponential backoff (0.5s → 1.0s → 2.0s) to handle transient network issues.
- Dynamic Limit Updates: User rate limits can be updated at runtime when creating a new NotificationManager instance with updated settings.
Thread Safety & Background Tasks
The notification system is designed to work safely from background threads (e.g., research queue processors). Use the settings snapshot pattern to avoid thread-safety issues with database sessions.
Settings Snapshot Pattern
Key Principle: Capture settings once with a database session, then pass the snapshot (not the session) to NotificationManager.
- ✅ Correct:
NotificationManager(settings_snapshot=settings_snapshot, user_id=username) - ❌ Wrong:
NotificationManager(session=session)- Not thread-safe!
See the source code in web/queue/processor_v2.py and error_handling/error_reporter.py for implementation examples.
Advanced Usage
Multiple Service URLs
Configure multiple comma-separated service URLs to send notifications to multiple services simultaneously (Discord, Slack, email, etc.).
Custom Retry Behavior
Use force=True parameter to bypass rate limits and disabled settings for critical notifications.
Event-Specific Configuration
Each event type can be individually enabled/disabled via settings (see Event-Specific Settings above).
Per-User Rate Limiting
The notification system supports independent rate limiting for each user:
How It Works:
- Each user configures their own rate limits via settings (e.g.,
notifications.rate_limit_per_hour) - Rate limits are enforced per-user, not globally
- One user hitting their limit does not affect other users
- Rate limits can be different for each user based on their settings
Example:
# User A with conservative limits (5/hour)
snapshot_a = {"notifications.rate_limit_per_hour": 5}
manager_a = NotificationManager(snapshot_a, user_id="user_a")
# User B with generous limits (20/hour)
snapshot_b = {"notifications.rate_limit_per_hour": 20}
manager_b = NotificationManager(snapshot_b, user_id="user_b")
# User A can send 5 notifications per hour
# User B can send 20 notifications per hour
# They don't interfere with each other
Technical Details:
- The rate limiter maintains separate counters for each user
- Each user's limits are stored in memory (~24 bytes per user)
- Limits can be updated dynamically by creating a new NotificationManager for that user
- The
user_idparameter is required when creating a NotificationManager
Rate Limit Handling
Rate limit exceptions (RateLimitError) can be caught and handled gracefully. See notifications/exceptions.py for available exception types.
Example:
from local_deep_research.notifications.exceptions import RateLimitError
try:
notification_manager.send_notification(
event_type=EventType.RESEARCH_COMPLETED,
context=context,
)
except RateLimitError as e:
# The manager already knows the user_id from initialization
logger.warning(f"Rate limit exceeded: {e}")
# Handle rate limit (e.g., queue for later, notify user)
Troubleshooting
Notifications Not Sending
- Check service URL configuration: Use
SettingsManager.get_setting("notifications.service_url")to verify the service URL is configured - Test service connection: Use
notification_manager.test_service(service_url)to verify connectivity - Check event-specific settings: Verify the specific event type is enabled (e.g.,
notifications.on_research_completed) - Check rate limits: Look for "Rate limit exceeded for user {user_id}" messages in logs
Common Issues
Issue: "No notification service URLs configured"
- Cause:
notifications.service_urlsetting is empty or not set - Fix: Configure service URL in settings dashboard or via API
Issue: "Rate limit exceeded"
- Cause: User has sent too many notifications within their configured time window (hourly or daily limit)
- Fix: Wait for rate limit window to expire (1 hour for hourly, 1 day for daily), adjust rate limit settings, or use
force=Truefor critical notifications - Note: Rate limits are enforced per-user, so this only affects the specific user who exceeded their limit
Issue: "Failed to send notification after 3 attempts"
- Cause: Service is unreachable or credentials are invalid
- Fix: Verify service URL is correct, test with
test_service(), check network connectivity
Issue: Notifications work in main thread but fail in background thread
- Cause: Using database session in background thread (not thread-safe)
- Fix: Use settings snapshot pattern as shown in migration guide above
See Also
- Full Configuration Reference - All notification settings, defaults, and environment variables
- News Subscriptions - News subscription system
- Features - Feature overview