From 3164ee588499a846d2e2058bb662cf7ee812686a Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Thu, 19 Feb 2026 18:01:55 -0500 Subject: [PATCH 1/3] Fix auth bypass and SSRF vulnerabilities (v2.5.64) - Replace spoofable X-Internal-Request header with HMAC-validated X-Internal-Secret for scheduler-to-app authentication - Add validate_safe_url() with DNS resolution and private IP blocklist (RFC 1918, link-local, loopback, cloud metadata, IPv6 private) - Add create_safe_session() with redirect validation hook - Apply SSRF protection to healthcheck, webhook, and ntfy services - Fix ntfy config field name mismatch (server vs server_url) - Add 22 security tests covering auth bypass and SSRF scenarios --- CHANGELOG.MD | 12 ++ app.py | 7 + auth.py | 15 +- config.py | 4 + pixelprobe/api/notification_routes.py | 13 +- pixelprobe/services/healthcheck_service.py | 26 ++- pixelprobe/services/notification_service.py | 14 +- pixelprobe/utils/security.py | 106 ++++++++- scheduler.py | 6 +- tests/conftest.py | 9 +- tests/test_app.py | 4 +- tests/test_performance.py | 4 +- tests/test_security_fixes.py | 226 ++++++++++++++++++++ version.py | 2 +- 14 files changed, 429 insertions(+), 19 deletions(-) create mode 100644 tests/test_security_fixes.py diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 1b3ae1b..d704c33 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0). +## [2.5.64] - 2026-02-19 + +### Security + +- **Fix authentication bypass via X-Internal-Request header**: Replace trivially spoofable static string check (`X-Internal-Request: scheduler`) with cryptographic HMAC validation using a shared secret (`X-Internal-Secret`). Secret is auto-generated at startup if not set via `INTERNAL_API_SECRET` environment variable. +- **Fix SSRF in healthcheck and notification services**: Add `validate_safe_url()` function that resolves hostnames and blocks requests to private/reserved IP ranges (RFC 1918, link-local, loopback, cloud metadata 169.254.x.x). Applied to healthcheck pings, webhook notifications, and ntfy notifications. +- **Add redirect-safe HTTP session**: New `create_safe_session()` creates a `requests.Session` with a response hook that validates redirect `Location` headers against the same private IP blocklist, preventing SSRF via DNS rebinding or redirect chains. +- **Fix ntfy config field name mismatch**: `_validate_provider_config()` now accepts both `server_url` (canonical, matching notification_service.py) and `server` (legacy) field names for backward compatibility. +- Files affected: `auth.py`, `config.py`, `app.py`, `scheduler.py`, `pixelprobe/utils/security.py`, `pixelprobe/services/healthcheck_service.py`, `pixelprobe/services/notification_service.py`, `pixelprobe/api/notification_routes.py`, `version.py` + +--- + ## [2.5.63] - 2026-02-18 ### Code Simplification diff --git a/app.py b/app.py index dae5013..730c7ed 100644 --- a/app.py +++ b/app.py @@ -71,6 +71,13 @@ config_class = get_config(config_name) config_class.init_app(app) +# Auto-generate INTERNAL_API_SECRET if not set via environment +# This secret is used for scheduler-to-app internal authentication +if not app.config.get('INTERNAL_API_SECRET'): + import secrets + app.config['INTERNAL_API_SECRET'] = secrets.token_urlsafe(32) + logger.info("Auto-generated INTERNAL_API_SECRET for internal request authentication") + # Backward compatibility - keep old environment variable support if not app.config.get('SECRET_KEY'): SECRET_KEY = os.environ.get('SECRET_KEY') diff --git a/auth.py b/auth.py index 7aa6220..ed831c5 100644 --- a/auth.py +++ b/auth.py @@ -3,11 +3,12 @@ Handles user authentication, session management, and API token validation """ +import hmac import logging from functools import wraps from datetime import datetime, timezone -from flask import jsonify, request, redirect, url_for, session +from flask import jsonify, request, redirect, url_for, session, current_app from flask_login import LoginManager, login_user, logout_user, login_required, current_user from flask_restx import abort as restx_abort from models import User, APIToken, db @@ -113,8 +114,10 @@ def auth_required(f): """ @wraps(f) def decorated_function(*args, **kwargs): - # Allow internal scheduler requests without authentication - if request.headers.get('X-Internal-Request') == 'scheduler': + # Allow internal scheduler requests authenticated by shared secret + internal_secret = request.headers.get('X-Internal-Secret', '') + expected_secret = current_app.config.get('INTERNAL_API_SECRET', '') + if internal_secret and expected_secret and hmac.compare_digest(internal_secret, expected_secret): return f(*args, **kwargs) # Check if user is authenticated via session @@ -142,8 +145,10 @@ def check_auth(): Returns True if authenticated, False otherwise. Use this inside Flask-RESTX Resource methods instead of the decorator. """ - # Allow internal scheduler requests without authentication - if request.headers.get('X-Internal-Request') == 'scheduler': + # Allow internal scheduler requests authenticated by shared secret + internal_secret = request.headers.get('X-Internal-Secret', '') + expected_secret = current_app.config.get('INTERNAL_API_SECRET', '') + if internal_secret and expected_secret and hmac.compare_digest(internal_secret, expected_secret): return True # Check if user is authenticated via session diff --git a/config.py b/config.py index 636b95a..dcf9cc9 100644 --- a/config.py +++ b/config.py @@ -15,6 +15,10 @@ class Config: SECRET_KEY = os.environ.get('SECRET_KEY') if not SECRET_KEY: raise ValueError("SECRET_KEY environment variable must be set") + + # Internal API secret for scheduler-to-app authentication + # If not set, app.py will auto-generate one at startup + INTERNAL_API_SECRET = os.environ.get('INTERNAL_API_SECRET', '') # PostgreSQL configuration POSTGRES_HOST = os.getenv('POSTGRES_HOST', 'localhost') diff --git a/pixelprobe/api/notification_routes.py b/pixelprobe/api/notification_routes.py index 1b4ebfd..4063196 100644 --- a/pixelprobe/api/notification_routes.py +++ b/pixelprobe/api/notification_routes.py @@ -11,6 +11,7 @@ from models import db, NotificationProvider, NotificationRule from auth import auth_required from pixelprobe.services.notification_service import NotificationService +from pixelprobe.utils.security import validate_safe_url logger = logging.getLogger(__name__) @@ -446,11 +447,21 @@ def _validate_provider_config(provider_type: str, config: dict) -> Optional[str] elif provider_type == 'ntfy': if not config.get('topic'): return 'ntfy topic is required' - if not config.get('server'): + # Support both 'server_url' (canonical) and 'server' (legacy) field names + server_url = config.get('server_url') or config.get('server') + if not server_url: return 'ntfy server URL is required' + # SSRF protection: validate server URL + is_safe, error = validate_safe_url(f"{server_url}/{config.get('topic')}") + if not is_safe: + return f'ntfy server URL blocked by security policy: {error}' elif provider_type == 'webhook': if not config.get('webhook_url'): return 'Webhook URL is required' + # SSRF protection: validate webhook URL + is_safe, error = validate_safe_url(config['webhook_url']) + if not is_safe: + return f'Webhook URL blocked by security policy: {error}' return None diff --git a/pixelprobe/services/healthcheck_service.py b/pixelprobe/services/healthcheck_service.py index 8e014e7..2231fa5 100644 --- a/pixelprobe/services/healthcheck_service.py +++ b/pixelprobe/services/healthcheck_service.py @@ -8,6 +8,7 @@ import requests from typing import Optional, Dict from datetime import datetime +from pixelprobe.utils.security import validate_safe_url, create_safe_session logger = logging.getLogger(__name__) @@ -16,7 +17,7 @@ class HealthcheckService: """Service for managing healthchecks.io integrations""" def __init__(self): - self.session = requests.Session() + self.session = create_safe_session() self.session.headers.update({ 'User-Agent': 'PixelProbe-Healthcheck/1.0' }) @@ -35,6 +36,12 @@ def ping_start(self, healthcheck_url: str) -> bool: logger.warning("No healthcheck URL provided") return False + # SSRF protection: validate URL before making request + is_safe, error = validate_safe_url(healthcheck_url) + if not is_safe: + logger.warning(f"Healthcheck start ping blocked (SSRF): {error}") + return False + # Healthchecks.io uses /start slug for start signals start_url = f"{healthcheck_url.rstrip('/')}/start" logger.info(f"Sending healthcheck start ping to: {start_url}") @@ -70,6 +77,12 @@ def ping_success(self, healthcheck_url: str, report_data: Optional[Dict] = None) logger.warning("No healthcheck URL provided") return False + # SSRF protection: validate URL before making request + is_safe, error = validate_safe_url(healthcheck_url) + if not is_safe: + logger.warning(f"Healthcheck success ping blocked (SSRF): {error}") + return False + logger.info(f"Sending healthcheck success ping to: {healthcheck_url}") # Format report data if provided @@ -135,6 +148,12 @@ def ping_fail(self, healthcheck_url: str, error_message: Optional[str] = None) - logger.warning("No healthcheck URL provided") return False + # SSRF protection: validate URL before making request + is_safe, error = validate_safe_url(healthcheck_url) + if not is_safe: + logger.warning(f"Healthcheck failure ping blocked (SSRF): {error}") + return False + # Healthchecks.io uses /fail slug for failure signals fail_url = f"{healthcheck_url.rstrip('/')}/fail" logger.info(f"Sending healthcheck failure ping to: {fail_url}") @@ -230,6 +249,11 @@ def validate_url(self, healthcheck_url: str) -> tuple[bool, Optional[str]]: if not healthcheck_url.startswith(('http://', 'https://')): return False, "Healthcheck URL must start with http:// or https://" + # SSRF protection: validate against private IP ranges + is_safe, ssrf_error = validate_safe_url(healthcheck_url) + if not is_safe: + return False, f"URL blocked by security policy: {ssrf_error}" + # Log the URL format for debugging # Supports both public hc-ping.com and self-hosted instances # Public: https://hc-ping.com/UUID diff --git a/pixelprobe/services/notification_service.py b/pixelprobe/services/notification_service.py index 96da395..8f10ca3 100644 --- a/pixelprobe/services/notification_service.py +++ b/pixelprobe/services/notification_service.py @@ -11,6 +11,7 @@ import requests from typing import Dict, Optional, List from datetime import datetime, timezone +from pixelprobe.utils.security import validate_safe_url, create_safe_session logger = logging.getLogger(__name__) @@ -19,7 +20,7 @@ class NotificationService: """Service for sending notifications via various providers""" def __init__(self): - self.session = requests.Session() + self.session = create_safe_session() self.session.headers.update({'User-Agent': 'PixelProbe-Notification/1.0'}) self.timeout = 10 @@ -171,6 +172,12 @@ def _send_ntfy( if token: headers['Authorization'] = f'Bearer {token}' + # SSRF protection: validate URL before making request + ntfy_url = f"{server_url}/{topic}" + is_safe, error = validate_safe_url(ntfy_url) + if not is_safe: + return False, f"ntfy URL blocked by security policy: {error}" + try: response = self.session.post( f"{server_url}/{topic}", @@ -288,6 +295,11 @@ def _send_webhook( headers = {'Content-Type': 'application/json'} headers.update(custom_headers) + # SSRF protection: validate URL before making request + is_safe, error = validate_safe_url(webhook_url) + if not is_safe: + return False, f"Webhook URL blocked by security policy: {error}" + try: if method == 'POST': response = self.session.post( diff --git a/pixelprobe/utils/security.py b/pixelprobe/utils/security.py index 7ea7bea..74b8af1 100644 --- a/pixelprobe/utils/security.py +++ b/pixelprobe/utils/security.py @@ -3,12 +3,17 @@ """ import os import re +import socket +import ipaddress import logging +import urllib.parse from functools import wraps from datetime import datetime, timezone +from typing import Optional, Tuple from flask import request, jsonify, current_app from werkzeug.security import safe_join from models import db, ScanConfiguration +import requests logger = logging.getLogger(__name__) @@ -328,4 +333,103 @@ def decorated_function(*args, **kwargs): return f(*args, **kwargs) return decorated_function - return decorator \ No newline at end of file + return decorator + + +# SSRF protection + +# Private/reserved IP networks that should never be targeted by outbound requests +_BLOCKED_NETWORKS = [ + ipaddress.ip_network('127.0.0.0/8'), # Loopback + ipaddress.ip_network('10.0.0.0/8'), # RFC 1918 + ipaddress.ip_network('172.16.0.0/12'), # RFC 1918 + ipaddress.ip_network('192.168.0.0/16'), # RFC 1918 + ipaddress.ip_network('169.254.0.0/16'), # Link-local / cloud metadata + ipaddress.ip_network('0.0.0.0/8'), # "This" network + ipaddress.ip_network('::1/128'), # IPv6 loopback + ipaddress.ip_network('fc00::/7'), # IPv6 unique local + ipaddress.ip_network('fe80::/10'), # IPv6 link-local +] + + +def validate_safe_url(url: str) -> Tuple[bool, Optional[str]]: + """Validate that a URL does not target private/internal IP ranges (SSRF protection). + + Args: + url: The URL to validate + + Returns: + Tuple of (is_safe, error_message). is_safe is True if URL is safe to request. + """ + if not url or not url.strip(): + return False, "URL is empty" + + try: + parsed = urllib.parse.urlparse(url) + except Exception: + return False, "Invalid URL format" + + # Validate scheme + if parsed.scheme not in ('http', 'https'): + return False, f"URL scheme must be http or https, got: {parsed.scheme or 'none'}" + + # Reject embedded credentials (user:pass@host) + if parsed.username or parsed.password: + return False, "URLs with embedded credentials are not allowed" + + hostname = parsed.hostname + if not hostname: + return False, "URL has no hostname" + + # Resolve hostname and check all resolved IPs + try: + addr_infos = socket.getaddrinfo(hostname, parsed.port or (443 if parsed.scheme == 'https' else 80)) + except socket.gaierror: + return False, f"Could not resolve hostname: {hostname}" + + for addr_info in addr_infos: + ip_str = addr_info[4][0] + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + continue + + for network in _BLOCKED_NETWORKS: + if ip in network: + AuditLogger.log_security_event( + 'ssrf_blocked', + f"Blocked outbound request to private IP: {url} resolved to {ip_str}", + severity='warning' + ) + return False, f"URL resolves to a private/reserved IP address ({ip_str})" + + return True, None + + +def create_safe_session(max_redirects: int = 5) -> requests.Session: + """Create a requests.Session that validates redirect targets against private IP ranges. + + Args: + max_redirects: Maximum number of redirects to follow + + Returns: + A requests.Session configured with SSRF-safe redirect handling + """ + session = requests.Session() + session.max_redirects = max_redirects + + def _check_redirect(response, *args, **kwargs): + """Response hook that validates redirect Location headers.""" + if response.is_redirect or response.is_permanent_redirect: + location = response.headers.get('Location') + if location: + # Resolve relative redirects against the request URL + redirect_url = urllib.parse.urljoin(response.url, location) + is_safe, error = validate_safe_url(redirect_url) + if not is_safe: + raise requests.ConnectionError( + f"Redirect blocked by SSRF protection: {error}" + ) + + session.hooks['response'].append(_check_redirect) + return session \ No newline at end of file diff --git a/scheduler.py b/scheduler.py index ff49145..a822423 100644 --- a/scheduler.py +++ b/scheduler.py @@ -48,7 +48,7 @@ def _execute_scan_request(self, endpoint, payload, scan_label, timeout=30): """ base_url = self._get_api_base_url() headers = { - 'X-Internal-Request': 'scheduler', + 'X-Internal-Secret': self.app.config.get('INTERNAL_API_SECRET', ''), 'Content-Type': 'application/json' } try: @@ -449,9 +449,9 @@ def _run_cleanup(self): base_url = self._get_api_base_url() - # Add internal request header + # Add internal request header with cryptographic secret headers = { - 'X-Internal-Request': 'scheduler', + 'X-Internal-Secret': self.app.config.get('INTERNAL_API_SECRET', ''), 'Content-Type': 'application/json' } diff --git a/tests/conftest.py b/tests/conftest.py index 844c60b..442a48d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,6 +40,7 @@ def create_test_app(): test_app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False test_app.config['SQLALCHEMY_EXPIRE_ON_COMMIT'] = False # Prevent detached instance errors in tests test_app.config['WTF_CSRF_ENABLED'] = False + test_app.config['INTERNAL_API_SECRET'] = 'test-internal-secret' # Initialize extensions _db.init_app(test_app) @@ -86,8 +87,11 @@ def create_test_app(): scheduler.scheduler.start() # Start the scheduler set_scheduler(scheduler) - # Add basic routes + # Add basic routes (with auth_required to match production app) + from auth import auth_required as _auth_required + @test_app.route('/health') + @_auth_required def health_check(): from datetime import datetime, timezone return { @@ -95,8 +99,9 @@ def health_check(): 'version': '1.0.0', 'timestamp': datetime.now(timezone.utc).isoformat() } - + @test_app.route('/api/version') + @_auth_required def get_version(): return { 'version': '1.0.0', diff --git a/tests/test_app.py b/tests/test_app.py index 10267aa..d65977d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -4,9 +4,9 @@ import pytest -def test_health_endpoint(client): +def test_health_endpoint(authenticated_client): """Test the health check endpoint""" - response = client.get('/health') + response = authenticated_client.get('/health') assert response.status_code == 200 data = response.get_json() assert data['status'] == 'healthy' diff --git a/tests/test_performance.py b/tests/test_performance.py index bac61f8..287be18 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -295,8 +295,8 @@ def test_api_response_time(self, app, db): response_times = {} - # Use internal scheduler header to bypass authentication in tests - headers = {'X-Internal-Request': 'scheduler'} + # Use internal secret header to bypass authentication in tests + headers = {'X-Internal-Secret': app.config.get('INTERNAL_API_SECRET', 'test-internal-secret')} with app.test_client() as client: for endpoint, method, data in endpoints: diff --git a/tests/test_security_fixes.py b/tests/test_security_fixes.py new file mode 100644 index 0000000..17c7c58 --- /dev/null +++ b/tests/test_security_fixes.py @@ -0,0 +1,226 @@ +""" +Tests for security fixes in v2.5.64: +- Authentication bypass via X-Internal-Request header +- SSRF via healthcheck and webhook URLs +""" + +import pytest +from unittest.mock import patch, MagicMock +import requests + + +# ==================== Auth Bypass Tests ==================== + + +class TestAuthBypassFix: + """Tests that the old X-Internal-Request: scheduler header no longer bypasses auth""" + + def test_old_header_no_longer_bypasses_auth(self, app): + """The old spoofable header must NOT grant access""" + with app.test_client() as client: + response = client.get( + '/api/version', + headers={'X-Internal-Request': 'scheduler'} + ) + assert response.status_code == 401 + + def test_correct_internal_secret_grants_access(self, app): + """Correct X-Internal-Secret value should grant access""" + secret = app.config.get('INTERNAL_API_SECRET', 'test-internal-secret') + with app.test_client() as client: + response = client.get( + '/api/version', + headers={'X-Internal-Secret': secret} + ) + assert response.status_code == 200 + + def test_wrong_internal_secret_returns_401(self, app): + """Wrong secret must be rejected""" + with app.test_client() as client: + response = client.get( + '/api/version', + headers={'X-Internal-Secret': 'wrong-secret-value'} + ) + assert response.status_code == 401 + + def test_empty_internal_secret_returns_401(self, app): + """Empty secret header must be rejected""" + with app.test_client() as client: + response = client.get( + '/api/version', + headers={'X-Internal-Secret': ''} + ) + assert response.status_code == 401 + + def test_no_auth_header_returns_401(self, app): + """No auth header at all must be rejected""" + with app.test_client() as client: + response = client.get('/api/version') + assert response.status_code == 401 + + +# ==================== SSRF Tests ==================== + + +class TestValidateSafeUrl: + """Tests for validate_safe_url() SSRF protection""" + + def test_rejects_loopback_ipv4(self): + from pixelprobe.utils.security import validate_safe_url + is_safe, error = validate_safe_url('http://127.0.0.1/') + assert not is_safe + assert 'private' in error.lower() or 'reserved' in error.lower() + + def test_rejects_cloud_metadata(self): + from pixelprobe.utils.security import validate_safe_url + is_safe, error = validate_safe_url('http://169.254.169.254/') + assert not is_safe + + def test_rejects_rfc1918_10(self): + from pixelprobe.utils.security import validate_safe_url + is_safe, error = validate_safe_url('http://10.0.0.1/') + assert not is_safe + + def test_rejects_rfc1918_192(self): + from pixelprobe.utils.security import validate_safe_url + is_safe, error = validate_safe_url('http://192.168.1.1/') + assert not is_safe + + def test_rejects_ipv6_loopback(self): + from pixelprobe.utils.security import validate_safe_url + is_safe, error = validate_safe_url('http://[::1]/') + assert not is_safe + + def test_rejects_zero_address(self): + from pixelprobe.utils.security import validate_safe_url + is_safe, error = validate_safe_url('http://0.0.0.0/') + assert not is_safe + + def test_rejects_empty_url(self): + from pixelprobe.utils.security import validate_safe_url + is_safe, error = validate_safe_url('') + assert not is_safe + assert 'empty' in error.lower() + + def test_rejects_ftp_scheme(self): + from pixelprobe.utils.security import validate_safe_url + is_safe, error = validate_safe_url('ftp://example.com/') + assert not is_safe + assert 'scheme' in error.lower() + + def test_rejects_credentials_in_url(self): + from pixelprobe.utils.security import validate_safe_url + is_safe, error = validate_safe_url('http://user:pass@example.com/') + assert not is_safe + assert 'credential' in error.lower() + + @patch('pixelprobe.utils.security.socket.getaddrinfo') + def test_accepts_public_url(self, mock_getaddrinfo): + """Public URLs should be accepted (mock DNS to avoid network calls)""" + from pixelprobe.utils.security import validate_safe_url + # Mock DNS resolution to return a public IP + mock_getaddrinfo.return_value = [ + (2, 1, 6, '', ('93.184.216.34', 80)) + ] + is_safe, error = validate_safe_url('http://example.com/') + assert is_safe + assert error is None + + @patch('pixelprobe.utils.security.socket.getaddrinfo') + def test_accepts_https_url(self, mock_getaddrinfo): + """HTTPS URLs with public IPs should be accepted""" + from pixelprobe.utils.security import validate_safe_url + mock_getaddrinfo.return_value = [ + (2, 1, 6, '', ('93.184.216.34', 443)) + ] + is_safe, error = validate_safe_url('https://hc-ping.com/abc-123') + assert is_safe + assert error is None + + def test_rejects_rfc1918_172(self): + from pixelprobe.utils.security import validate_safe_url + is_safe, error = validate_safe_url('http://172.16.0.1/') + assert not is_safe + + +class TestCreateSafeSession: + """Tests for create_safe_session()""" + + def test_session_has_max_redirects(self): + from pixelprobe.utils.security import create_safe_session + session = create_safe_session() + assert session.max_redirects == 5 + + def test_custom_max_redirects(self): + from pixelprobe.utils.security import create_safe_session + session = create_safe_session(max_redirects=3) + assert session.max_redirects == 3 + + def test_session_has_response_hook(self): + from pixelprobe.utils.security import create_safe_session + session = create_safe_session() + assert len(session.hooks['response']) >= 1 + + +class TestNotificationRoutesValidation: + """Tests for SSRF validation in notification route config validation""" + + @patch('pixelprobe.api.notification_routes.validate_safe_url') + def test_webhook_private_ip_rejected(self, mock_validate): + """Webhook URLs targeting private IPs should be rejected""" + mock_validate.return_value = (False, "URL resolves to a private/reserved IP address") + from pixelprobe.api.notification_routes import _validate_provider_config + + error = _validate_provider_config('webhook', { + 'webhook_url': 'http://192.168.1.1/hook' + }) + assert error is not None + assert 'blocked' in error.lower() or 'private' in error.lower() + + @patch('pixelprobe.api.notification_routes.validate_safe_url') + def test_ntfy_private_ip_rejected(self, mock_validate): + """ntfy server URLs targeting private IPs should be rejected""" + mock_validate.return_value = (False, "URL resolves to a private/reserved IP address") + from pixelprobe.api.notification_routes import _validate_provider_config + + error = _validate_provider_config('ntfy', { + 'server_url': 'http://10.0.0.1', + 'topic': 'test' + }) + assert error is not None + assert 'blocked' in error.lower() or 'private' in error.lower() + + def test_ntfy_accepts_server_url_field(self): + """The server_url field name should be accepted for ntfy config""" + from pixelprobe.api.notification_routes import _validate_provider_config + + with patch('pixelprobe.api.notification_routes.validate_safe_url') as mock_validate: + mock_validate.return_value = (True, None) + error = _validate_provider_config('ntfy', { + 'server_url': 'https://ntfy.example.com', + 'topic': 'test' + }) + assert error is None + + def test_ntfy_accepts_legacy_server_field(self): + """The legacy 'server' field name should still be accepted for backward compat""" + from pixelprobe.api.notification_routes import _validate_provider_config + + with patch('pixelprobe.api.notification_routes.validate_safe_url') as mock_validate: + mock_validate.return_value = (True, None) + error = _validate_provider_config('ntfy', { + 'server': 'https://ntfy.example.com', + 'topic': 'test' + }) + assert error is None + + @patch('pixelprobe.api.notification_routes.validate_safe_url') + def test_webhook_public_url_accepted(self, mock_validate): + """Webhook URLs targeting public IPs should pass validation""" + mock_validate.return_value = (True, None) + from pixelprobe.api.notification_routes import _validate_provider_config + + error = _validate_provider_config('webhook', { + 'webhook_url': 'https://hooks.slack.com/services/T00/B00/xxxx' + }) + assert error is None diff --git a/version.py b/version.py index 602fb05..79362a7 100644 --- a/version.py +++ b/version.py @@ -4,7 +4,7 @@ # Default version - this is the single source of truth -_DEFAULT_VERSION = '2.5.63' +_DEFAULT_VERSION = '2.5.64' # Allow override via environment variable for CI/CD, but default to the hardcoded version From 78176b724a8f8da054634029cc15579e0e896ac0 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 20 Feb 2026 09:25:11 -0500 Subject: [PATCH 2/3] Share INTERNAL_API_SECRET via Redis across gunicorn workers The auto-generated secret was unique per worker, causing 401s when the scheduler (in one worker) sent requests handled by a different worker. Now uses Redis SETNX to generate once and share across all workers and the celery container. --- app.py | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index 730c7ed..f9c1dcb 100644 --- a/app.py +++ b/app.py @@ -73,10 +73,39 @@ # Auto-generate INTERNAL_API_SECRET if not set via environment # This secret is used for scheduler-to-app internal authentication +# In multi-worker/multi-container setups, the secret is shared via Redis +# so all gunicorn workers and the celery worker use the same value. if not app.config.get('INTERNAL_API_SECRET'): import secrets - app.config['INTERNAL_API_SECRET'] = secrets.token_urlsafe(32) - logger.info("Auto-generated INTERNAL_API_SECRET for internal request authentication") + _REDIS_SECRET_KEY = 'pixelprobe:internal_api_secret' + _secret_loaded = False + try: + from pixelprobe.progress_utils import get_redis_client + _redis = get_redis_client() + if _redis: + _existing = _redis.get(_REDIS_SECRET_KEY) + if _existing: + app.config['INTERNAL_API_SECRET'] = _existing.decode('utf-8') if isinstance(_existing, bytes) else _existing + _secret_loaded = True + logger.info("Loaded INTERNAL_API_SECRET from Redis (shared across workers)") + else: + _new_secret = secrets.token_urlsafe(32) + # Use SETNX to avoid race conditions between workers + if _redis.set(_REDIS_SECRET_KEY, _new_secret, nx=True): + app.config['INTERNAL_API_SECRET'] = _new_secret + logger.info("Generated and stored INTERNAL_API_SECRET in Redis") + else: + # Another worker beat us to it, read theirs + _existing = _redis.get(_REDIS_SECRET_KEY) + app.config['INTERNAL_API_SECRET'] = _existing.decode('utf-8') if isinstance(_existing, bytes) else _existing + logger.info("Loaded INTERNAL_API_SECRET from Redis (set by sibling worker)") + _secret_loaded = True + except Exception as e: + logger.warning(f"Could not use Redis for INTERNAL_API_SECRET: {e}") + + if not _secret_loaded: + app.config['INTERNAL_API_SECRET'] = secrets.token_urlsafe(32) + logger.info("Auto-generated INTERNAL_API_SECRET (Redis unavailable, single-worker mode)") # Backward compatibility - keep old environment variable support if not app.config.get('SECRET_KEY'): From 361d47cdfa6d59fae851117483263229ad0dc50f Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 20 Feb 2026 09:26:55 -0500 Subject: [PATCH 3/3] Bump version to 2.5.65 for Redis-shared secret fix --- CHANGELOG.MD | 9 +++++++++ version.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index d704c33..e772069 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0). +## [2.5.65] - 2026-02-20 + +### Fixed + +- **Share INTERNAL_API_SECRET via Redis across gunicorn workers**: The auto-generated secret was unique per gunicorn worker, causing scheduler internal requests to fail with 401 when routed to a different worker. Now uses Redis `SETNX` to generate the secret once and share it across all workers and the celery container. +- Files affected: `app.py`, `version.py` + +--- + ## [2.5.64] - 2026-02-19 ### Security diff --git a/version.py b/version.py index 79362a7..e85a96e 100644 --- a/version.py +++ b/version.py @@ -4,7 +4,7 @@ # Default version - this is the single source of truth -_DEFAULT_VERSION = '2.5.64' +_DEFAULT_VERSION = '2.5.65' # Allow override via environment variable for CI/CD, but default to the hardcoded version