Files
local-deep-research/docs/NOTIFICATIONS.md
LearningCircuit 2f7056a52c feat(notifications): default-off + env-only master switch for SSRF rebinding risk (#3675)
* 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.
2026-04-27 23:18:00 +00:00

15 KiB
Raw Blame History

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=true

Without this, every send_notification call returns False and 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 to http://localhost:5000 or auto-constructs from app.host and app.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 successfully
  • research_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 completes
  • subscription_error - When a subscription fails

System Events

  • api_quota_warning - When API quota or rate limits are exceeded
  • auth_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:

  1. NotificationManager - High-level manager that handles rate limiting, settings, and user preferences
  2. NotificationService - Low-level service that uses Apprise to send notifications
  3. 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_id parameter 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

  1. Check service URL configuration: Use SettingsManager.get_setting("notifications.service_url") to verify the service URL is configured
  2. Test service connection: Use notification_manager.test_service(service_url) to verify connectivity
  3. Check event-specific settings: Verify the specific event type is enabled (e.g., notifications.on_research_completed)
  4. 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_url setting 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=True for 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