stop push sends from reaching internal addresses

pywebpush posts through requests with redirects enabled, so an endpoint could answer 307 to http://127.0.0.1:5000/api/restart and the sender would follow it into the internal admin port on every alert. Pushers now use a session that never follows redirects, and each send first resolves the endpoint host and skips it unless every address is global.
This commit is contained in:
Josh Hawkins
2026-10-18 12:11:08 -05:00
parent 7095e21af9
commit d015c4510b
3 changed files with 164 additions and 1 deletions
+41 -1
View File
@@ -6,6 +6,7 @@ import json
import logging import logging
import os import os
import queue import queue
import socket
import threading import threading
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
from dataclasses import dataclass from dataclasses import dataclass
@@ -13,6 +14,7 @@ from multiprocessing.synchronize import Event as MpEvent
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
import requests
from py_vapid import Vapid01 from py_vapid import Vapid01
from pywebpush import WebPusher from pywebpush import WebPusher
from titlecase import titlecase from titlecase import titlecase
@@ -108,6 +110,34 @@ def validate_push_endpoint(endpoint: Any) -> str | None:
return None return None
class _NoRedirectSession(requests.Session):
"""Session that never follows redirects.
A push endpoint could otherwise redirect the send to an internal URL.
"""
def request(self, *args: Any, **kwargs: Any) -> requests.Response:
kwargs["allow_redirects"] = False
return super().request(*args, **kwargs)
_push_session = _NoRedirectSession()
def _resolves_to_public_address(endpoint: str) -> bool:
hostname = urlparse(endpoint).hostname
if not hostname:
return False
try:
infos = socket.getaddrinfo(hostname, 443, proto=socket.IPPROTO_TCP)
except (socket.gaierror, UnicodeError):
return False
return all(ipaddress.ip_address(info[4][0]).is_global for info in infos)
def _vapid_audience(endpoint: str) -> str: def _vapid_audience(endpoint: str) -> str:
parsed = urlparse(endpoint) parsed = urlparse(endpoint)
return f"{parsed.scheme}://{parsed.netloc}" return f"{parsed.scheme}://{parsed.netloc}"
@@ -131,7 +161,7 @@ def _build_web_pushers(user: str, subs: list[dict[str, Any]]) -> list[WebPusher]
# WebPusher decodes the stored keys and raises on malformed ones # WebPusher decodes the stored keys and raises on malformed ones
try: try:
pushers.append(WebPusher(sub)) pushers.append(WebPusher(sub, requests_session=_push_session))
except Exception: except Exception:
logger.warning("Skipping invalid notification subscription for %s", user) logger.warning("Skipping invalid notification subscription for %s", user)
@@ -403,6 +433,16 @@ class WebPushClient(Communicator):
for pusher in self.web_pushers[notification.user]: for pusher in self.web_pushers[notification.user]:
endpoint = pusher.subscription_info["endpoint"] endpoint = pusher.subscription_info["endpoint"]
# Resolve-then-send leaves a DNS rebinding window. Closing it
# needs the connection pinned to the checked address.
if not _resolves_to_public_address(endpoint):
logger.warning(
"Skipping notification to %s: endpoint does not resolve to a public address",
notification.user,
)
continue
headers = self.claim_headers[_vapid_audience(endpoint)].copy() headers = self.claim_headers[_vapid_audience(endpoint)].copy()
headers["urgency"] = "high" headers["urgency"] = "high"
@@ -6,6 +6,7 @@ from base64 import urlsafe_b64encode
from frigate.api.notification import _validate_subscription from frigate.api.notification import _validate_subscription
from frigate.comms.webpush import ( from frigate.comms.webpush import (
_build_web_pushers, _build_web_pushers,
_NoRedirectSession,
_vapid_audience, _vapid_audience,
validate_push_endpoint, validate_push_endpoint,
) )
@@ -174,6 +175,7 @@ class TestBuildWebPushers(unittest.TestCase):
self.assertEqual(len(pushers), 1) self.assertEqual(len(pushers), 1)
self.assertEqual(pushers[0].subscription_info["endpoint"], VALID_ENDPOINTS[0]) self.assertEqual(pushers[0].subscription_info["endpoint"], VALID_ENDPOINTS[0])
self.assertIsInstance(pushers[0].mod_or_session, _NoRedirectSession)
class TestVapidAudience(unittest.TestCase): class TestVapidAudience(unittest.TestCase):
+121
View File
@@ -0,0 +1,121 @@
"""Tests that push sends cannot be aimed at internal addresses."""
import queue
import socket
import unittest
from unittest.mock import MagicMock, patch
import requests
from requests.adapters import BaseAdapter
from frigate.comms.webpush import (
PushNotification,
WebPushClient,
_NoRedirectSession,
_resolves_to_public_address,
)
ENDPOINT = "https://push.example.com/token"
def _addrinfo(address: str) -> list:
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (address, 443))]
class RedirectAdapter(BaseAdapter):
def __init__(self):
super().__init__()
self.calls = 0
def send(self, request, **kwargs):
self.calls += 1
response = requests.Response()
response.status_code = 307
response.headers["Location"] = "http://127.0.0.1:5000/api/restart"
response.url = request.url
response.request = request
return response
def close(self):
pass
class TestNoRedirectSession(unittest.TestCase):
def test_does_not_follow_redirects(self):
adapter = RedirectAdapter()
session = _NoRedirectSession()
session.mount("https://", adapter)
session.mount("http://", adapter)
response = session.post(ENDPOINT, data=b"payload")
self.assertEqual(response.status_code, 307)
self.assertEqual(adapter.calls, 1)
class TestResolvesToPublicAddress(unittest.TestCase):
def test_accepts_global_address(self):
with patch("socket.getaddrinfo", return_value=_addrinfo("8.8.8.8")):
self.assertTrue(_resolves_to_public_address(ENDPOINT))
def test_rejects_non_global_addresses(self):
for address in ("127.0.0.1", "10.0.0.5", "169.254.169.254", "::1"):
with (
self.subTest(address=address),
patch("socket.getaddrinfo", return_value=_addrinfo(address)),
):
self.assertFalse(_resolves_to_public_address(ENDPOINT))
def test_rejects_when_any_address_is_non_global(self):
infos = _addrinfo("8.8.8.8") + _addrinfo("127.0.0.1")
with patch("socket.getaddrinfo", return_value=infos):
self.assertFalse(_resolves_to_public_address(ENDPOINT))
def test_rejects_unresolvable_host(self):
with patch("socket.getaddrinfo", side_effect=socket.gaierror):
self.assertFalse(_resolves_to_public_address(ENDPOINT))
class TestProcessNotifications(unittest.TestCase):
def _client(self, pusher: MagicMock) -> WebPushClient:
client = WebPushClient.__new__(WebPushClient)
client.stop_event = MagicMock()
client.stop_event.is_set.side_effect = [False, True]
client.notification_queue = queue.Queue()
client.notification_queue.put(
PushNotification(user="viewer", payload={}, title="t", message="m")
)
client.check_registrations = MagicMock()
client.claim_headers = {"https://push.example.com": {}}
client.web_pushers = {"viewer": [pusher]}
client.expired_subs = {}
return client
def test_skips_endpoint_resolving_to_internal_address(self):
pusher = MagicMock()
pusher.subscription_info = {"endpoint": ENDPOINT}
client = self._client(pusher)
with (
patch("socket.getaddrinfo", return_value=_addrinfo("127.0.0.1")),
self.assertLogs("frigate.comms.webpush", level="WARNING"),
):
client._process_notifications()
pusher.send.assert_not_called()
def test_sends_to_public_endpoint(self):
pusher = MagicMock()
pusher.subscription_info = {"endpoint": ENDPOINT}
pusher.send.return_value.status_code = 201
client = self._client(pusher)
with patch("socket.getaddrinfo", return_value=_addrinfo("8.8.8.8")):
client._process_notifications()
pusher.send.assert_called_once()
if __name__ == "__main__":
unittest.main()