{"title":"SSRF via requests.get() on Untrusted URL","language":"Python","severity":"High","cwe":"CWE-918","source_lines":[8],"flow_lines":[8,14],"sink_lines":[14],"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_url = request.json.get('callback_endpoint')\n    device_id = request.json.get('device_id', 'unknown')\n    \n    if not device_callback_url:\n        return jsonify({'error': 'callback_endpoint required'}), 400\n    \n    try:\n        health_response = requests.get(device_callback_url, timeout=5)\n        device_status = health_response.json()\n        \n        return jsonify({\n            'device_id': device_id,\n            'status': 'reachable',\n            'health_data': device_status,\n            'http_code': health_response.status_code\n        }), 200\n    except Exception as e:\n        return jsonify({\n            'device_id': device_id,\n            'status': 'unreachable',\n            'error': str(e)\n        }), 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 services, cloud metadata endpoints, or other restricted resources.","remediation":"The fix adds a URL validation function that checks the scheme, port, and resolves the hostname to ensure it does not point to private, loopback, link-local, or reserved IP addresses (blocking access to cloud metadata endpoints like 169.254.169.254). Additionally, redirects are disabled to prevent redirect-based SSRF bypasses, and error messages are sanitized to avoid leaking internal network 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 schemes and optional domain allowlist\nALLOWED_SCHEMES = {'http', 'https'}\nALLOWED_PORTS = {80, 443, 8080, 8443}\n# Optional: restrict to known device network ranges or domains\n# ALLOWED_DOMAINS = {'devices.example.com', 'iot.example.com'}\n\ndef is_private_ip(hostname):\n    \"\"\"Check if a hostname resolves to a private/reserved IP address.\"\"\"\n    try:\n        resolved_ips = socket.getaddrinfo(hostname, None)\n        for entry in resolved_ips:\n            ip = ipaddress.ip_address(entry[4][0])\n            if (ip.is_private or ip.is_loopback or ip.is_reserved or\n                ip.is_multicast or ip.is_link_local or\n                ip.is_unspecified):\n                return True\n    except (socket.gaierror, ValueError):\n        return True  # If we can't resolve, deny by default\n    return False\n\n\ndef validate_callback_url(url):\n    \"\"\"Validate that the callback URL is safe to request.\"\"\"\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. Use http or https.\"\n\n    # Ensure hostname is present\n    hostname = parsed.hostname\n    if not hostname:\n        return False, \"No hostname provided in URL\"\n\n    # Check port if specified\n    port = parsed.port\n    if port and port not in ALLOWED_PORTS:\n        return False, f\"Port {port} not allowed\"\n\n    # Block IP-based URLs that resolve to private/internal addresses\n    if is_private_ip(hostname):\n        return False, \"URL resolves to a restricted internal address\"\n\n    # Optional: enforce domain allowlist\n    # if hostname not in ALLOWED_DOMAINS:\n    #     return False, f\"Domain '{hostname}' not in allowed device domains\"\n\n    return True, None\n\n\n@app.route('/api/iot/device/health', methods=['POST'])\ndef check_device_health():\n    device_callback_url = request.json.get('callback_endpoint')\n    device_id = request.json.get('device_id', 'unknown')\n\n    if not device_callback_url:\n        return jsonify({'error': 'callback_endpoint required'}), 400\n\n    # Validate the callback URL before making the request\n    is_valid, validation_error = validate_callback_url(device_callback_url)\n    if not is_valid:\n        return jsonify({\n            'device_id': device_id,\n            'status': 'rejected',\n            'error': f'Invalid callback URL: {validation_error}'\n        }), 400\n\n    try:\n        health_response = requests.get(\n            device_callback_url,\n            timeout=5,\n            allow_redirects=False  # Prevent redirect-based SSRF bypass\n        )\n        device_status = health_response.json()\n\n        return jsonify({\n            'device_id': device_id,\n            'status': 'reachable',\n            'health_data': device_status,\n            'http_code': health_response.status_code\n        }), 200\n    except Exception as e:\n        return jsonify({\n            'device_id': device_id,\n            'status': 'unreachable',\n            'error': 'Device health check failed'\n        }), 500"}