{"title":"CSRF via State-Changing GET Endpoint in Flask","language":"Python","severity":"Critical","cwe":"CWE-352","source_lines":[10,11],"flow_lines":[10,12,11,12,14],"sink_lines":[12,14],"vulnerable_code":"from flask import Flask, request, jsonify\nimport boto3\n\napp = Flask(__name__)\ns3_client = boto3.client('s3')\n\n@app.route('/cloud/bucket/purge', methods=['GET'])\ndef purge_s3_bucket():\n    bucket_name = request.args.get('bucket', 'user-uploads-prod')\n    prefix = request.args.get('prefix', '')\n    objects = s3_client.list_objects_v2(Bucket=bucket_name, Prefix=prefix)\n    if 'Contents' in objects:\n        delete_keys = [{'Key': obj['Key']} for obj in objects['Contents']]\n        s3_client.delete_objects(Bucket=bucket_name, Delete={'Objects': delete_keys})\n    return jsonify({'status': 'success', 'deleted': len(delete_keys) if 'Contents' in objects else 0})","explanation":"This endpoint performs destructive S3 operations (listing and deleting objects) via GET request without CSRF protection. An attacker can craft a malicious link or embed it in an image tag that, when visited by an authenticated administrator, will delete S3 bucket contents. The use of GET for state-changing operations violates HTTP semantics and enables CSRF attacks through simple GET requests.","remediation":"The fix changes the endpoint from GET to POST to follow HTTP semantics for state-changing operations, adds CSRF token validation via a decorator that checks a session-stored token against the request header or form field, and adds input validation including an allowlist for bucket names and requiring a non-empty prefix to prevent accidental full bucket deletion.","secure_code":"from flask import Flask, request, jsonify, session, abort\nimport boto3\nimport secrets\nimport functools\n\napp = Flask(__name__)\napp.secret_key = secrets.token_hex(32)\ns3_client = boto3.client('s3')\n\nALLOWED_BUCKETS = ['user-uploads-prod', 'user-uploads-staging']\n\ndef require_csrf_token(f):\n    @functools.wraps(f)\n    def decorated_function(*args, **kwargs):\n        token = request.headers.get('X-CSRF-Token') or request.form.get('csrf_token')\n        if not token or token != session.get('csrf_token'):\n            abort(403, description='CSRF token missing or invalid')\n        return f(*args, **kwargs)\n    return decorated_function\n\n@app.route('/cloud/bucket/csrf-token', methods=['GET'])\ndef get_csrf_token():\n    if 'csrf_token' not in session:\n        session['csrf_token'] = secrets.token_hex(32)\n    return jsonify({'csrf_token': session['csrf_token']})\n\n@app.route('/cloud/bucket/purge', methods=['POST'])\n@require_csrf_token\ndef purge_s3_bucket():\n    data = request.get_json() or request.form\n    bucket_name = data.get('bucket', 'user-uploads-prod')\n    prefix = data.get('prefix', '')\n\n    if bucket_name not in ALLOWED_BUCKETS:\n        return jsonify({'status': 'error', 'message': 'Bucket not allowed'}), 400\n\n    if not prefix:\n        return jsonify({'status': 'error', 'message': 'Prefix is required to prevent full bucket purge'}), 400\n\n    deleted_count = 0\n    objects = s3_client.list_objects_v2(Bucket=bucket_name, Prefix=prefix)\n    if 'Contents' in objects:\n        delete_keys = [{'Key': obj['Key']} for obj in objects['Contents']]\n        s3_client.delete_objects(Bucket=bucket_name, Delete={'Objects': delete_keys})\n        deleted_count = len(delete_keys)\n\n    return jsonify({'status': 'success', 'deleted': deleted_count})"}