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
+2
View File
@@ -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. 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. 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 ## Useful API Calls
+8 -7
View File
@@ -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 ### SA-004: User-controlled Mastodon instance creates SSRF and uncontrolled egress risk
**Severity:** High **Severity:** High, remediated in current worktree
**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. **Evidence before remediation:** Mastodon instance values were passed to outbound `urlopen()` calls with no DNS/IP-range or redirect controls.
**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. **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 ### 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. - [ ] 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. - [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. - [ ] 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. - [ ] Disable direct host publication of the application port in production.
- [ ] Configure HTTPS, HSTS, CSP, Referrer-Policy, frame protections, `nosniff`, and trusted hosts. - [ ] Configure HTTPS, HSTS, CSP, Referrer-Policy, frame protections, `nosniff`, and trusted hosts.
- [ ] Define a restrictive CORS policy or leave CORS disabled. - [ ] Define a restrictive CORS policy or leave CORS disabled.
@@ -252,7 +253,7 @@ Before production exposure:
2. Query-token removal and token rotation. 2. Query-token removal and token rotation.
3. Secret-at-rest protection and backup controls. 3. Secret-at-rest protection and backup controls.
4. Login/setup/OTP rate limiting. 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. 6. Production network and TLS hardening.
7. Extension permission and credential-storage review. 7. Extension permission and credential-storage review.
8. Security headers, trusted hosts, request limits, media validation, recovery codes, and audit logging. 8. Security headers, trusted hosts, request limits, media validation, recovery codes, and audit logging.
+12
View File
@@ -951,6 +951,18 @@ Address SA-003 by supplying a dedicated protected encryption key and encrypting
### Assistant outcome ### 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. 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 ### User
When the user is logged in, the web plugin should not display “Please sign in to use LinkLog.” When the user is logged in, the web plugin should not display “Please sign in to use LinkLog.”
+2
View File
@@ -170,6 +170,8 @@
153. Remove the entire "New primary email address" block; keep only selecting an existing alternative as primary. 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. 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 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." 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 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. 159. The plugin still does not behave as expected. It still shows that the user should sign in.
+4 -6
View File
@@ -7,19 +7,17 @@ import json
from secrets import token_urlsafe from secrets import token_urlsafe
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.error import HTTPError from urllib.error import HTTPError
from urllib.request import Request, urlopen from urllib.request import Request
from uuid import uuid4 from uuid import uuid4
from backend.app.core.config import settings from backend.app.core.config import settings
from backend.app.database import get_connection from backend.app.database import get_connection
from backend.app.services.secret_store import decrypt_secret, encrypt_secret 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: def normalize_instance(instance: str) -> str:
value = instance.strip().rstrip('/') return validate_public_instance(instance)
if not value:
raise ValueError('Mastodon instance is required')
return value if '://' in value else f'https://{value}'
def post_form(url: str, values: dict) -> dict: 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'}, headers={'Content-Type': 'application/x-www-form-urlencoded'},
method='POST', 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')) 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 import logging
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import urlencode 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.plugins.base import BasePlugin
from backend.app.services.secret_store import decrypt_secret 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: ' DEFAULT_POST_PREFIX = 'From my #LinkLog: '
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -50,9 +51,10 @@ class MastodonPlugin(BasePlugin):
config.update(json.loads(row['config'])) config.update(json.loads(row['config']))
config['access_token'] = decrypt_secret(config.get('access_token', '')) config['access_token'] = decrypt_secret(config.get('access_token', ''))
instance = str(config.get('instance', '')).strip().rstrip('/') try:
if instance and '://' not in instance: instance = validate_public_instance(str(config.get('instance', '')))
instance = f'https://{instance}' except ValueError as error:
return {'status': 'failed', 'plugin': self.name, 'reason': str(error)}
access_token = str(config.get('access_token', '')).strip() access_token = str(config.get('access_token', '')).strip()
if not instance or not access_token: if not instance or not access_token:
logger.debug( logger.debug(
@@ -92,7 +94,7 @@ class MastodonPlugin(BasePlugin):
}, },
method='POST', 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') response_body = response.read().decode('utf-8')
logger.debug( logger.debug(
'Mastodon post response: endpoint=%s status=%s body_length=%d', 'Mastodon post response: endpoint=%s status=%s body_length=%d',
@@ -139,9 +141,10 @@ class MastodonPlugin(BasePlugin):
config.update(json.loads(row['config'])) config.update(json.loads(row['config']))
config['access_token'] = decrypt_secret(config.get('access_token', '')) config['access_token'] = decrypt_secret(config.get('access_token', ''))
instance = str(config.get('instance', '')).strip().rstrip('/') try:
if instance and '://' not in instance: instance = validate_public_instance(str(config.get('instance', '')))
instance = f'https://{instance}' except ValueError as error:
return {'status': 'failed', 'plugin': self.name, 'reason': str(error)}
access_token = str(config.get('access_token', '')).strip() access_token = str(config.get('access_token', '')).strip()
post_ids = event.get('mastodon_post_ids') or [] post_ids = event.get('mastodon_post_ids') or []
if not post_ids and event.get('mastodon_post_id'): 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'}, headers={'Authorization': f'Bearer {access_token}', 'User-Agent': 'LinkLog/1.0'},
method='DELETE', method='DELETE',
) )
with urlopen(request, timeout=5) as response: with open_no_redirect(request, timeout=5) as response:
response.read() response.read()
return {'status': 'deleted', 'plugin': self.name, 'count': len(post_ids)} return {'status': 'deleted', 'plugin': self.name, 'count': len(post_ids)}
except HTTPError as error: except HTTPError as error:
+15 -13
View File
@@ -648,19 +648,20 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
try: try:
headers = login_headers() headers = login_headers()
base_url = f'http://127.0.0.1:{server.server_port}' base_url = f'http://127.0.0.1:{server.server_port}'
assert client.put('/api/user/plugins/mastodon', headers=headers, json={ with patch('backend.app.services.plugin_manager.validate_public_instance', return_value=base_url):
'instance': base_url, assert client.put('/api/user/plugins/mastodon', headers=headers, json={
'access_token': 'test-token', 'instance': base_url,
'post_prefix': 'From my #LinkLog: ', 'access_token': 'test-token',
}).status_code == 200 'post_prefix': 'From my #LinkLog: ',
assert client.put('/api/admin/plugins/mastodon', headers=headers, json={'enabled': True}).status_code == 200 }).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={ response = client.post('/api/links', headers=headers, json={
'title': 'A useful page', 'title': 'A useful page',
'url': 'https://example.com/useful', 'url': 'https://example.com/useful',
'comment': 'Worth sharing', 'comment': 'Worth sharing',
'tags': ['#python', '#web'], 'tags': ['#python', '#web'],
}) })
assert response.status_code == 201 assert response.status_code == 201
assert received['path'] == '/api/v1/statuses' assert received['path'] == '/api/v1/statuses'
@@ -683,7 +684,8 @@ def test_mastodon_post_without_title_omits_source_line():
plugin = MastodonPlugin() plugin = MastodonPlugin()
plugin.initialize({'instance': 'https://mastodon.example', 'access_token': 'test-token'}) 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({ result = plugin.handle_event({
'url': 'https://example.com/useful', 'url': 'https://example.com/useful',
'title': '', '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)