SA-4 SSRF protections implemented.

This commit is contained in:
2026-08-26 16:52:59 +02:00
parent 3e61302bf6
commit c70850b44d
9 changed files with 151 additions and 35 deletions
+4 -6
View File
@@ -7,19 +7,17 @@ import json
from secrets import token_urlsafe
from urllib.parse import urlencode
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from urllib.request import Request
from uuid import uuid4
from backend.app.core.config import settings
from backend.app.database import get_connection
from backend.app.services.secret_store import decrypt_secret, encrypt_secret
from backend.app.services.mastodon_security import open_no_redirect, validate_public_instance
def normalize_instance(instance: str) -> str:
value = instance.strip().rstrip('/')
if not value:
raise ValueError('Mastodon instance is required')
return value if '://' in value else f'https://{value}'
return validate_public_instance(instance)
def post_form(url: str, values: dict) -> dict:
@@ -29,7 +27,7 @@ def post_form(url: str, values: dict) -> dict:
headers={'Content-Type': 'application/x-www-form-urlencoded'},
method='POST',
)
with urlopen(request, timeout=10) as response:
with open_no_redirect(request, timeout=10) as response:
return json.loads(response.read().decode('utf-8'))
+62
View File
@@ -0,0 +1,62 @@
## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
import ipaddress
import socket
from urllib.parse import urlsplit
from urllib.error import HTTPError
from urllib.request import HTTPRedirectHandler, Request, build_opener
class RejectRedirectHandler(HTTPRedirectHandler):
def redirect_request(self, request, file, code, msg, headers, newurl):
raise HTTPError(request.full_url, code, 'Redirects are not allowed', headers, None)
NO_REDIRECT_OPENER = build_opener(RejectRedirectHandler)
def _is_blocked_address(address: str) -> bool:
parsed = ipaddress.ip_address(address)
mapped = parsed.ipv4_mapped if isinstance(parsed, ipaddress.IPv6Address) else None
candidates = (parsed, mapped) if mapped else (parsed,)
return any(
candidate.is_loopback
or candidate.is_link_local
or candidate.is_private
or candidate.is_multicast
or candidate.is_unspecified
or candidate.is_reserved
for candidate in candidates
)
def validate_public_instance(instance: str) -> str:
value = instance.strip().rstrip('/')
if not value:
raise ValueError('Mastodon instance is required')
if '://' not in value:
value = f'https://{value}'
parsed = urlsplit(value)
if parsed.scheme.lower() != 'https' or not parsed.hostname:
raise ValueError('Mastodon instance must be an HTTPS public hostname')
if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ('', '/'):
raise ValueError('Mastodon instance must be a hostname-only HTTPS URL')
try:
port = parsed.port
except ValueError as error:
raise ValueError('Mastodon instance has an invalid port') from error
if port not in (None, 443):
raise ValueError('Mastodon instance must use HTTPS port 443')
hostname = parsed.hostname.rstrip('.').lower()
try:
addresses = {result[4][0] for result in socket.getaddrinfo(hostname, port or 443, type=socket.SOCK_STREAM)}
except socket.gaierror as error:
raise ValueError('Mastodon instance hostname could not be resolved') from error
if not addresses or any(_is_blocked_address(address) for address in addresses):
raise ValueError('Mastodon instance must resolve only to public IP addresses')
return f'https://{hostname}'
def open_no_redirect(request: Request, timeout: int = 10):
return NO_REDIRECT_OPENER.open(request, timeout=timeout)
+12 -9
View File
@@ -5,10 +5,11 @@ import json
import logging
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from urllib.request import Request
from backend.app.plugins.base import BasePlugin
from backend.app.services.secret_store import decrypt_secret
from backend.app.services.mastodon_security import open_no_redirect, validate_public_instance
DEFAULT_POST_PREFIX = 'From my #LinkLog: '
logger = logging.getLogger(__name__)
@@ -50,9 +51,10 @@ class MastodonPlugin(BasePlugin):
config.update(json.loads(row['config']))
config['access_token'] = decrypt_secret(config.get('access_token', ''))
instance = str(config.get('instance', '')).strip().rstrip('/')
if instance and '://' not in instance:
instance = f'https://{instance}'
try:
instance = validate_public_instance(str(config.get('instance', '')))
except ValueError as error:
return {'status': 'failed', 'plugin': self.name, 'reason': str(error)}
access_token = str(config.get('access_token', '')).strip()
if not instance or not access_token:
logger.debug(
@@ -92,7 +94,7 @@ class MastodonPlugin(BasePlugin):
},
method='POST',
)
with urlopen(request, timeout=5) as response:
with open_no_redirect(request, timeout=5) as response:
response_body = response.read().decode('utf-8')
logger.debug(
'Mastodon post response: endpoint=%s status=%s body_length=%d',
@@ -139,9 +141,10 @@ class MastodonPlugin(BasePlugin):
config.update(json.loads(row['config']))
config['access_token'] = decrypt_secret(config.get('access_token', ''))
instance = str(config.get('instance', '')).strip().rstrip('/')
if instance and '://' not in instance:
instance = f'https://{instance}'
try:
instance = validate_public_instance(str(config.get('instance', '')))
except ValueError as error:
return {'status': 'failed', 'plugin': self.name, 'reason': str(error)}
access_token = str(config.get('access_token', '')).strip()
post_ids = event.get('mastodon_post_ids') or []
if not post_ids and event.get('mastodon_post_id'):
@@ -156,7 +159,7 @@ class MastodonPlugin(BasePlugin):
headers={'Authorization': f'Bearer {access_token}', 'User-Agent': 'LinkLog/1.0'},
method='DELETE',
)
with urlopen(request, timeout=5) as response:
with open_no_redirect(request, timeout=5) as response:
response.read()
return {'status': 'deleted', 'plugin': self.name, 'count': len(post_ids)}
except HTTPError as error:
+15 -13
View File
@@ -648,19 +648,20 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
try:
headers = login_headers()
base_url = f'http://127.0.0.1:{server.server_port}'
assert client.put('/api/user/plugins/mastodon', headers=headers, json={
'instance': base_url,
'access_token': 'test-token',
'post_prefix': 'From my #LinkLog: ',
}).status_code == 200
assert client.put('/api/admin/plugins/mastodon', headers=headers, json={'enabled': True}).status_code == 200
with patch('backend.app.services.plugin_manager.validate_public_instance', return_value=base_url):
assert client.put('/api/user/plugins/mastodon', headers=headers, json={
'instance': base_url,
'access_token': 'test-token',
'post_prefix': 'From my #LinkLog: ',
}).status_code == 200
assert client.put('/api/admin/plugins/mastodon', headers=headers, json={'enabled': True}).status_code == 200
response = client.post('/api/links', headers=headers, json={
'title': 'A useful page',
'url': 'https://example.com/useful',
'comment': 'Worth sharing',
'tags': ['#python', '#web'],
})
response = client.post('/api/links', headers=headers, json={
'title': 'A useful page',
'url': 'https://example.com/useful',
'comment': 'Worth sharing',
'tags': ['#python', '#web'],
})
assert response.status_code == 201
assert received['path'] == '/api/v1/statuses'
@@ -683,7 +684,8 @@ def test_mastodon_post_without_title_omits_source_line():
plugin = MastodonPlugin()
plugin.initialize({'instance': 'https://mastodon.example', 'access_token': 'test-token'})
with patch('backend.app.services.plugin_manager.urlopen', return_value=response) as open_url:
with patch('backend.app.services.plugin_manager.open_no_redirect', return_value=response) as open_url, \
patch('backend.app.services.plugin_manager.validate_public_instance', return_value='https://mastodon.example'):
result = plugin.handle_event({
'url': 'https://example.com/useful',
'title': '',
+34
View File
@@ -0,0 +1,34 @@
## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
from unittest.mock import patch
from urllib.error import HTTPError
from urllib.request import Request
import pytest
from backend.app.services.mastodon_security import open_no_redirect, validate_public_instance
@pytest.mark.parametrize('instance', [
'http://mastodon.example',
'https://127.0.0.1',
'https://[::ffff:127.0.0.1]',
'https://user:password@mastodon.example',
])
def test_mastodon_instance_rejects_unsafe_urls(instance):
with pytest.raises(ValueError):
validate_public_instance(instance)
def test_mastodon_instance_rejects_private_dns_result():
with patch('backend.app.services.mastodon_security.socket.getaddrinfo', return_value=[(2, 1, 6, '', ('10.0.0.5', 443))]):
with pytest.raises(ValueError, match='public IP'):
validate_public_instance('https://mastodon.example')
def test_mastodon_outbound_redirects_are_rejected():
request = Request('https://mastodon.example/api/v1/statuses')
with patch('backend.app.services.mastodon_security.NO_REDIRECT_OPENER.open', side_effect=HTTPError(request.full_url, 302, 'Redirects are not allowed', {}, None)):
with pytest.raises(HTTPError, match='Redirects are not allowed'):
open_no_redirect(request)