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: