PAYATHON 2026

Content Security Policy 导致支付回调页面 404

支付小周

结论

这通常不是 Angular 路由被 Content Security Policy(CSP)“重定向成了 404”,而是支付网关通过 POST 表单返回数据,但 Angular SSR 服务端只处理页面的 GET 请求,没有对应的 POST 回调接口。请求到达服务器后找不到匹配路由,于是返回 404。

建议按以下顺序处理:

  1. 在浏览器 Network 面板中确认回调请求的 URL、HTTP 方法和响应状态。
  2. 在服务端创建专用的支付回调接口,用来接收并验证支付网关提交的数据。
  3. 验签成功后,通过 303 See Other 跳转到 Angular 的 GET 结果页。
  4. 如果控制台明确报告某条 CSP 指令被违反,再针对该指令调整策略,不要直接放宽全部 CSP 配置。

先区分 404 和 CSP 拦截

404 和 CSP 拦截是两类问题:

  • 404 Not Found:请求已经到达服务器,但服务器没有匹配的路由,或者 SSR、反向代理没有正确处理该请求。
  • CSP 拦截:浏览器在发起请求、提交表单、加载 iframe 或执行脚本前阻止了操作。控制台通常会显示类似 Refused to ... because it violates the following Content Security Policy directive 的错误。

支付网关经常通过自动提交表单返回结果:

<form method="POST" action="https://shop.example.com/payment/callback">
  <input type="hidden" name="transactionId" value="...">
  <input type="hidden" name="signature" value="...">
</form>

假设服务器只有下面这个 Angular 页面路由:

/payment/result

却没有接收 POST /payment/callback 的服务端接口,那么请求无法进入 Angular 客户端路由,SSR 服务器或反向代理很可能直接返回 404。

排查步骤

1. 检查实际回调请求

在浏览器开发者工具的 Network 面板中找到失败的请求,重点检查:

  • Request URL 是否与支付网关后台配置完全一致
  • Request Method 是 GET 还是 POST
  • Status Code 来自支付网关还是本站服务器
  • 请求是否经过 301、302、303、307 或 308 跳转
  • Response Headers 中是否包含 Content-Security-Policy
  • 表单数据使用了什么 Content-Type

支付网关常用的提交类型是:

Content-Type: application/x-www-form-urlencoded

也可能是:

Content-Type: multipart/form-data

不要只看最终显示的 404 页面,还要检查完整的请求和跳转链。

2. 查看浏览器控制台中的完整 CSP 错误

需要确认具体违反了哪条指令。例如:

Refused to send form data to 'https://...' because it violates
the following Content Security Policy directive: "form-action 'self'"

或者:

Refused to frame 'https://...' because it violates
the following Content Security Policy directive: "frame-src 'self'"

不同指令控制的内容不同:

CSP 指令影响范围
form-action限制当前页面可以向哪些地址提交表单
frame-src限制当前页面可以加载哪些 iframe
frame-ancestors限制当前页面可以被哪些站点嵌入
connect-src限制 fetch、XHR、WebSocket 等连接
script-src限制脚本来源和内联脚本执行
default-src未单独配置其他指令时使用的默认来源

普通的顶层页面跳转通常不受 connect-src 控制。看到”重定向”并不意味着应该修改 connect-src。

3. 直接测试回调地址

分别测试回调地址处理 GET 和 POST 的结果:

curl -i https://shop.example.com/payment/callback
curl -i -X POST \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data 'transactionId=test&status=success&signature=test' \
  https://shop.example.com/payment/callback

如果 GET 可以返回页面,而 POST 返回 404,基本可以确定服务端缺少 POST 路由。这里的测试数据不能证明真实支付是否成功,正式接口仍须验证签名。

推荐的服务端处理方式

支付回调不应直接交给 Angular 页面处理。可以采用下面的流程:

支付网关
    ↓ POST 表单
/payment/callback(服务端接收并验签)
    ↓ 303 Redirect
/payment/result?reference=...(Angular SSR/客户端结果页)

以常见的 Angular Universal + Express 结构为例,可以在 SSR 通配路由之前注册回调接口:

import express, { Request, Response } from 'express';

const app = express();

app.use(express.urlencoded({ extended: false }));
app.use(express.json());

app.post('/payment/callback', async (req: Request, res: Response) => {
  const payload = req.body;

  try {
    const isValid = verifyPaymentSignature(payload);

    if (!isValid) {
      return res.status(400).send('Invalid payment signature');
    }

    // 应根据 transactionId 查询本地订单,
    // 并按支付网关协议校验金额、币种、商户号和支付状态。
    const order = await updateOrderFromGateway(payload);

    // POST 完成后跳转到 GET 页面,避免刷新时重复提交表单。
    return res.redirect(
      303,
      `/payment/result?reference=${encodeURIComponent(order.publicReference)}`
    );
  } catch (error) {
    console.error('Payment callback failed', error);
    return res.redirect(303, '/payment/result?status=processing');
  }
});

// 静态资源和 Angular SSR 通配路由应放在回调接口之后。
app.get('*', (req: Request, res: Response) => {
  // 调用 Angular Universal 的 SSR 渲染逻辑。
});

示例中的 verifyPaymentSignature() 和 updateOrderFromGateway() 必须按照实际支付网关的协议实现。不能仅凭请求中的 status=success 判断支付成功。

303 会明确要求浏览器在跳转后发起 GET 请求。如果使用 307 或 308,浏览器会保留原来的 POST 方法,结果页可能再次返回 404。

检查反向代理配置

如果应用前面还有 Nginx、CDN、负载均衡器或托管平台,需要确认这些组件没有把 POST 请求当作静态页面请求处理。

例如,可以把回调请求转发给 Node.js 服务:

location = /payment/callback {
    proxy_pass http://127.0.0.1:4000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

location / {
    proxy_pass http://127.0.0.1:4000;
    proxy_set_header Host $host;
}

如果使用静态托管,即使 Angular 配置了客户端路由回退,通常也无法接收支付网关提交的 POST 表单。此时需要增加后端、Serverless Function,或使用支付平台支持的服务端回调接口。

CSP 应该如何调整

只有控制台明确显示本站 CSP 阻止了操作时,才需要修改策略。

如果本站页面要向支付网关提交表单,可以放行指定的支付域名:

Content-Security-Policy: default-src 'self'; form-action 'self' https://payments.example-gateway.com;

如果支付网关通过 iframe 嵌入,可能还需要配置:

Content-Security-Policy: default-src 'self'; frame-src 'self' https://payments.example-gateway.com;

如果支付结果页需要嵌入支付网关页面,则要检查结果页的 frame-ancestors:

Content-Security-Policy: default-src 'self'; frame-ancestors 'self' https://payments.example-gateway.com;

其中的域名必须替换成支付服务商文档明确列出的生产环境域名。不要使用这种过度宽松的配置:

Content-Security-Policy: default-src * 'unsafe-inline' 'unsafe-eval';

这会明显削弱 CSP 对 XSS 和恶意资源加载的防护。

还要注意,form-action 检查的是发起表单提交页面的 CSP。如果表单完全由支付网关页面生成并提交,而控制台显示是支付网关自己的 CSP 阻止了提交,那么修改本站响应头无法解决问题。此时需要支付服务商修正配置,或确认回调地址是否符合其要求。

Angular 路由配置

服务端处理完回调后,Angular 中仍需配置对应的结果页路由:

const routes: Routes = [
  {
    path: 'payment/result',
    component: PaymentResultComponent
  },
  {
    path: '**',
    component: NotFoundComponent
  }
];

结果页最好只接收公开的订单引用,再向本站后端查询最终状态:

this.route.queryParamMap.subscribe(params => {
  const reference = params.get('reference');

  if (reference) {
    this.paymentService.getPaymentStatus(reference).subscribe(status => {
      this.paymentStatus = status;
    });
  }
});

不要把金额、支付成功状态或敏感支付数据直接放入查询参数,并据此更新订单,因为用户可以修改这些参数。

还需要注意的事项

  • 浏览器返回页和支付网关的服务器通知通常是两个不同的通道。订单最终状态应以经过验签的服务端通知或主动查询结果为准。
  • 回调接口应支持幂等处理。同一笔交易通知可能被重复发送,不能因此重复扣库存、发货或记账。
  • 必须校验签名、商户号、订单号、金额、币种和交易状态。
  • 回调 URL 的协议、域名、路径、端口和末尾斜杠必须与支付网关配置完全一致。
  • 检查 HTTP 到 HTTPS 的跳转。部分跳转状态码会改变请求方法或丢失表单数据。
  • CSP 可能由应用、Nginx、CDN 和安全中间件同时设置。多个 Content-Security-Policy 响应头会一起生效,最终策略可能比预期更严格。
  • 不要只修改 Angular 中的 <meta http-equiv="Content-Security-Policy">。如果服务器响应头也设置了 CSP,两处都需要检查,而且部分指令不适合通过 meta 配置。
  • 不要在日志中完整记录银行卡信息、令牌、签名密钥或其他敏感支付数据。

如果失败的请求确实是 POST,应先补上服务端回调路由,并采用”接收、验签、落库、303 跳转”的流程。如果请求根本没有发出,再根据控制台报告的具体 CSP 指令,只放行必要的域名。

备注:内容仅供参考。