mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-19 13:12:48 +03:00
reject push endpoints that crash the vapid audience parse
The validator accepted hostnames like "." so https://./x passed, and check_registrations then raised ValueError slicing the endpoint, killing the dispatcher's IPC thread. The validator now rejects empty hostname labels and lives in webpush.py, the audience comes from urlparse, and _build_web_pushers skips stored rows whose endpoint fails validation.
This commit is contained in:
@@ -1,10 +1,8 @@
|
|||||||
"""Notification apis."""
|
"""Notification apis."""
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
@@ -13,6 +11,7 @@ from py_vapid import Vapid01, utils
|
|||||||
|
|
||||||
from frigate.api.auth import allow_any_authenticated
|
from frigate.api.auth import allow_any_authenticated
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
|
from frigate.comms.webpush import validate_push_endpoint
|
||||||
from frigate.const import CONFIG_DIR
|
from frigate.const import CONFIG_DIR
|
||||||
from frigate.models import User
|
from frigate.models import User
|
||||||
|
|
||||||
@@ -20,76 +19,13 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(tags=[Tags.notifications])
|
router = APIRouter(tags=[Tags.notifications])
|
||||||
|
|
||||||
# Push endpoints are opaque URLs but stay well under this in practice
|
|
||||||
MAX_ENDPOINT_LENGTH = 2048
|
|
||||||
|
|
||||||
# Suffixes that only ever resolve on the local network
|
|
||||||
INTERNAL_HOST_SUFFIXES = (".local", ".localdomain", ".internal", ".home.arpa")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_push_endpoint(endpoint: Any) -> str | None:
|
|
||||||
"""Return a reason the endpoint is unusable, or None when it is valid.
|
|
||||||
|
|
||||||
Subscriptions are issued by the browser vendor's push service, so a valid
|
|
||||||
endpoint is always a public https URL. Anything else is either a broken
|
|
||||||
registration or an attempt to aim the notification sender somewhere it
|
|
||||||
should not reach.
|
|
||||||
"""
|
|
||||||
if not isinstance(endpoint, str) or not endpoint:
|
|
||||||
return "endpoint must be a url"
|
|
||||||
|
|
||||||
if len(endpoint) > MAX_ENDPOINT_LENGTH:
|
|
||||||
return "endpoint is too long"
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = urlparse(endpoint)
|
|
||||||
port = parsed.port
|
|
||||||
except ValueError:
|
|
||||||
return "endpoint is not a valid url"
|
|
||||||
|
|
||||||
if parsed.scheme != "https":
|
|
||||||
return "endpoint must use https"
|
|
||||||
|
|
||||||
if parsed.username or parsed.password:
|
|
||||||
return "endpoint must not include credentials"
|
|
||||||
|
|
||||||
if port is not None and port != 443:
|
|
||||||
return "endpoint must use the default https port"
|
|
||||||
|
|
||||||
hostname = parsed.hostname
|
|
||||||
|
|
||||||
if not hostname:
|
|
||||||
return "endpoint must include a hostname"
|
|
||||||
|
|
||||||
try:
|
|
||||||
address = ipaddress.ip_address(hostname)
|
|
||||||
except ValueError:
|
|
||||||
address = None
|
|
||||||
|
|
||||||
if address is not None:
|
|
||||||
# A push service is never reachable at an address only this network can
|
|
||||||
# route, so anything non-global is a misconfiguration at best
|
|
||||||
if not address.is_global:
|
|
||||||
return "endpoint must not use a private address"
|
|
||||||
elif hostname == "localhost" or "." not in hostname:
|
|
||||||
return "endpoint must use a fully qualified hostname"
|
|
||||||
elif hostname.endswith(INTERNAL_HOST_SUFFIXES):
|
|
||||||
return "endpoint must not use an internal hostname"
|
|
||||||
|
|
||||||
# The subscription token lives in the path, and webpush.py assumes there is
|
|
||||||
# a separator after the host when it builds the VAPID audience
|
|
||||||
if len(parsed.path) <= 1:
|
|
||||||
return "endpoint must include a subscription path"
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_subscription(sub: Any) -> str | None:
|
def _validate_subscription(sub: Any) -> str | None:
|
||||||
"""Return a reason the subscription is unusable, or None when it is valid."""
|
"""Return a reason the subscription is unusable, or None when it is valid."""
|
||||||
if not isinstance(sub, dict):
|
if not isinstance(sub, dict):
|
||||||
return "subscription must be an object"
|
return "subscription must be an object"
|
||||||
|
|
||||||
reason = _validate_push_endpoint(sub.get("endpoint"))
|
reason = validate_push_endpoint(sub.get("endpoint"))
|
||||||
|
|
||||||
if reason:
|
if reason:
|
||||||
return reason
|
return reason
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Handle sending notifications for Frigate via Firebase."""
|
"""Handle sending notifications for Frigate via Firebase."""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -10,6 +11,7 @@ from collections.abc import Callable, Iterator
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from py_vapid import Vapid01
|
from py_vapid import Vapid01
|
||||||
from pywebpush import WebPusher
|
from pywebpush import WebPusher
|
||||||
@@ -41,11 +43,92 @@ class PushNotification:
|
|||||||
ttl: int = 0
|
ttl: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# Push endpoints are opaque URLs but stay well under this in practice
|
||||||
|
MAX_ENDPOINT_LENGTH = 2048
|
||||||
|
|
||||||
|
# Suffixes that only ever resolve on the local network
|
||||||
|
INTERNAL_HOST_SUFFIXES = (".local", ".localdomain", ".internal", ".home.arpa")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_push_endpoint(endpoint: Any) -> str | None:
|
||||||
|
"""Return a reason the endpoint is unusable, or None when it is valid.
|
||||||
|
|
||||||
|
Subscriptions are issued by the browser vendor's push service, so a valid
|
||||||
|
endpoint is always a public https URL. Anything else is either a broken
|
||||||
|
registration or an attempt to aim the notification sender somewhere it
|
||||||
|
should not reach.
|
||||||
|
"""
|
||||||
|
if not isinstance(endpoint, str) or not endpoint:
|
||||||
|
return "endpoint must be a url"
|
||||||
|
|
||||||
|
if len(endpoint) > MAX_ENDPOINT_LENGTH:
|
||||||
|
return "endpoint is too long"
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(endpoint)
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError:
|
||||||
|
return "endpoint is not a valid url"
|
||||||
|
|
||||||
|
if parsed.scheme != "https":
|
||||||
|
return "endpoint must use https"
|
||||||
|
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
return "endpoint must not include credentials"
|
||||||
|
|
||||||
|
if port is not None and port != 443:
|
||||||
|
return "endpoint must use the default https port"
|
||||||
|
|
||||||
|
hostname = parsed.hostname
|
||||||
|
|
||||||
|
if not hostname:
|
||||||
|
return "endpoint must include a hostname"
|
||||||
|
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(hostname)
|
||||||
|
except ValueError:
|
||||||
|
address = None
|
||||||
|
|
||||||
|
if address is not None:
|
||||||
|
# A push service is never reachable at an address only this network can
|
||||||
|
# route, so anything non-global is a misconfiguration at best
|
||||||
|
if not address.is_global:
|
||||||
|
return "endpoint must not use a private address"
|
||||||
|
elif hostname == "localhost" or "." not in hostname:
|
||||||
|
return "endpoint must use a fully qualified hostname"
|
||||||
|
elif "" in hostname.split("."):
|
||||||
|
return "endpoint hostname must not contain empty labels"
|
||||||
|
elif hostname.endswith(INTERNAL_HOST_SUFFIXES):
|
||||||
|
return "endpoint must not use an internal hostname"
|
||||||
|
|
||||||
|
# The subscription token lives in the path
|
||||||
|
if len(parsed.path) <= 1:
|
||||||
|
return "endpoint must include a subscription path"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _vapid_audience(endpoint: str) -> str:
|
||||||
|
parsed = urlparse(endpoint)
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
|
||||||
|
|
||||||
def _build_web_pushers(user: str, subs: list[dict[str, Any]]) -> list[WebPusher]:
|
def _build_web_pushers(user: str, subs: list[dict[str, Any]]) -> list[WebPusher]:
|
||||||
"""Build pushers for a user's stored subscriptions, skipping unusable ones."""
|
"""Build pushers for a user's stored subscriptions, skipping unusable ones."""
|
||||||
pushers: list[WebPusher] = []
|
pushers: list[WebPusher] = []
|
||||||
|
|
||||||
for sub in subs:
|
for sub in subs:
|
||||||
|
# Rows stored before registration validation can hold any endpoint
|
||||||
|
reason = validate_push_endpoint(
|
||||||
|
sub.get("endpoint") if isinstance(sub, dict) else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if reason:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping notification subscription for %s: %s", user, reason
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# 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))
|
||||||
@@ -131,7 +214,7 @@ class WebPushClient(Communicator):
|
|||||||
for pushers in self.web_pushers.values():
|
for pushers in self.web_pushers.values():
|
||||||
for push in pushers:
|
for push in pushers:
|
||||||
endpoint: str = push.subscription_info["endpoint"]
|
endpoint: str = push.subscription_info["endpoint"]
|
||||||
endpoints.add(endpoint[0 : endpoint.index("/", 10)])
|
endpoints.add(_vapid_audience(endpoint))
|
||||||
|
|
||||||
# create new claim
|
# create new claim
|
||||||
for endpoint in endpoints:
|
for endpoint in endpoints:
|
||||||
@@ -320,9 +403,7 @@ 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"]
|
||||||
headers = self.claim_headers[
|
headers = self.claim_headers[_vapid_audience(endpoint)].copy()
|
||||||
endpoint[: endpoint.index("/", 10)]
|
|
||||||
].copy()
|
|
||||||
headers["urgency"] = "high"
|
headers["urgency"] = "high"
|
||||||
|
|
||||||
resp = pusher.send(
|
resp = pusher.send(
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
|
|
||||||
from frigate.api.notification import _validate_push_endpoint, _validate_subscription
|
from frigate.api.notification import _validate_subscription
|
||||||
from frigate.comms.webpush import _build_web_pushers
|
from frigate.comms.webpush import (
|
||||||
|
_build_web_pushers,
|
||||||
|
_vapid_audience,
|
||||||
|
validate_push_endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
VALID_ENDPOINTS = [
|
VALID_ENDPOINTS = [
|
||||||
"https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu",
|
"https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu",
|
||||||
@@ -26,11 +30,11 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
|||||||
def test_accepts_real_push_service_endpoints(self):
|
def test_accepts_real_push_service_endpoints(self):
|
||||||
for endpoint in VALID_ENDPOINTS:
|
for endpoint in VALID_ENDPOINTS:
|
||||||
with self.subTest(endpoint=endpoint):
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNone(_validate_push_endpoint(endpoint))
|
self.assertIsNone(validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_http(self):
|
def test_rejects_http(self):
|
||||||
self.assertIsNotNone(
|
self.assertIsNotNone(
|
||||||
_validate_push_endpoint("http://fcm.googleapis.com/fcm/send/token")
|
validate_push_endpoint("http://fcm.googleapis.com/fcm/send/token")
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_rejects_non_http_schemes(self):
|
def test_rejects_non_http_schemes(self):
|
||||||
@@ -40,7 +44,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
|||||||
"//example.com/token",
|
"//example.com/token",
|
||||||
):
|
):
|
||||||
with self.subTest(endpoint=endpoint):
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_localhost(self):
|
def test_rejects_localhost(self):
|
||||||
for endpoint in (
|
for endpoint in (
|
||||||
@@ -50,7 +54,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
|||||||
"https://[::1]/token",
|
"https://[::1]/token",
|
||||||
):
|
):
|
||||||
with self.subTest(endpoint=endpoint):
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_private_addresses(self):
|
def test_rejects_private_addresses(self):
|
||||||
for endpoint in (
|
for endpoint in (
|
||||||
@@ -61,7 +65,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
|||||||
"https://0.0.0.0/token",
|
"https://0.0.0.0/token",
|
||||||
):
|
):
|
||||||
with self.subTest(endpoint=endpoint):
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_internal_hostnames(self):
|
def test_rejects_internal_hostnames(self):
|
||||||
for endpoint in (
|
for endpoint in (
|
||||||
@@ -71,16 +75,16 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
|||||||
"https://host.home.arpa/token",
|
"https://host.home.arpa/token",
|
||||||
):
|
):
|
||||||
with self.subTest(endpoint=endpoint):
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_non_default_port(self):
|
def test_rejects_non_default_port(self):
|
||||||
self.assertIsNotNone(
|
self.assertIsNotNone(
|
||||||
_validate_push_endpoint("https://fcm.googleapis.com:8080/fcm/send/token")
|
validate_push_endpoint("https://fcm.googleapis.com:8080/fcm/send/token")
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_rejects_embedded_credentials(self):
|
def test_rejects_embedded_credentials(self):
|
||||||
self.assertIsNotNone(
|
self.assertIsNotNone(
|
||||||
_validate_push_endpoint(
|
validate_push_endpoint(
|
||||||
"https://user:pass@fcm.googleapis.com/fcm/send/token"
|
"https://user:pass@fcm.googleapis.com/fcm/send/token"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -88,26 +92,26 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
|||||||
def test_rejects_endpoint_without_path(self):
|
def test_rejects_endpoint_without_path(self):
|
||||||
for endpoint in ("https://fcm.googleapis.com", "https://fcm.googleapis.com/"):
|
for endpoint in ("https://fcm.googleapis.com", "https://fcm.googleapis.com/"):
|
||||||
with self.subTest(endpoint=endpoint):
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_endpoint_that_breaks_audience_parsing(self):
|
def test_rejects_empty_hostname_labels(self):
|
||||||
# webpush.py locates the host by searching for a separator after index
|
for endpoint in (
|
||||||
# 10, which raises ValueError when the url has no path at all
|
"https://./x",
|
||||||
endpoint = "https://fcm.googleapis.com"
|
"https://fcm..googleapis.com/token",
|
||||||
|
"https://.fcm.googleapis.com/token",
|
||||||
with self.assertRaises(ValueError):
|
"https://fcm.googleapis.com./token",
|
||||||
endpoint.index("/", 10)
|
):
|
||||||
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_missing_or_non_string_endpoint(self):
|
def test_rejects_missing_or_non_string_endpoint(self):
|
||||||
for endpoint in (None, "", 5, {"url": "https://example.com/token"}):
|
for endpoint in (None, "", 5, {"url": "https://example.com/token"}):
|
||||||
with self.subTest(endpoint=endpoint):
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_overlong_endpoint(self):
|
def test_rejects_overlong_endpoint(self):
|
||||||
self.assertIsNotNone(
|
self.assertIsNotNone(
|
||||||
_validate_push_endpoint(f"https://fcm.googleapis.com/{'a' * 4096}")
|
validate_push_endpoint(f"https://fcm.googleapis.com/{'a' * 4096}")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -148,10 +152,6 @@ class TestValidateSubscription(unittest.TestCase):
|
|||||||
self.assertIsNotNone(_validate_subscription(sub))
|
self.assertIsNotNone(_validate_subscription(sub))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildWebPushers(unittest.TestCase):
|
class TestBuildWebPushers(unittest.TestCase):
|
||||||
def test_skips_subscriptions_with_malformed_keys(self):
|
def test_skips_subscriptions_with_malformed_keys(self):
|
||||||
bad = _subscription(VALID_ENDPOINTS[0])
|
bad = _subscription(VALID_ENDPOINTS[0])
|
||||||
@@ -163,3 +163,28 @@ class TestBuildWebPushers(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(pushers), 1)
|
self.assertEqual(len(pushers), 1)
|
||||||
self.assertEqual(pushers[0].receiver_key, b"\x04" + bytes(64))
|
self.assertEqual(pushers[0].receiver_key, b"\x04" + bytes(64))
|
||||||
|
|
||||||
|
def test_skips_subscriptions_with_invalid_endpoints(self):
|
||||||
|
good = _subscription(VALID_ENDPOINTS[0])
|
||||||
|
good["keys"]["p256dh"] = urlsafe_b64encode(b"\x04" + bytes(64)).decode()
|
||||||
|
bad = dict(good, endpoint="https://./x")
|
||||||
|
|
||||||
|
with self.assertLogs("frigate.comms.webpush", level="WARNING"):
|
||||||
|
pushers = _build_web_pushers("viewer", [bad, "not-a-dict", good])
|
||||||
|
|
||||||
|
self.assertEqual(len(pushers), 1)
|
||||||
|
self.assertEqual(pushers[0].subscription_info["endpoint"], VALID_ENDPOINTS[0])
|
||||||
|
|
||||||
|
|
||||||
|
class TestVapidAudience(unittest.TestCase):
|
||||||
|
def test_uses_scheme_and_host(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_vapid_audience(VALID_ENDPOINTS[0]), "https://fcm.googleapis.com"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
_vapid_audience(VALID_ENDPOINTS[4]), "https://fcm.googleapis.com:443"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user