{"title":"SSRF via requests.get on Unvalidated Webhook URL","language":"Python","severity":"High","cwe":"CWE-918","source_lines":[8],"flow_lines":[8,11],"sink_lines":[11],"vulnerable_code":"import requests\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\n@app.route('/api/iot/device/healthcheck', methods=['POST'])\ndef trigger_device_health_probe():\n    device_callback = request.json.get('callback_endpoint')\n    device_id = request.json.get('device_id', 'unknown')\n    probe_data = {'device_id': device_id, 'timestamp': '2024-01-15T10:30:00Z'}\n    try:\n        response = requests.get(device_callback, params=probe_data, timeout=5)\n        return jsonify({'status': 'probe_sent', 'response_code': response.status_code, 'body': response.text[:200]})\n    except Exception as e:\n        return jsonify({'status': 'probe_failed', 'error': str(e)}), 500","explanation":"The application accepts a user-controlled 'callback_endpoint' URL without validation and directly passes it to requests.get(), enabling Server-Side Request Forgery (SSRF). An attacker can force the server to make requests to internal network resources, cloud metadata endpoints, or other restricted services that are inaccessible from the external network.","remediation":"The fix validates the callback URL before making any outbound request by checking the URL scheme (only HTTPS allowed), resolving the hostname to its IP addresses, and verifying none of them fall within private/internal network ranges (RFC 1918, link-local, loopback, cloud metadata). Additionally, redirects are disabled to prevent redirect-based SSRF bypasses, the response body is no longer leaked back to the caller, and an optional domain allowlist provides defense-in-depth.","secure_code":"import requests\nimport ipaddress\nfrom urllib.parse import urlparse\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\nALLOWED_SCHEMES = {'https'}\nBLOCKED_NETWORKS = [\n    ipaddress.ip_network('10.0.0.0/8'),\n    ipaddress.ip_network('172.16.0.0/12'),\n    ipaddress.ip_network('192.168.0.0/16'),\n    ipaddress.ip_network('127.0.0.0/8'),\n    ipaddress.ip_network('169.254.0.0/16'),\n    ipaddress.ip_network('0.0.0.0/8'),\n    ipaddress.ip_network('::1/128'),\n    ipaddress.ip_network('fc00::/7'),\n    ipaddress.ip_network('fe80::/10'),\n]\n\nALLOWED_CALLBACK_DOMAINS = []  # Configure with trusted device callback domains e.g. ['devices.example.com']\n\n\ndef is_safe_url(url):\n    \"\"\"Validate that the URL is safe to request (not targeting internal resources).\"\"\"\n    try:\n        parsed = urlparse(url)\n    except Exception:\n        return False, \"Invalid URL format\"\n\n    # Check scheme\n    if parsed.scheme not in ALLOWED_SCHEMES:\n        return False, f\"Scheme '{parsed.scheme}' not allowed. Only {ALLOWED_SCHEMES} permitted.\"\n\n    # Check that hostname exists\n    hostname = parsed.hostname\n    if not hostname:\n        return False, \"No hostname found in URL\"\n\n    # If allowlist is configured, enforce it\n    if ALLOWED_CALLBACK_DOMAINS:\n        if not any(hostname == domain or hostname.endswith('.' + domain) for domain in ALLOWED_CALLBACK_DOMAINS):\n            return False, f\"Hostname '{hostname}' is not in the allowed callback domains\"\n\n    # Resolve hostname and check against blocked networks\n    import socket\n    try:\n        addr_infos = socket.getaddrinfo(hostname, parsed.port or 443)\n    except socket.gaierror:\n        return False, f\"Unable to resolve hostname '{hostname}'\"\n\n    for addr_info in addr_infos:\n        ip = ipaddress.ip_address(addr_info[4][0])\n        for blocked_network in BLOCKED_NETWORKS:\n            if ip in blocked_network:\n                return False, f\"Resolved IP {ip} is in a blocked network range\"\n\n    return True, None\n\n\n@app.route('/api/iot/device/healthcheck', methods=['POST'])\ndef trigger_device_health_probe():\n    device_callback = request.json.get('callback_endpoint')\n    device_id = request.json.get('device_id', 'unknown')\n\n    if not device_callback or not isinstance(device_callback, str):\n        return jsonify({'status': 'error', 'message': 'Invalid or missing callback_endpoint'}), 400\n\n    # Validate the callback URL before making the request\n    is_safe, rejection_reason = is_safe_url(device_callback)\n    if not is_safe:\n        return jsonify({'status': 'rejected', 'message': f'Callback URL rejected: {rejection_reason}'}), 400\n\n    probe_data = {'device_id': device_id, 'timestamp': '2024-01-15T10:30:00Z'}\n    try:\n        response = requests.get(device_callback, params=probe_data, timeout=5, allow_redirects=False)\n        return jsonify({'status': 'probe_sent', 'response_code': response.status_code})\n    except Exception as e:\n        return jsonify({'status': 'probe_failed', 'error': 'Health probe request failed'}), 500"}