mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 00:27:11 +09:00
Event subscriptions, the webhook gateway's forward_url and the Discord/Slack/generic alert hooks posted to whatever URL a user typed, from inside the network: the panel's own API on loopback, LAN hosts, cloud metadata. Subscriptions followed redirects and stored the reply, and the gateway forward can be triggered without logging in. safe_http.post refuses non-public addresses, connects to the address it checked so DNS can't swap it afterwards, never follows redirects and caps the body it keeps. SERVERKIT_ALLOW_PRIVATE_WEBHOOKS=1 restores internal targets for operators who need them.
151 lines
5.9 KiB
Python
151 lines
5.9 KiB
Python
"""safe_http.post must refuse internal targets, connect to the address it
|
|
checked, never follow redirects, and cap the body it keeps."""
|
|
import socket
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
import pytest
|
|
|
|
from app.utils import safe_http
|
|
from app.utils.safe_http import UnsafeURLError, post, resolve_public
|
|
|
|
_real_getaddrinfo = socket.getaddrinfo
|
|
|
|
|
|
def _fake_dns(mapping):
|
|
def getaddrinfo(host, port, *args, **kwargs):
|
|
if host in mapping:
|
|
family = socket.AF_INET6 if ':' in mapping[host] else socket.AF_INET
|
|
return [(family, socket.SOCK_STREAM, 6, '', (mapping[host], port))]
|
|
return _real_getaddrinfo(host, port, *args, **kwargs)
|
|
return getaddrinfo
|
|
|
|
|
|
@pytest.mark.parametrize('ip', [
|
|
'127.0.0.1', '10.0.0.5', '172.16.3.4', '192.168.1.1', '169.254.169.254',
|
|
'100.64.0.1', '0.0.0.0', '::1', 'fd00::1', 'fe80::1', '::ffff:127.0.0.1',
|
|
'::ffff:169.254.169.254', '224.0.0.1',
|
|
])
|
|
def test_internal_addresses_are_refused(monkeypatch, ip):
|
|
monkeypatch.setattr(socket, 'getaddrinfo', _fake_dns({'hook.test': ip}))
|
|
with pytest.raises(UnsafeURLError):
|
|
resolve_public('https://hook.test/x')
|
|
|
|
|
|
def test_a_name_with_any_internal_answer_is_refused(monkeypatch):
|
|
def getaddrinfo(host, port, *a, **k):
|
|
return [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('93.184.216.34', port)),
|
|
(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('10.0.0.1', port))]
|
|
monkeypatch.setattr(socket, 'getaddrinfo', getaddrinfo)
|
|
with pytest.raises(UnsafeURLError):
|
|
resolve_public('https://hook.test/x')
|
|
|
|
|
|
@pytest.mark.parametrize('url', ['file:///etc/passwd', 'gopher://x/', 'ftp://h/', '', 'http://'])
|
|
def test_non_http_urls_are_refused(url):
|
|
with pytest.raises(UnsafeURLError):
|
|
resolve_public(url)
|
|
|
|
|
|
def test_public_address_is_allowed(monkeypatch):
|
|
monkeypatch.setattr(socket, 'getaddrinfo', _fake_dns({'hook.test': '93.184.216.34'}))
|
|
parts, ip = resolve_public('https://hook.test/x')
|
|
assert str(ip) == '93.184.216.34' and parts.hostname == 'hook.test'
|
|
|
|
|
|
def test_refused_before_any_connection(monkeypatch):
|
|
monkeypatch.setattr(socket, 'getaddrinfo', _fake_dns({'hook.test': '169.254.169.254'}))
|
|
monkeypatch.setattr(safe_http.requests, 'Session',
|
|
lambda: pytest.fail('a request was attempted'))
|
|
with pytest.raises(UnsafeURLError):
|
|
post('http://hook.test/latest/meta-data/')
|
|
|
|
|
|
@pytest.fixture
|
|
def server():
|
|
seen = []
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_POST(self):
|
|
seen.append({'host': self.headers['Host'], 'path': self.path,
|
|
'body': self.rfile.read(int(self.headers['Content-Length'] or 0))})
|
|
if self.path == '/redirect':
|
|
self.send_response(302)
|
|
self.send_header('Location', 'http://169.254.169.254/')
|
|
self.end_headers()
|
|
return
|
|
body = b'x' * (1024 * 1024) if self.path == '/big' else b'ok'
|
|
self.send_response(200)
|
|
self.send_header('Content-Length', str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
httpd = HTTPServer(('127.0.0.1', 0), Handler)
|
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
thread.start()
|
|
yield httpd.server_address[1], seen
|
|
httpd.shutdown()
|
|
|
|
|
|
def test_connects_to_the_checked_address_with_the_original_host(monkeypatch, server):
|
|
port, seen = server
|
|
monkeypatch.setenv(safe_http.ALLOW_PRIVATE_ENV, '1')
|
|
monkeypatch.setattr(socket, 'getaddrinfo', _fake_dns({'hook.test': '127.0.0.1'}))
|
|
resp = post(f'http://hook.test:{port}/in?a=1', data=b'payload')
|
|
assert resp.status_code == 200 and resp.text == 'ok'
|
|
assert seen == [{'host': f'hook.test:{port}', 'path': '/in?a=1', 'body': b'payload'}]
|
|
|
|
|
|
def test_redirects_are_not_followed(monkeypatch, server):
|
|
port, seen = server
|
|
monkeypatch.setenv(safe_http.ALLOW_PRIVATE_ENV, '1')
|
|
resp = post(f'http://127.0.0.1:{port}/redirect')
|
|
assert resp.status_code == 302
|
|
assert len(seen) == 1
|
|
|
|
|
|
def test_response_body_is_capped(monkeypatch, server):
|
|
port, _ = server
|
|
monkeypatch.setenv(safe_http.ALLOW_PRIVATE_ENV, '1')
|
|
resp = post(f'http://127.0.0.1:{port}/big', max_bytes=1000)
|
|
assert len(resp.content) == 1000
|
|
|
|
|
|
def test_loopback_needs_the_opt_in(server):
|
|
port, seen = server
|
|
with pytest.raises(UnsafeURLError):
|
|
post(f'http://127.0.0.1:{port}/in')
|
|
assert seen == []
|
|
|
|
|
|
# The user-configured sinks all go through safe_http: the metadata address
|
|
# (an IP literal, so no DNS involved) must be refused by each of them.
|
|
METADATA = 'http://169.254.169.254/latest/meta-data/'
|
|
_ALERT = [{'type': 'cpu', 'severity': 'critical', 'message': 'hot', 'value': 99, 'threshold': 90}]
|
|
|
|
|
|
@pytest.mark.parametrize('sender', ['send_discord', 'send_slack', 'send_generic_webhook'])
|
|
def test_notification_webhooks_refuse_internal_urls(app, monkeypatch, sender):
|
|
from app.services.notification_service import NotificationService
|
|
monkeypatch.setattr(safe_http.requests, 'Session',
|
|
lambda: pytest.fail('a request was attempted'))
|
|
result = getattr(NotificationService, sender)(
|
|
_ALERT, {'enabled': True, 'webhook_url': METADATA, 'url': METADATA})
|
|
assert result['success'] is False
|
|
assert 'non-public' in result['error']
|
|
|
|
|
|
def test_gateway_forward_refuses_internal_urls(app, monkeypatch):
|
|
from types import SimpleNamespace
|
|
from app.services.webhook_gateway_service import WebhookGatewayService
|
|
monkeypatch.setattr(safe_http.requests, 'Session',
|
|
lambda: pytest.fail('a request was attempted'))
|
|
endpoint = SimpleNamespace(forward_url=METADATA, retry_count=1)
|
|
delivery = SimpleNamespace(event_id='e1')
|
|
result = WebhookGatewayService._forward(endpoint, delivery, b'{}', {})
|
|
assert result['success'] is False
|
|
assert 'non-public' in result['error']
|