PAYATHON 2026

Django callback 报 Forbidden Referer checking failed

支付小周

结论

第三方支付平台的服务器端回调通常不会发送 Referer,因此支付 webhook 不应依赖 Django 的 CSRF 或 Referer 校验。建议这样处理:

  1. 只为实际接收回调的 view 添加 @csrf_exempt。
  2. 确认 /callback/ 的 URL 指向的正是这个已豁免的 view。
  3. 使用支付平台提供的签名机制验证请求。关闭 CSRF 后,不能直接信任请求内容。
  4. 如果添加 @csrf_exempt 后仍然出现同样的错误,通常是因为装饰器没有作用于最终执行的 view,或者其他中间件、DRF 认证类仍在执行 CSRF 校验。

Django 的标准配置名是 CSRF_TRUSTED_ORIGINS,不是 CSRF_ALLOWED_ORIGIN。即使正确配置了可信来源,也无法解决请求完全没有 Referer 的问题。

为什么会出现这个错误

Django 的 CSRF 防护主要用于浏览器中的 Cookie 会话。对于 HTTPS 请求,CsrfViewMiddleware 可能检查 Origin 或 Referer,防止恶意网站利用用户现有的登录状态发起跨站请求。

支付平台回调的请求方式不同。它通常由支付平台的服务器直接向你的服务器发送 POST 请求,不经过浏览器,所以很可能不包含以下请求头或 token:

Referer
Origin
CSRFToken

这是正常现象。CORS 配置也无法解决这个问题,因为 CORS 是由浏览器执行的跨域限制,对服务器之间的 webhook 请求没有实际作用。

正确配置函数视图

装饰器必须直接作用于 urls.py 最终引用的函数:

# views.py
import json

from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt


@csrf_exempt
def callback(request):
    if request.method != "POST":
        return HttpResponse(status=405)

    try:
        payload = json.loads(request.body)
    except json.JSONDecodeError:
        return JsonResponse({"error": "invalid JSON"}, status=400)

    # 先验证支付平台签名,再处理并保存交易
    # verify_signature(request.headers, request.body)

    return JsonResponse({"received": True})

URL 必须引用同一个函数:

# urls.py
from django.urls import path

from .views import callback

urlpatterns = [
    path("callback/", callback, name="payment-callback"),
]

常见问题是装饰了一个函数,但 URL 实际指向另一个包装函数或同名 view:

# callback 被豁免,但 URL 实际指向 payment_callback
path("callback/", payment_callback)

此时,@csrf_exempt 不会生效。

类视图的处理方式

如果使用 class-based view,应对 dispatch 进行豁免:

from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt


@method_decorator(csrf_exempt, name="dispatch")
class PaymentCallbackView(View):
    def post(self, request, *args, **kwargs):
        return JsonResponse({"received": True})

对应的 URL 配置如下:

from django.urls import path

from .views import PaymentCallbackView

urlpatterns = [
    path(
        "callback/",
        PaymentCallbackView.as_view(),
        name="payment-callback",
    ),
]

在部分组合方式下,只装饰 post() 方法可能无法避开中间件检查,因此装饰 dispatch 更稳妥。

使用 Django REST Framework 时

如果 endpoint 使用 Django REST Framework,还需要检查认证配置。SessionAuthentication 会执行 CSRF 校验,仅在方法上添加 @csrf_exempt 不一定能得到预期结果。

支付 webhook 一般不应使用浏览器 Session 认证,可以为该接口单独设置认证方式:

from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView


class PaymentCallbackView(APIView):
    authentication_classes = []
    permission_classes = [AllowAny]

    def post(self, request):
        # 必须在这里验证支付平台签名
        return Response({"received": True})

这不代表接口可以不做认证。AllowAny 只是取消 DRF 对用户登录状态的要求,真正的可信校验仍应由支付平台的 webhook 签名、共享密钥、证书或其他官方验证机制完成。

仍然报错时如何排查

1. 确认错误由哪个组件产生

Django 原生的 CSRF 错误通常由 CsrfViewMiddleware 返回。检查 MIDDLEWARE 中是否存在重复项,以及项目是否添加了自定义的 CSRF 或 Referer 检查中间件:

MIDDLEWARE = [
    # ...
    "django.middleware.csrf.CsrfViewMiddleware",
    # ...
]

不要因为一个 callback 就删除全局的 CsrfViewMiddleware。其他浏览器表单和后台页面仍然需要 CSRF 防护,只应豁免明确的 webhook endpoint。

2. 检查最终解析到的 view

可以在 Django shell 中查看 URL 的解析结果:

from django.urls import resolve

match = resolve("/callback/")
print(match.func)
print(getattr(match.func, "csrf_exempt", False))

对于正确豁免的函数视图,第二行通常会输出:

True

如果输出 False,说明 URL 最终指向的 callable 没有被正确豁免。

3. 检查路径和尾部斜杠

确认支付平台请求的地址是:

/callback/

而不是:

/callback

如果请求发生重定向,第三方平台可能无法正确处理,POST 请求体也可能受到影响。支付平台后台填写的地址最好与 Django URL 完全一致,不要依赖 APPEND_SLASH 自动纠正。

4. 直接模拟无 Referer 请求

可以发送一个不包含 Referer 和 CSRF token 的请求:

curl -i \
  -X POST \
  -H 'Content-Type: application/json' \
  --data '{"transaction_id":"test-001"}' \
  https://example.com/callback/

如果 @csrf_exempt 已正确作用于最终 view,这个请求不应再因 CSRF Referer 检查返回 403。不过,它仍可能被签名验证、代理规则或其他业务校验拒绝。

5. 检查反向代理和外围安全层

如果已经确认 Django 中的 csrf_exempt=True,但错误依然存在,还需要检查:

  • Nginx、Apache 或网关是否限制了请求头。
  • 是否存在多个 Django 服务实例,其中部分实例尚未更新。
  • 请求是否实际到达了另一个应用或旧版本部署。
  • 是否安装了额外的安全中间件。
  • 请求是否经过内部转发,进入了另一个仍启用 CSRF 校验的 view。

CSRF_TRUSTED_ORIGINS 何时使用

Django 的正确配置示例如下:

CSRF_TRUSTED_ORIGINS = [
    "https://payments.example.com",
]

这项配置适合允许受信任的浏览器来源提交需要 CSRF 校验的请求。它不是支付 webhook 的身份验证机制,也无法保证第三方请求一定会包含 Origin 或 Referer。

不要为了消除错误而加入范围过大的通配域名,更不要把所有未知来源都设为可信。

支付回调必须补上的安全措施

关闭 CSRF 后,理论上任何人都可以向 /callback/ 发送请求,因此必须验证支付平台提供的签名。例如:

import hashlib
import hmac

from django.conf import settings


def verify_signature(raw_body: bytes, supplied_signature: str) -> bool:
    expected_signature = hmac.new(
        settings.PAYMENT_WEBHOOK_SECRET.encode(),
        raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected_signature, supplied_signature)

实际使用的签名算法、请求头名称、时间戳处理方式和编码方式,必须以支付平台的官方文档为准,不能自行假设。验证签名时通常应使用原始 request.body,避免 JSON 重新序列化后内容发生变化,造成签名不一致。

还需要处理以下事项:

  • 使用交易号或事件 ID 建立唯一约束,保证幂等性。
  • 在数据库事务中更新支付状态。
  • 校验金额、币种、订单号和商户号。
  • 防止旧请求被重放,必要时验证时间戳。
  • 不要记录完整卡号、密钥、签名原文等敏感信息。
  • 成功保存后,返回支付平台要求的 2xx 响应。
  • 不要仅凭 callback 中的 status=paid 就认定付款成功。必要时调用支付平台的查询接口进行二次确认。

问题的解决办法不是把第三方域名加入 CSRF 白名单,而是让实际接收请求的 callback view 跳过浏览器 CSRF 检查,再用支付平台提供的签名机制验证请求。

备注:内容仅供参考。