{"title":"SSRF via Unvalidated requests.get URL Fetch","language":"Python","severity":"Critical","cwe":"CWE-918","source_lines":[9],"flow_lines":[9,11],"sink_lines":[11],"vulnerable_code":"import requests\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\n@app.route('/api/iot/sensor/proxy', methods=['POST'])\ndef fetch_sensor_telemetry():\n    device_endpoint = request.json.get('sensor_url')\n    auth_token = request.json.get('token', 'default-key')\n    headers = {'Authorization': f'Bearer {auth_token}'}\n    sensor_data = requests.get(device_endpoint, headers=headers, timeout=5)\n    return jsonify({'status': 'success', 'telemetry': sensor_data.json(), 'source': device_endpoint})","explanation":"The application accepts a user-controlled 'sensor_url' parameter and directly passes it to requests.get() without any validation or allowlisting. An attacker can provide internal network URLs (e.g., http://localhost:6379, http://169.254.169.254/latest/meta-data/) to scan internal services, access cloud metadata endpoints, or exfiltrate data from restricted network segments.","remediation":"The fix implements multiple layers of SSRF protection: a URL validation function that enforces an allowlist of permitted sensor hostnames, restricts the scheme to HTTPS only, resolves the hostname to check that the IP doesn't fall within private/internal/link-local ranges, and blocks URLs with embedded credentials. Additionally, the response no longer returns the source URL (preventing internal network topology disclosure), redirects are disabled to prevent redirect-based SSRF bypasses, and proper error handling is added.","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 sensor network domains/IPs\nALLOWED_SENSOR_HOSTS = [\n    'sensors.example.com',\n    'iot-gateway.example.com',\n]\n\n# Allowed schemes\nALLOWED_SCHEMES = ['https']\n\n# Blocked IP ranges (private, loopback, link-local, metadata endpoints)\nBLOCKED_IP_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_blocked(ip_str):\n    \"\"\"Check if a resolved IP address falls within blocked ranges.\"\"\"\n    try:\n        ip = ipaddress.ip_address(ip_str)\n        for blocked_range in BLOCKED_IP_RANGES:\n            if ip in blocked_range:\n                return True\n        return False\n    except ValueError:\n        return True\n\n\ndef validate_sensor_url(url):\n    \"\"\"Validate that the sensor URL is safe to fetch.\"\"\"\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    # 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    # Extract hostname\n    hostname = parsed.hostname\n    if not hostname:\n        return False, \"No hostname found in URL\"\n\n    # Check against allowlist\n    if hostname not in ALLOWED_SENSOR_HOSTS:\n        return False, f\"Host '{hostname}' is not in the allowed sensor hosts list\"\n\n    # Resolve hostname and check IP against blocked ranges\n    try:\n        resolved_ips = socket.getaddrinfo(hostname, parsed.port or 443)\n        for result in resolved_ips:\n            ip_str = result[4][0]\n            if is_ip_blocked(ip_str):\n                return False, f\"Resolved IP address is in a blocked range\"\n    except socket.gaierror:\n        return False, \"Unable to resolve hostname\"\n\n    # Block URLs with credentials\n    if parsed.username or parsed.password:\n        return False, \"URLs with embedded credentials are not allowed\"\n\n    return True, None\n\n\n@app.route('/api/iot/sensor/proxy', methods=['POST'])\ndef fetch_sensor_telemetry():\n    if not request.json:\n        return jsonify({'status': 'error', 'message': 'Request body must be JSON'}), 400\n\n    device_endpoint = request.json.get('sensor_url')\n    auth_token = request.json.get('token', 'default-key')\n\n    # Validate the target URL before making the request\n    is_valid, error_message = validate_sensor_url(device_endpoint)\n    if not is_valid:\n        return jsonify({'status': 'error', 'message': error_message}), 400\n\n    headers = {'Authorization': f'Bearer {auth_token}'}\n\n    try:\n        sensor_data = requests.get(device_endpoint, headers=headers, timeout=5, allow_redirects=False)\n        sensor_data.raise_for_status()\n        return jsonify({'status': 'success', 'telemetry': sensor_data.json()})\n    except requests.exceptions.RequestException as e:\n        return jsonify({'status': 'error', 'message': 'Failed to fetch sensor data'}), 502"}