API Consistency / Security Fixes (#24057)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s

* Make review user read status consistent with other APIs

* Validate URLs for web push endpoint

* Validate the role for a custom viewer, rate limit password changing

* Cleanup
This commit is contained in:
Nicolas Mowen
2026-08-22 11:08:24 -05:00
committed by GitHub
parent fc79aeab5e
commit ad79e666eb
6 changed files with 375 additions and 5 deletions
+5 -3
View File
@@ -971,6 +971,7 @@ def delete_user(request: Request, username: str):
summary="Update user password",
description="Updates a user's password. Users can only change their own password unless they have admin role. Requires the current password to verify identity for non-admin users. Password must be at least 12 characters long. If user changes their own password, a new JWT cookie is automatically issued.",
)
@limiter.limit(limit_value=rateLimiter.get_limit)
async def update_password(
request: Request,
username: str,
@@ -984,10 +985,11 @@ async def update_password(
current_username = current_user.get("username")
current_role = current_user.get("role")
# viewers can only change their own password
if current_role == "viewer" and current_username != username:
# Only admins may target another account. This has to cover every non-admin
# role rather than just viewer, since custom roles are arbitrary names
if current_role != "admin" and current_username != username:
raise HTTPException(
status_code=403, detail="Viewers can only update their own password"
status_code=403, detail="Users can only update their own password"
)
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
+102
View File
@@ -1,8 +1,10 @@
"""Notification apis."""
import ipaddress
import logging
import os
from typing import Any
from urllib.parse import urlparse
from cryptography.hazmat.primitives import serialization
from fastapi import APIRouter, Depends, Request
@@ -19,6 +21,95 @@ logger = logging.getLogger(__name__)
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:
"""Return a reason the subscription is unusable, or None when it is valid."""
if not isinstance(sub, dict):
return "subscription must be an object"
reason = _validate_push_endpoint(sub.get("endpoint"))
if reason:
return reason
keys = sub.get("keys")
if not isinstance(keys, dict):
return "subscription must include keys"
# WebPusher raises on a missing key, which would break every send for the
# user rather than just this registration
for name in ("p256dh", "auth"):
value = keys.get(name)
if not isinstance(value, str) or not value:
return f"subscription keys must include {name}"
return None
@router.get(
"/notifications/pubkey",
@@ -71,6 +162,17 @@ def register_notifications(request: Request, body: dict = None):
status_code=400,
)
reason = _validate_subscription(sub)
if reason:
logger.warning(
"Rejected notification registration for %s: %s", username, reason
)
return JSONResponse(
content={"success": False, "message": f"Invalid subscription: {reason}"},
status_code=400,
)
try:
User.update(notification_tokens=User.notification_tokens.append(sub)).where(
User.username == username
+3
View File
@@ -710,6 +710,7 @@ async def get_review(request: Request, review_id: str):
dependencies=[Depends(allow_any_authenticated())],
)
async def set_not_reviewed(
request: Request,
review_id: str,
current_user: dict = Depends(get_current_user),
):
@@ -728,6 +729,8 @@ async def set_not_reviewed(
status_code=404,
)
await require_camera_access(review.camera, request=request)
try:
user_review = UserReviewStatus.get(
UserReviewStatus.user_id == user_id,