{"title":"SSRF via requests.get() to User-Controlled URL","language":"Python","severity":"High","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_status():\n    device_endpoint = request.json.get('status_url')\n    auth_token = request.json.get('token', '')\n    headers = {'Authorization': f'Bearer {auth_token}'}\n    try:\n        response = requests.get(device_endpoint, headers=headers, timeout=5)\n        return jsonify({'status': 'online', 'data': response.text, 'code': response.status_code})\n    except requests.RequestException as e:\n        return jsonify({'status': 'offline', 'error': str(e)}), 500","explanation":"The application accepts a user-controlled URL via 'status_url' parameter and directly passes it to requests.get() without validation. This allows attackers to make the server send HTTP requests to arbitrary internal or external endpoints, enabling SSRF attacks to access internal services, cloud metadata endpoints, or perform port scanning.","remediation":"The fix validates the user-supplied URL by enforcing HTTPS-only scheme, restricting allowed ports, resolving the hostname to its IP address, and checking the IP against blocked private/internal ranges (including cloud metadata at 169.254.x.x) while allowing explicitly configured IoT device subnets. Additionally, redirects are disabled to prevent redirect-based SSRF bypasses, response size is limited, and error messages are sanitized to avoid leaking internal information.","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 device network ranges (configure as needed)\nALLOWED_SCHEMES = {'https'}\nALLOWED_PORTS = {443, 8443, 8080}\n# Define allowed external subnets for IoT devices (example: specific subnets)\nALLOWED_DEVICE_SUBNETS = [\n    ipaddress.ip_network('192.168.100.0/24'),  # Example IoT device subnet\n    ipaddress.ip_network('10.0.50.0/24'),       # Example IoT device subnet\n]\n\nBLOCKED_RANGES = [\n    ipaddress.ip_network('127.0.0.0/8'),\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('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\n\ndef is_ip_allowed(ip_str):\n    \"\"\"Check if the resolved IP is in allowed device subnets and not in blocked ranges.\"\"\"\n    try:\n        ip = ipaddress.ip_address(ip_str)\n    except ValueError:\n        return False\n\n    # Check against blocked/private ranges\n    for blocked in BLOCKED_RANGES:\n        if ip in blocked:\n            # Even if blocked, check if it's in an explicitly allowed device subnet\n            for allowed in ALLOWED_DEVICE_SUBNETS:\n                if ip in allowed:\n                    return True\n            return False\n\n    return True\n\n\ndef validate_device_url(url):\n    \"\"\"Validate that the URL is safe to request.\"\"\"\n    if not url or not isinstance(url, str):\n        return False, \"Invalid URL provided\"\n\n    try:\n        parsed = urlparse(url)\n    except Exception:\n        return False, \"Malformed URL\"\n\n    # Validate scheme\n    if parsed.scheme not in ALLOWED_SCHEMES:\n        return False, f\"Scheme '{parsed.scheme}' not allowed. Use HTTPS.\"\n\n    # Ensure hostname exists\n    hostname = parsed.hostname\n    if not hostname:\n        return False, \"No hostname in URL\"\n\n    # Validate 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} not allowed\"\n\n    # Resolve hostname and validate IP\n    try:\n        resolved_ips = socket.getaddrinfo(hostname, port, proto=socket.IPPROTO_TCP)\n    except socket.gaierror:\n        return False, \"Could not resolve hostname\"\n\n    if not resolved_ips:\n        return False, \"No addresses resolved for hostname\"\n\n    for addr_info in resolved_ips:\n        ip_str = addr_info[4][0]\n        if not is_ip_allowed(ip_str):\n            return False, f\"Target IP address is not allowed\"\n\n    return True, \"Valid\"\n\n\n@app.route('/api/iot/device-health', methods=['POST'])\ndef check_device_status():\n    if not request.json:\n        return jsonify({'status': 'error', 'error': 'Request body must be JSON'}), 400\n\n    device_endpoint = request.json.get('status_url')\n    auth_token = request.json.get('token', '')\n\n    # Validate the device URL before making the request\n    is_valid, validation_message = validate_device_url(device_endpoint)\n    if not is_valid:\n        return jsonify({'status': 'error', 'error': f'Invalid device URL: {validation_message}'}), 400\n\n    headers = {'Authorization': f'Bearer {auth_token}'}\n    try:\n        response = requests.get(\n            device_endpoint,\n            headers=headers,\n            timeout=5,\n            allow_redirects=False  # Prevent redirect-based SSRF bypass\n        )\n        # Limit response size to prevent memory issues\n        response_data = response.text[:10000]\n        return jsonify({'status': 'online', 'data': response_data, 'code': response.status_code})\n    except requests.RequestException as e:\n        return jsonify({'status': 'offline', 'error': 'Device unreachable'}), 500"}