PAYATHON 2026

Django payment gateway return url 导致 CSRF 403

支付老李

结论

URL 路径不会改变 Django 的 CSRF 校验规则。如果两个 URL 确实指向同一个 view,并且访问时使用相同的 HTTP 方法、Cookie 和请求参数,CSRF 校验结果应该相同。

将 /purchased/ 改成 /mypayments/purchased/ 后出现 403,说明请求的实际条件已经发生变化。常见原因有:

  • 支付网关向新地址发送了 POST,但请求中没有有效的 CSRF token。
  • CSRF Cookie 的 Path 限制使新路径无法收到 Cookie。
  • 新路径被排在前面的 URL 规则匹配到了另一个 view。
  • 支付平台仍在使用旧配置,或者对不同回调地址使用了不同的请求方式。
  • 路径末尾的 / 不一致,引发重定向,进而改变了 POST 数据或 Cookie 的处理方式。

为什么支付回调容易触发 CSRF 403

Django 的 CSRF 保护主要检查 POST、PUT、PATCH 和 DELETE 等可能修改数据的请求。普通表单 POST 通常需要满足以下条件:

  1. 浏览器发送了 CSRF Cookie。
  2. 表单中包含 csrfmiddlewaretoken 字段,或者请求头中包含 X-CSRFToken。
  3. 两个 token 能通过 Django 的校验。
  4. 如果启用了相应校验,Origin 或 Referer 也要符合要求。

支付网关属于第三方系统,通常不会携带 Django 生成的 CSRF token。因此,网关直接向受 CSRF 保护的 view 发送 POST 时,被 Django 拒绝是正常的。

@csrf_protect 会明确要求该 view 执行 CSRF 校验:

from django.views.decorators.csrf import csrf_protect

@csrf_protect
def purchased(request):
    ...

如果项目已经启用 CsrfViewMiddleware,通常不需要再添加这个装饰器,因为中间件已经会执行同类检查。

先确认实际失败原因

不要只对比两个 URL 字符串,还要检查两个地址实际收到的请求。至少需要确认:

  • request.method 是否相同。
  • 新地址是否确实进入了 purchased。
  • 请求中是否有 csrftoken Cookie。
  • POST 数据中是否有 csrfmiddlewaretoken。
  • 请求是否经过了 301、302、307 或 308 重定向。
  • Django 日志中记录了什么 CSRF 拒绝原因。

可以暂时在 view 中记录必要信息,但不要记录支付密码、完整卡号、签名密钥等敏感数据:

import logging

logger = logging.getLogger(__name__)

def purchased(request):
    logger.info(
        "payment return path=%s method=%s has_csrf_cookie=%s",
        request.path,
        request.method,
        "csrftoken" in request.COOKIES,
    )
    ...

还要检查 URL 的匹配结果:

from django.urls import resolve

match = resolve("/mypayments/purchased/")
print(match.func)
print(match.url_name)

如果 URL 配置中有通配规则,排列顺序也会影响匹配结果。例如,下面的动态规则会先截获请求:

urlpatterns = [
    path("mypayments/<str:name>/", another_view),
    path("mypayments/purchased/", purchased, name="purchased"),
]

应把更具体的规则放在前面:

urlpatterns = [
    path("mypayments/purchased/", purchased, name="purchased"),
    path("mypayments/<str:name>/", another_view),
]

Django 的 CSRF Cookie 路径默认通常为 /,所以站点内的所有路径都能收到该 Cookie。如果项目修改了 CSRF_COOKIE_PATH,不同 URL 可能会收到不同的 Cookie。

例如,以下配置只允许浏览器向 /purchased/ 及其子路径发送 Cookie:

CSRF_COOKIE_PATH = "/purchased/"

这样一来,/mypayments/purchased/ 就收不到该 Cookie。一般应配置为:

CSRF_COOKIE_PATH = "/"

如果 CSRF token 存储在 session 中,还要检查:

SESSION_COOKIE_PATH = "/"

修改配置后,需要清除旧 Cookie 或重新登录,因为浏览器中可能仍保留着使用旧 Path 的 Cookie。

即使 Cookie 路径正确,第三方支付网关发送的 POST 通常也不会包含表单 CSRF token。因此,只调整 Cookie 配置未必能解决回调问题。

推荐的支付回调设计

建议把“用户返回页面”和“支付服务器通知”拆成两个端点。

用户返回页面

用户完成支付后,由浏览器通过 GET 返回。这个页面只负责展示结果,不能仅根据查询参数修改订单状态:

from django.shortcuts import render

def payment_return(request):
    order_id = request.GET.get("order_id")
    # 从数据库读取已经过服务端确认的支付状态
    return render(request, "payments/result.html", {"order_id": order_id})

URL 配置:

from django.urls import path
from . import views

urlpatterns = [
    path(
        "mypayments/return/",
        views.payment_return,
        name="payment_return",
    ),
]

支付服务器回调

支付网关主动发送的 webhook 通常无法提供 Django CSRF token,因此可以为这个专用端点添加 @csrf_exempt。同时,必须验证支付平台提供的签名、时间戳、订单号、金额和商户身份。

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

@csrf_exempt
@require_POST
def payment_webhook(request):
    payload = request.body
    signature = request.headers.get("X-Payment-Signature")

    if not verify_gateway_signature(payload, signature):
        return HttpResponseBadRequest("invalid signature")

    event = parse_gateway_event(payload)

    # 还应核对订单号、金额、币种和商户号,并保证重复通知不会重复入账
    process_payment_event(event)

    return HttpResponse("ok")

URL 配置:

urlpatterns = [
    path(
        "mypayments/webhook/",
        views.payment_webhook,
        name="payment_webhook",
    ),
    path(
        "mypayments/return/",
        views.payment_return,
        name="payment_return",
    ),
]

@csrf_exempt 只适合支付回调这类明确的机器对机器端点。不要为了消除 403 而关闭整个站点的 CSRF 保护。

不要把 CSRF_TRUSTED_ORIGINS 当作主要修复方式

把支付平台域名加入 CSRF_TRUSTED_ORIGINS,不代表 Django 会接受缺少 CSRF token 的 POST。这个设置主要影响 Origin 和 Referer 的来源校验,既不能代替 token 校验,也不能代替支付签名验证。

支付通知是否可信,应由支付平台的签名机制判断,而不是根据请求来自哪个页面、使用哪个路径,或者携带了哪个可伪造的参数来判断。

还需要检查尾部斜杠

确认支付平台中配置的地址与 Django 路由完全一致:

http://127.0.0.1:8000/mypayments/purchased/

下面的地址缺少末尾斜杠:

http://127.0.0.1:8000/mypayments/purchased

启用 APPEND_SLASH 后,该地址可能会被重定向到带斜杠的地址。POST 回调不应依赖这种自动重定向,因为不同客户端可能会以不同方式处理重定向后的请求方法和请求体。

注意事项

  • 127.0.0.1 只对当前机器有效。真实支付平台的服务器通常无法访问你的本机地址。本地联调一般需要使用沙箱工具、反向代理或临时公网 HTTPS 地址。
  • 不要根据 return URL 中的 status=success 直接将订单标记为已支付。
  • 必须在服务端验证支付结果,包括签名、订单号、金额、币种和商户身份。
  • webhook 需要实现幂等处理,因为支付平台可能重复发送通知。
  • 如果两个路径确实进入了同一个 view,但结果不同,应先比较 HTTP 方法、Cookie、请求体、请求头、重定向链和 URL 解析结果,不要直接把原因归结为路径名称。

备注:内容仅供参考。