fix: harden server-initiated HTTP against SSRF and DNS rebinding

Route all server-initiated outbound HTTP through one SSRF-protected layer:

- Resolve each host once, validate every resolved address (private,
  loopback, link-local, CGNAT 100.64/10, multicast, reserved, unspecified,
  IPv4-mapped IPv6), and reject if any is unsafe.
- Pin the connection to the validated address via curl's RESOLVE option so
  curl cannot re-resolve to a rebound IP (closes the validate-then-connect
  TOCTOU). Uses getaddrinfo so IPv6 records are checked too.
- Add a sync SafeTransport and safehttp.post, and move the webhook and
  recipe-action paths off raw requests.post so they are guarded like the
  scraper.
- Add HTTP_ALLOW_LIST / HTTP_DISALLOW_LIST settings (deny > allow > default)
  so operators can reach an internal server or block extra hosts.

Apprise notifications egress through the third-party library and are not
covered here.
This commit is contained in:
Hayden
2026-07-19 21:41:30 -05:00
parent db4262dc67
commit ce88ed6841
11 changed files with 435 additions and 57 deletions

View File

@@ -1,9 +1,9 @@
from uuid import UUID, uuid4
import pytest
import requests
from fastapi.testclient import TestClient
from mealie.pkgs import safehttp
from mealie.schema.household.group_recipe_action import (
CreateGroupRecipeAction,
GroupRecipeActionOut,
@@ -17,8 +17,8 @@ from tests.utils.fixture_schemas import TestUser
@pytest.fixture(autouse=True)
def mock_requests_post(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(requests, "post", lambda *args, **kwargs: None)
def mock_outbound_post(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(safehttp, "post", lambda *args, **kwargs: None)
def create_action(action_type: GroupRecipeActionType = GroupRecipeActionType.link) -> CreateGroupRecipeAction:

View File

@@ -91,7 +91,7 @@ def test_delete_webhook(api_client: TestClient, webhook_data, unique_user: TestU
def test_post_test_webhook(
monkeypatch: pytest.MonkeyPatch, api_client: TestClient, unique_user: TestUser, webhook_data
):
# Mock the requests.post to avoid actual HTTP calls
# Mock the outbound post to avoid actual HTTP calls
class MockResponse:
status_code = 200
@@ -101,7 +101,7 @@ def test_post_test_webhook(
mock_calls.append((args, kwargs))
return MockResponse()
monkeypatch.setattr("mealie.services.event_bus_service.publisher.requests.post", mock_post)
monkeypatch.setattr("mealie.services.event_bus_service.publisher.safehttp.post", mock_post)
# Create a webhook and post it
response = api_client.post(
@@ -124,7 +124,7 @@ def test_post_test_webhook(
test_message = "This is a test webhook message"
post_test_webhook(webhook, test_message)
# Verify that requests.post was called with the correct parameters
# Verify that the outbound post was called with the correct parameters
assert len(mock_calls) == 1
args, kwargs = mock_calls[0]