SA-4 SSRF protections implemented.
This commit is contained in:
@@ -231,6 +231,8 @@ After logging in, open <http://localhost:8000/profile>, enter the Mastodon insta
|
||||
|
||||
LinkLog caches the OAuth application credentials per Mastodon server in the persistent SQLite `app_settings` table, so subsequent connections do not register a new application on every attempt. If the server rate-limits application registration, the profile page reports the upstream `429` response and the user can retry after the server's cooldown.
|
||||
|
||||
Mastodon instances must be HTTPS hostname-only URLs that resolve to public IP addresses. Loopback, private, link-local, multicast, unspecified, reserved, and IPv4-mapped IPv6 destinations are rejected, and outbound redirects are refused.
|
||||
|
||||
Enable the plugin from the admin API or the admin page. New links are saved first and then posted to the configured instance at `/api/v1/statuses`. A Mastodon network failure does not undo the saved link.
|
||||
|
||||
## Useful API Calls
|
||||
|
||||
+8
-7
@@ -78,13 +78,14 @@ These findings are prioritized below. Severity describes the potential security
|
||||
|
||||
### SA-004: User-controlled Mastodon instance creates SSRF and uncontrolled egress risk
|
||||
|
||||
**Severity:** High
|
||||
**Evidence:** `normalize_instance()` in `backend/app/services/mastodon_oauth.py` and the outbound requests in `backend/app/services/plugin_manager.py` accept an instance supplied by the user and call `urlopen()` against it.
|
||||
**Impact:** A user can potentially configure an internal hostname, loopback address, cloud metadata endpoint, or other private network destination. The backend may send OAuth registration, token, status, or deletion requests to that destination. In addition to SSRF, this bypasses expected network egress policy and may disclose OAuth-related request data to an unintended host.
|
||||
**Severity:** High, remediated in current worktree
|
||||
**Evidence before remediation:** Mastodon instance values were passed to outbound `urlopen()` calls with no DNS/IP-range or redirect controls.
|
||||
**Current state:** `mastodon_security.py` requires hostname-only HTTPS URLs, resolves DNS, rejects loopback, link-local, private, multicast, unspecified, reserved, and IPv4-mapped IPv6 addresses, and uses an opener that refuses redirects. OAuth, posting, and deletion all use these controls.
|
||||
**Residual impact:** DNS and network policy can change after validation; production deployments should still use egress firewalling or a restricted outbound proxy.
|
||||
|
||||
**Recommendation:** Validate Mastodon instances as HTTPS public hostnames. Resolve DNS and reject loopback, link-local, private, multicast, unspecified, and reserved IP ranges, including IPv4-mapped IPv6 addresses. Re-check after redirects and disable or strictly limit redirects. Prefer an outbound proxy with an allow-list and network egress policy. Set explicit URL and response-size limits and use a vetted HTTP client with safe redirect handling. Do not accept arbitrary schemes.
|
||||
**Recommendation:** Keep outbound firewalling or an allow-listed proxy in production, monitor DNS rebinding risk, and maintain response-size/time limits.
|
||||
|
||||
**Priority:** High.
|
||||
**Priority:** Completed in code; network-level egress controls remain.
|
||||
|
||||
### SA-005: Login endpoint lacks rate limiting and lockout
|
||||
|
||||
@@ -233,7 +234,7 @@ Before production exposure:
|
||||
- [ ] Remove query-string token authentication and rotate existing access tokens.
|
||||
- [x] Encrypt newly written SMTP, Mastodon, OAuth, and OTP secrets at rest; protect the encryption key separately. Rotate legacy plaintext values.
|
||||
- [ ] Add login, OTP, reset-mail, and setup rate limiting.
|
||||
- [ ] Restrict Mastodon instance validation and outbound network egress.
|
||||
- [x] Validate Mastodon instances as HTTPS public hostnames, reject unsafe DNS/IP ranges, and block redirects. Keep network-level egress controls in production.
|
||||
- [ ] Disable direct host publication of the application port in production.
|
||||
- [ ] Configure HTTPS, HSTS, CSP, Referrer-Policy, frame protections, `nosniff`, and trusted hosts.
|
||||
- [ ] Define a restrictive CORS policy or leave CORS disabled.
|
||||
@@ -252,7 +253,7 @@ Before production exposure:
|
||||
2. Query-token removal and token rotation.
|
||||
3. Secret-at-rest protection and backup controls.
|
||||
4. Login/setup/OTP rate limiting.
|
||||
5. Mastodon SSRF and egress restrictions.
|
||||
5. Production network egress controls for Mastodon.
|
||||
6. Production network and TLS hardening.
|
||||
7. Extension permission and credential-storage review.
|
||||
8. Security headers, trusted hosts, request limits, media validation, recovery codes, and audit logging.
|
||||
|
||||
@@ -951,6 +951,18 @@ Address SA-003 by supplying a dedicated protected encryption key and encrypting
|
||||
### Assistant outcome
|
||||
Added Fernet encryption with required `LINKLOG_DATA_ENCRYPTION_KEY` handling for SMTP, Mastodon OAuth/client and access-token values, TOTP seeds, and recognized administrator plugin secrets. Updated Docker/Compose configuration, tests, README guidance, and the security audit. Legacy plaintext values require rotation after key configuration.
|
||||
|
||||
### User
|
||||
Address SA-004 by validating Mastodon instances as HTTPS public hostnames, rejecting unsafe DNS/IP ranges including mapped IPv6, and blocking redirects.
|
||||
|
||||
### Assistant outcome
|
||||
Implemented and validated SA-004 with centralized Mastodon URL checks, DNS resolution and blocked-range detection, IPv4-mapped IPv6 handling, and no-redirect outbound requests for OAuth, posting, and deletion. The rebuilt container suite passed with 42 tests.
|
||||
|
||||
### User
|
||||
Address SA-004 by validating Mastodon instances as HTTPS public hostnames, rejecting unsafe DNS/IP ranges including mapped IPv6, and blocking redirects.
|
||||
|
||||
### Assistant outcome
|
||||
Added centralized Mastodon SSRF protection for OAuth, posting, and deletion. Validation requires HTTPS hostname-only instances, resolves and rejects private or reserved destinations, and all outbound requests use a no-redirect opener. Added security tests, documentation, and audit status updates.
|
||||
|
||||
### User
|
||||
When the user is logged in, the web plugin should not display “Please sign in to use LinkLog.”
|
||||
|
||||
|
||||
@@ -170,6 +170,8 @@
|
||||
153. Remove the entire "New primary email address" block; keep only selecting an existing alternative as primary.
|
||||
155. Remove the “New primary email address” functionality and keep only selecting an existing alternative as primary.
|
||||
166. Address SA-003 by using a dedicated secret key supplied through a protected environment/secret file, encrypt sensitive values before SQLite storage
|
||||
167. Address SA-004 by validating Mastodon instances as HTTPS public hostnames, rejecting unsafe DNS/IP ranges including mapped IPv6, and blocking redirects
|
||||
167. Address SA-004 by validating Mastodon instances as HTTPS public hostnames, rejecting unsafe DNS/IP ranges including mapped IPv6, and blocking redirects.
|
||||
158. When the user is logged in the webplugin should not display "Please sign in to use LinkLog."
|
||||
156. When the user is signed in the plugin should not display "Please sign in to use LinkLog." and the link to the settings
|
||||
159. The plugin still does not behave as expected. It still shows that the user should sign in.
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -648,6 +648,7 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
|
||||
try:
|
||||
headers = login_headers()
|
||||
base_url = f'http://127.0.0.1:{server.server_port}'
|
||||
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',
|
||||
@@ -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': '',
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user