{"title":"SSRF via Untrusted requests.get() URL Fetching","language":"Python","severity":"Critical","cwe":"CWE-918","source_lines":[8],"flow_lines":[8,12],"sink_lines":[12],"vulnerable_code":"import requests\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\n@app.route('/api/iot/device/health', methods=['POST'])\ndef check_device_health():\n    device_callback = request.json.get('callback_url')\n    device_id = request.json.get('device_id', 'unknown')\n    timeout_val = request.json.get('timeout', 5)\n    headers = {'X-Device-ID': device_id, 'User-Agent': 'IoT-Health-Monitor/2.1'}\n    try:\n        health_response = requests.get(device_callback, headers=headers, timeout=timeout_val)\n        return jsonify({'status': 'success', 'device_id': device_id, 'health_data': health_response.text, 'status_code': health_response.status_code})\n    except requests.exceptions.RequestException as e:\n        return jsonify({'status': 'error', 'device_id': device_id, 'message': str(e)}), 500","explanation":"The application accepts a user-controlled 'callback_url' parameter 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 resources, cloud metadata services, or arbitrary external URLs, potentially exposing sensitive data or bypassing network security controls.","remediation":"The fix validates the user-provided callback URL before making the request by checking the scheme (HTTPS only), port (allowlisted ports only), and resolving the hostname to ensure it does not point to private, loopback, link-local, or reserved IP addresses. Additionally, redirects are disabled to prevent redirect-based SSRF bypasses, the timeout is sanitized, and response data is truncated to limit data exfiltration.","secure_code":"import requests\nfrom flask import Flask, request, jsonify\nfrom urllib.parse import urlparse\nimport ipaddress\nimport socket\n\napp = Flask(__name__)\n\n# Allowlist of permitted schemes and port ranges for IoT devices\nALLOWED_SCHEMES = {'https'}\nALLOWED_PORTS = {443, 8443, 8080}\n# Optional: allowlist of known device subnets (external only)\nALLOWED_DEVICE_SUBNETS = [\n    ipaddress.ip_network('192.0.2.0/24'),  # Example: replace with actual device subnet\n]\n\ndef is_private_or_reserved(ip_str):\n    \"\"\"Check if an IP address is private, loopback, link-local, or reserved.\"\"\"\n    try:\n        ip = ipaddress.ip_address(ip_str)\n        return (\n            ip.is_private or\n            ip.is_loopback or\n            ip.is_link_local or\n            ip.is_multicast or\n            ip.is_reserved or\n            ip.is_unspecified\n        )\n    except ValueError:\n        return True\n\ndef validate_callback_url(url):\n    \"\"\"Validate that the callback URL is safe to fetch.\"\"\"\n    if not url or not isinstance(url, str):\n        return False, \"Invalid or missing callback URL\"\n\n    try:\n        parsed = urlparse(url)\n    except Exception:\n        return False, \"Malformed URL\"\n\n    # Check scheme\n    if parsed.scheme not in ALLOWED_SCHEMES:\n        return False, f\"Scheme '{parsed.scheme}' not allowed. Only HTTPS is permitted.\"\n\n    # Extract hostname\n    hostname = parsed.hostname\n    if not hostname:\n        return False, \"No hostname found in URL\"\n\n    # Check port\n    port = parsed.port or (443 if parsed.scheme == 'https' else 80)\n    if port not in ALLOWED_PORTS:\n        return False, f\"Port {port} is not in the allowed port list\"\n\n    # Resolve hostname to IP and check for internal addresses\n    try:\n        resolved_ips = socket.getaddrinfo(hostname, port, proto=socket.IPPROTO_TCP)\n    except socket.gaierror:\n        return False, \"Unable to resolve hostname\"\n\n    for family, socktype, proto, canonname, sockaddr in resolved_ips:\n        ip_str = sockaddr[0]\n        if is_private_or_reserved(ip_str):\n            return False, \"URL resolves to a private or reserved IP address\"\n\n    return True, \"URL is valid\"\n\n@app.route('/api/iot/device/health', methods=['POST'])\ndef check_device_health():\n    if not request.json:\n        return jsonify({'status': 'error', 'message': 'Request body must be JSON'}), 400\n\n    device_callback = request.json.get('callback_url')\n    device_id = request.json.get('device_id', 'unknown')\n    timeout_val = request.json.get('timeout', 5)\n\n    # Sanitize timeout to prevent abuse\n    if not isinstance(timeout_val, (int, float)) or timeout_val < 1 or timeout_val > 10:\n        timeout_val = 5\n\n    # Validate the callback URL before making the request\n    is_valid, validation_message = validate_callback_url(device_callback)\n    if not is_valid:\n        return jsonify({'status': 'error', 'device_id': device_id, 'message': f'Invalid callback URL: {validation_message}'}), 400\n\n    headers = {'X-Device-ID': device_id, 'User-Agent': 'IoT-Health-Monitor/2.1'}\n\n    try:\n        health_response = requests.get(\n            device_callback,\n            headers=headers,\n            timeout=timeout_val,\n            allow_redirects=False  # Prevent redirect-based SSRF bypass\n        )\n        return jsonify({\n            'status': 'success',\n            'device_id': device_id,\n            'health_data': health_response.text[:4096],  # Limit response size\n            'status_code': health_response.status_code\n        })\n    except requests.exceptions.RequestException as e:\n        return jsonify({'status': 'error', 'device_id': device_id, 'message': 'Failed to reach device'}), 500"}