所有文章
DjangoPythonwebhook debugginglocal testing

Django Webhook:越过 CSRF 进入处理器

开箱即用时,Django 会用 403 拒绝每一个 webhook POST。罪魁祸首是 CSRF 中间件在尽职——防御跨站请求伪造——只不过服务商不是浏览器,不带会话 cookie 或 CSRF 令牌。修复办法是一个装饰器,外加对请求体访问的一行认知。

为 webhook 视图跳过 CSRF

Django 的做法:

from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse, HttpResponseBadRequest
import hmac, hashlib

@csrf_exempt
def stripe_webhook(request):
    if request.method != 'POST':
        return HttpResponseBadRequest('POST only')

    payload = request.body            # 原始字节,未被改动
    signature = request.headers.get('Stripe-Signature', '')

    # 验证签名(真实代码请用 stripe SDK)
    if not verify(payload, signature, settings.STRIPE_WEBHOOK_SECRET):
        return HttpResponse(status=401)

    event = json.loads(payload)
    handle_stripe_event(event)
    return HttpResponse(status=200)

@csrf_exempt 装饰器只为这个视图移除中间件检查——你其他的表单仍受保护。request.body 返回原样到达的原始字节,这正是签名验证所需要的。

如果你在用 Django REST Framework

DRF 把请求包进自己的 Request 对象,并根据 content-type 运行解析器。等你的视图代码读取 request.data 时,JSON 解析器已经消费了请求体。两条出路:

方案 A:保留为普通 Django 视图

webhook 处理器不需要 DRF 的解析器、可浏览 API 或认证机制。在你的 DRF 端点旁放一个普通的 @csrf_exempt 视图是最干净的模式。

方案 B:用 DRF 但做原始访问

如果你真的想用 DRF,请读取 request.body(底层的 Django 请求)而不是 request.data

from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import AllowAny

@api_view(['POST'])
@authentication_classes([])         # webhook 不做认证
@permission_classes([AllowAny])     # 服务商已通过签名认证
def stripe_webhook(request):
    payload = request._request.body  # 底层 Django 请求,原始字节
    signature = request.META.get('HTTP_STRIPE_SIGNATURE', '')
    # ...验证并处理

request._request 这种间接访问很别扭,但当你需要绕过 DRF 的解析时,这是官方的逃生口。

搭建隧道

  1. 运行你的 Django 开发服务器:python manage.py runserver 8000
  2. 把隧道的主机名加入 settings.py 里的 ALLOWED_HOSTS。本地开发最简单是 ['*'],或者你愿意的话用具体的隧道域名。
  3. 在另一个终端 npx portpreview 8000
  4. 把隧道网址加上你的 webhook 路径粘贴到服务商控制台。
  5. 触发一个测试事件,看它落进你的视图。

忘了 ALLOWED_HOSTS 会产生一个没有明显原因的通用 Django 400。如果你第一次 webhook 投递返回 400 且看不到视图日志,原因就在这。

常见陷阱

把请求体读两次

和每个框架一样,Django 把请求体作为只能读一次的流给你。如果中间件或日志包装器已经碰过 request.body,你的视图代码会看到一个空字节对象。打印 len(request.body) 做个健全性检查。

时区与时间戳容差

如果你把签名时间戳和 datetime.now() 比较,确保使用 timezone.now() 或在 UTC 下比较。naive datetime 比较会在夏令时切换时间歇性失败。

异步视图

Django 4+ 支持 async def 视图。webhook 处理器可以是异步的,但要注意签名验证 SDK(Stripe Python 等)是同步的。用 asgiref.sync_to_async 运行它们,或把整个视图保持同步。

生产部署注意

过了本地测试,同一个视图在生产里也能用。Gunicorn、uWSGI、Daphne——它们都不会在你的视图看到之前碰 request.body。你的反向代理可能会(例如开启了 proxy_buffering 的 nginx),但那是缓冲问题,不是请求体修改。

关于底层签名理论,见签名验证指南。Stripe 专属模式的控制台一侧由Stripe 本地测试指南覆盖。加入 PortPreview 等候名单,获得与 Python 开发服务器配合良好的隧道。

常见问题

为什么 Django 对每个 webhook POST 都返回 403?
Django 的 CSRF 中间件会拒绝没有有效 CSRF 令牌的 POST。webhook 服务商不会发送令牌。给你的 webhook 视图(仅该视图)加上 @csrf_exempt 以绕过该检查,然后改为验证签名。
webhook 视图该用普通 Django 还是 DRF?
对 webhook 来说普通 Django 更简单。DRF 的解析器会在你的视图看到之前消费请求体,从而破坏签名验证。如果保留 DRF,请读取 request._request.body 以获取原始字节。
Django 中隧道需要什么样的 ALLOWED_HOSTS 设置?
把你的隧道域名加入 settings.py 的 ALLOWED_HOSTS。本地开发期间 ['*'] 可用;更严格的配置请列出具体的隧道主机名。没有这一项,Django 会返回一个没有清晰日志信息的 400。