关于 payment-pci-security
面向开发 VTEX 支付连接器和无头店铺支付流程的开发者,指导 PCI DSS 合规与安全代理的使用。内容重点包括隔离卡数据、在非 PCI 环境通过 Secure Proxy 授权,以及避免存储或记录敏感数据。
主要功能
- 01判断 Secure Proxy 的使用条件
- 02授权与后续支付操作分流
- 03禁止 BFF 转发卡数据
- 04禁止持久化卡数据
- 05避免日志记录敏感卡信息
适用场景
- 01构建信用卡支付连接器
- 02审查卡数据的存储与日志
- 03设计无头店铺支付数据流
- 04区分授权和退款等后续操作
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ vtex/ skills --skill payment-pci-security安装后在 Claude Code 中输入/payment-pci-security确认已加载。
详细介绍
PCI 合规性与安全代理
适用场景
在以下情况使用此技能:
- 构建接受信用卡、借记卡或联名卡的支付连接器
- 连接器需要处理卡数据或与收单机构通信
- 确定托管环境是否需要使用安全代理
- 审核连接器是否符合 PCI DSS(数据存储、日志记录、传输)
以下情况不要使用此技能:
- PPP 端点契约和响应结构 — 使用
payment-provider-protocol - 幂等性和重复请求防护 — 使用
payment-idempotency - 异步支付流程(Boleto、Pix)和回调 — 使用
payment-async-flow - POS / VTEX Sales App 刷卡支付(Gateway 的
card字段为 null)— 使用payment-ppp-pos
决策规则
- 如果连接器托管在非 PCI 环境中(包括所有 VTEX IO 应用),则必须使用安全代理。
- 如果连接器获得了 PCI DSS 认证(由 QSA 签署的 AOC),则可以使用原始卡数据直接调用收单机构。
- 检查 Create Payment 请求中是否存在
secureProxyUrl— 如果存在,则表示安全代理已启用,必须使用。 secureProxyUrl仅存在于 Create Payment(授权)请求中。 取消、捕获、结算和退款操作不携带卡数据,也不会收到此字段。授权后的调用会使用凭证和outbound-access策略直接访问 PSP API — 由于这些操作不涉及卡数据,因此不会降低 PCI 合规性。- 卡令牌(
numberToken、holderToken、cscToken)只有通过secureProxyUrl发送才有效 — 代理会在转发给收单机构之前将其替换为真实数据。 - 只能存储
card.bin(前 6 位数字)、card.numberLength和card.expiration。其他所有数据均禁止存储。 - 卡数据绝不能出现在日志、数据库、文件、缓存、错误跟踪系统或 APM 工具中 — 即使在开发环境中也不行。
严格要求
要求:非 PCI 环境必须使用 secureProxyUrl
如果连接器托管在非 PCI 环境中(包括所有 VTEX IO 应用),则必须使用 Create Payment 请求中的 secureProxyUrl 与收单机构通信。不得使用原始卡数据直接调用收单机构。如果请求中存在 secureProxyUrl 字段,则表示安全代理已启用,必须使用。
为何重要 非 PCI 环境无权处理原始卡数据。直接调用收单机构会绕过 Gateway 的安全数据处理机制,违反 PCI DSS。这可能导致数据泄露、巨额罚款(每月超过 10 万美元)、丧失银行卡处理能力以及法律责任。
检测
如果请求中存在 secureProxyUrl,但连接器直接调用收单机构端点(未通过 secureProxyUrl),请立即停止。所有与收单机构的通信都必须通过安全代理。
正确
async function createPaymentHandler(
req: Request,
res: Response,
): Promise<void> {
const { paymentId, secureProxyUrl, card } = req.body;
if (secureProxyUrl) {
// Non-PCI: Route through Secure Proxy
const acquirerResponse = await fetch(secureProxyUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-PROVIDER-Forward-To": "https://api.acquirer.com/v2/payments",
"X-PROVIDER-Forward-MerchantId": process.env.ACQUIRER_MERCHANT_ID!,
"X-PROVIDER-Forward-MerchantKey": process.env.ACQUIRER_MERCHANT_KEY!,
},
body: JSON.stringify({
orderId: paymentId,
payment: {
cardNumber: card.numberToken, // Token, not real number
holder: card.holderToken, // Token, not real name
securityCode: card.cscToken, // Token, not real CVV
expirationMonth: card.expiration.month,
expirationYear: card.expiration.year,
},
}),
});
const result = await acquirerResponse.json();
// Build and return PPP response...
}
}
错误
async function createPaymentHandler(
req: Request,
res: Response,
): Promise<void> {
const { paymentId, secureProxyUrl, card } = req.body;
// WRONG: Calling acquirer directly, bypassing Secure Proxy
// This connector is non-PCI but handles card data as if it were PCI-certified
const acquirerResponse = await fetch("https://api.acquirer.com/v2/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
MerchantId: process.env.ACQUIRER_MERCHANT_ID!,
},
body: JSON.stringify({
orderId: paymentId,
payment: {
// These are tokens but sent directly to acquirer — acquirer can't read tokens!
// And if raw data were here, this would be a PCI violation
cardNumber: card.numberToken,
holder: card.holderToken,
securityCode: card.cscToken,
},
}),
});
// This request will fail: acquirer receives tokens instead of real card data
// And the Secure Proxy was completely bypassed
}
注意:Secure Proxy 仅适用于银行卡授权,不适用于授权后的操作
secureProxyUrl 字段仅存在于 Create Payment 请求中(银行卡授权)。取消、捕获(结算)和退款请求不包含 secureProxyUrl,因为这些操作不涉及银行卡数据——它们通过 tid、authorizationId 或 paymentId 引用交易。
授权后的调用必须使用带有 API 凭证的 ExternalClient,直接发送到 PSP API,并通过 manifest.json 中的 outbound-access 策略进行授权。
为什么这很重要
尝试通过 Secure Proxy 路由取消、捕获或退款操作会失败,因为这些请求中的 secureProxyUrl 是 undefined。从架构上看,这不会带来 PCI 合规方面的顾虑——只有授权操作会携带敏感银行卡数据。假设所有 PSP 通信都必须经过 Secure Proxy 的 Agent 和开发者,会浪费大量时间来排查 undefined 代理 URL。
检测
如果取消、捕获或退款处理程序引用了 secureProxyUrl、使用了 SecureExternalClient,或尝试通过 Secure Proxy 路由,请立即停止。授权后的操作应使用 ExternalClient 直接调用 API。
正确——双客户端架构
// Authorization (Create Payment) — via Secure Proxy
import { SecureExternalClient } from "@vtex/payment-provider";
export class PspSecureClient extends SecureExternalClient {
public async authorize(data: object, secureProxyUrl: string) {
return this.http.post("/payments", data, {
secureProxy: secureProxyUrl,
} as any);
}
}
// Cancel, Capture, Refund — direct API calls
import { ExternalClient } from "@vtex/api";
export class PspClient extends ExternalClient {
public async capture(tid: string, amount: number) {
return this.http.post(
`/payments/${tid}/capture`,
{ amount },
{
headers: { "X-API-Key": "..." },
},
);
}
}
错误
// WRONG — trying to use SecureExternalClient for all operations
async settle(request: SettlementRequest) {
// secureProxyUrl does not exist on SettlementRequest — this is undefined
await this.secureClient.capture(request.tid, request.value, request.secureProxyUrl)
}
约束:无头店铺的 BFF 绝不能将卡数据代理到 vtexpayments.com.br
在无头店铺中,发送支付信息调用(POST https://{account}.vtexpayments.com.br/api/pub/transactions/{tid}/payments)必须由购物者的浏览器或原生应用发起。商家的 BFF(Node、Next.js 路由处理程序、边缘函数、lambda、反向代理或任何服务器端组件)绝不能从浏览器接收卡字段,也绝不能将其转发给 Payment Gateway,即使经过脱敏、即使服务器端配置了 appKey/appToken,也不例外;即使看起来转发的只有“令牌化”字段,也不例外。
此约束延伸了驱动 Secure Proxy 的同一 PCI 原则:非 PCI 环境不得处理卡数据。对于支付连接器,该环境是 IO 应用;对于无头店铺,则是商家的 BFF。目标路径有所不同——Secure Proxy 保护连接器 → 收单机构的路径,浏览器 → vtexpayments.com.br 模式保护店铺 → Payment Gateway 的路径——但规则完全相同:原始卡数据必须留在经认证的范围内。
为何重要
运营无头店铺的商家很少获得 PCI DSS Level 1 认证。通过商家的 BFF 传输卡号、持卡人姓名或 CVV,会使 BFF 及其接触的每个系统(应用日志、APM、反向代理、负载均衡器、错误跟踪工具)都纳入 PCI 范围。运营会处理卡数据的非 PCI 环境违反 PCI DSS 要求 3 和 4,可能导致卡组织处以每月 $5,000 至超过 $100,000 的罚款、强制取证调查费用、丧失处理银行卡的能力、集体诉讼风险,以及某些司法管辖区的刑事责任。
VTEX Payment Gateway(vtexpayments.com.br)已通过 PCI DSS Level 1 认证。浏览器 → Payment Gateway 的路径可使卡数据留在经认证的范围内;商家的 BFF 则完全不参与卡数据流。发送支付信息端点通过前一个 Place Order 步骤设置的购物者会话 Cookie 进行身份验证——此步骤无需商家凭证。
检测
如果在 BFF / 服务器端代码(server/、bff/、api/、路由处理程序、中间件、边缘函数、lambda)中发现以下任何内容,请立即停止:
- BFF 向
https://*.vtexpayments.com.br/api/pub/transactions/.../payments发起请求, - 从浏览器接收
cardNumber、holderName、validationCode、csc、dueDate或完整支付fields的处理程序, - 服务器上指向
vtexpayments.com.br、用于发送支付信息端点的“Payments client” /ExternalClient/axios实例。
BFF 应公开 /api/bff/order/place(返回 transactionId、orderGroup、merchantName)和 /api/bff/order/process(调用 /api/checkout/pub/gatewayCallback/{orderGroup}——不含卡数据),但绝不能提供将卡字段转发到 vtexpayments.com.br 的端点。
正确
async function sendPaymentDataDirect(args: {
account: string;
transactionId: string;
orderGroup: string;
merchantName: string;
paymentInformation: PaymentField[];
}): Promise<void> {
const {
account,
transactionId,
orderGroup,
merchantName,
paymentInformation,
} = args;
const response = await fetch(
`https://${account}.vtexpayments.com.br/api/pub/transactions/${transactionId}/payments?orderId=${orderGroup}`,
{
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(
paymentInformation.map((p) => ({
...p,
transaction: { id: transactionId, merchantName },
})),
),
},
);
if (!response.ok) {
throw new Error(`Payment submission failed: ${response.status}`);
}
}
错误
const VTEX_APP_KEY = process.env.VTEX_APP_KEY!;
const VTEX_APP_TOKEN = process.env.VTEX_APP_TOKEN!;
paymentRoutes.post("/", async (req: Request, res: Response) => {
const { transactionId, orderGroup, paymentInformation } = req.body as {
transactionId: string;
orderGroup: string;
paymentInformation: Array<{
paymentSystem: number;
installments: number;
value: number;
fields: {
cardNumber: string;
holderName: string;
validationCode: string;
dueDate: string;
};
}>;
};
const url = `https://${process.env.VTEX_ACCOUNT}.vtexpayments.com.br/api/pub/transactions/${transactionId}/payments?orderId=${orderGroup}`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-VTEX-API-AppKey": VTEX_APP_KEY,
"X-VTEX-API-AppToken": VTEX_APP_TOKEN,
},
body: JSON.stringify(paymentInformation),
});
res.json({ status: response.status });
});
约束:绝不能存储原始卡数据
连接器绝不能在任何持久化存储中存储完整卡号(PAN)、CVV/CSC、持卡人姓名或任何银行卡令牌值——包括数据库、文件系统、缓存、会话存储或任何其他持久介质。卡数据只能在请求生命周期内存在于内存中。
为何重要 存储原始卡数据违反 PCI DSS 要求 3。数据泄露会使客户面临欺诈风险。后果包括卡组织每月处以 $5,000–$100,000 的罚款、强制取证调查费用($50K 以上)、丧失处理银行卡的能力、集体诉讼,以及某些司法管辖区的刑事责任。
检测
如果代码将卡号、CVV、持卡人姓名或令牌值写入数据库、文件、缓存(Redis、VBase)或任何持久化存储,请立即停止。只能存储 card.bin(前 6 位数字)和 card.numberLength。
正确
async function createPaymentHandler(
req: Request,
res: Response,
): Promise<void> {
const { paymentId, card, secureProxyUrl } = req.body;
// Only store non-sensitive card metadata
await paymentStore.save(paymentId, {
paymentId,
cardBin: card.bin, // First 6 digits — safe to store
cardNumberLength: card.numberLength, // Length — safe to store
cardExpMonth: card.expiration.month, // Expiration — safe to store
cardExpYear: card.expiration.year,
// DO NOT store: card.numberToken, card.holderToken, card.cscToken
});
// Use card tokens only in-memory for the Secure Proxy call
const acquirerResult = await callAcquirerViaProxy(secureProxyUrl, card);
// Return response — card data is now out of scope
res.status(200).json(buildResponse(paymentId, acquirerResult));
}
错误
async function createPaymentHandler(
req: Request,
res: Response,
): Promise<void> {
const { paymentId, card } = req.body;
// CRITICAL PCI VIOLATION: Storing full card data in database
await database.query(
`INSERT INTO payments (payment_id, card_number, cvv, holder_name)
VALUES ($1, $2, $3, $4)`,
[paymentId, card.number, card.csc, card.holder],
);
// This single line can result in:
// - $100K/month fines from card networks
// - Mandatory forensic audit ($50K+)
// - Loss of card processing ability
// - Criminal liability
}
约束:绝不能记录敏感银行卡数据
连接器绝不能将卡号、CVV/CSC 值、持卡人姓名或令牌值记录到任何日志系统中,包括控制台、文件、监控服务、错误跟踪工具或 APM 工具。即使在调试模式下也不行,即使在开发环境中也不行。
为何这很重要 日志通常以明文存储,保留时间很长,而且许多团队成员都可以访问。日志中包含银行卡数据违反 PCI DSS,属于数据泄露。日志聚合服务(Datadog、Splunk、CloudWatch)可能会在多个区域存储数据,从而扩大泄露范围。
检测
如果代码中包含 console.log、console.error、logger.info、logger.debug,或任何包含 card.number、card.csc、card.holder、card.numberToken、card.holderToken、card.cscToken 或未脱敏的完整请求正文的日志调用,请立即停止。在记录日志前,先脱敏或省略所有敏感字段。
正确
async function createPaymentHandler(
req: Request,
res: Response,
): Promise<void> {
const { paymentId, card, paymentMethod, value } = req.body;
// Safe logging — only non-sensitive fields
console.log("Processing payment", {
paymentId,
paymentMethod,
value,
cardBin: card?.bin, // First 6 digits only — safe
cardNumberLength: card?.numberLength, // Safe
});
// NEVER log the full request body for payment requests
// It contains card tokens or raw card data
}
function redactSensitiveFields(
body: Record<string, unknown>,
): Record<string, unknown> {
const redacted = { ...body };
if (redacted.card && typeof redacted.card === "object") {
const card = redacted.card as Record<string, unknown>;
redacted.card = {
bin: card.bin,
numberLength: card.numberLength,
expiration: card.expiration,
// All other fields redacted
};
}
return redacted;
}
错误
async function createPaymentHandler(
req: Request,
res: Response,
): Promise<void> {
// CRITICAL PCI VIOLATION: Logging the entire request body
// This includes card number, CVV, holder name, and/or token values
console.log("Payment request received:", JSON.stringify(req.body));
// ALSO WRONG: Logging specific card fields
console.log("Card number:", req.body.card.number);
console.log("CVV:", req.body.card.csc);
console.log("Card holder:", req.body.card.holder);
// ALSO WRONG: Logging tokens (they reference real card data)
console.log("Card token:", req.body.card.numberToken);
}
推荐模式
Secure Proxy 数据流:
1. Gateway → POST /payments (with secureProxyUrl + tokenized card data) → Connector
2. Connector → POST secureProxyUrl (tokens in body, X-PROVIDER-Forward-To: acquirer URL) → Gateway
3. Gateway replaces tokens with real card data → POST acquirer URL → Acquirer
4. Acquirer → response → Gateway → Connector
5. Connector → Create Payment response → Gateway
授权后的数据流(不使用 Secure Proxy):
1. Gateway → POST /payments/{id}/settlements → Connector
2. Connector → POST acquirer API directly (tid + amount, no card data) → Acquirer
3. Acquirer → response → Connector
4. Connector → Settlement response → Gateway
检测 Secure Proxy 模式:
interface CreatePaymentRequest {
paymentId: string;
value: number;
currency: string;
paymentMethod: string;
card?: {
holder?: string; // Raw (PCI) or absent (Secure Proxy)
holderToken?: string; // Token (Secure Proxy only)
number?: string; // Raw (PCI) or absent (Secure Proxy)
numberToken?: string; // Token (Secure Proxy only)
bin: string; // Always present — first 6 digits
numberLength: number; // Always present
csc?: string; // Raw (PCI) or absent (Secure Proxy)
cscToken?: string; // Token (Secure Proxy only)
expiration: { month: string; year: string };
};
secureProxyUrl?: string; // Present when Secure Proxy is active
secureProxyTokensURL?: string; // For custom token operations
callbackUrl: string;
miniCart: Record<string, unknown>;
}
function isSecureProxyActive(req: CreatePaymentRequest): boolean {
return !!req.secureProxyUrl;
}
使用令牌或原始值构建收单机构请求:
function buildAcquirerRequest(paymentReq: CreatePaymentRequest) {
const card = paymentReq.card!;
return {
merchantOrderId: paymentReq.paymentId,
payment: {
// Use tokens if Secure Proxy, raw values if PCI-certified
cardNumber: card.numberToken ?? card.number!,
holder: card.holderToken ?? card.holder!,
securityCode: card.cscToken ?? card.csc!,
expirationDate: `${card.expiration.month}/${card.expiration.year}`,
amount: paymentReq.value,
},
};
}
通过 Secure Proxy 并使用正确的标头调用收单机构:
async function callAcquirerViaProxy(
secureProxyUrl: string,
acquirerRequest: object,
): Promise<AcquirerResponse> {
const response = await fetch(secureProxyUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
// X-PROVIDER-Forward-To tells the proxy where to send the request
"X-PROVIDER-Forward-To": process.env.ACQUIRER_API_URL!,
// Custom headers for the acquirer — prefix is stripped by the proxy
"X-PROVIDER-Forward-MerchantId": process.env.ACQUIRER_MERCHANT_ID!,
"X-PROVIDER-Forward-MerchantKey": process.env.ACQUIRER_MERCHANT_KEY!,
},
body: JSON.stringify(acquirerRequest),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Secure Proxy call failed: ${response.status} ${errorText}`,
);
}
return response.json() as Promise<AcquirerResponse>;
}
// For PCI-certified environments, call acquirer directly
async function callAcquirerDirect(
acquirerRequest: object,
): Promise<AcquirerResponse> {
const response = await fetch(process.env.ACQUIRER_API_URL!, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
MerchantId: process.env.ACQUIRER_MERCHANT_ID!,
MerchantKey: process.env.ACQUIRER_MERCHANT_KEY!,
},
body: JSON.stringify(acquirerRequest),
});
return response.json() as Promise<AcquirerResponse>;
}
安全日志工具:
function safePaymentLog(label: string, body: Record<string, unknown>): void {
const safe = {
paymentId: body.paymentId,
paymentMethod: body.paymentMethod,
value: body.value,
currency: body.currency,
orderId: body.orderId,
hasCard: !!body.card,
hasSecureProxy: !!body.secureProxyUrl,
cardBin: (body.card as Record<string, unknown>)?.bin,
// Everything else is intentionally omitted
};
console.log(label, JSON.stringify(safe));
}
常见故障模式
- 在非 PCI 环境中直接处理银行卡数据 — 不使用 Secure Proxy,直接调用收单机构 API。收单机构收到的是令牌(例如
#vtex#token#d799bae#number#),而非真实卡号,因此会拒绝交易。即使可以获取原始数据,从非 PCI 环境传输这些数据也违反 PCI DSS。 - 对取消/捕获/退款使用 Secure Proxy — 这些操作不会收到
secureProxyUrl,也不携带银行卡数据。尝试通过 Secure Proxy 路由这些请求会失败,因为 URL 为undefined。授权后的操作应使用ExternalClient直接调用 API。 - 存储完整卡号(PAN) — 为了“参考”或“对账”而将完整卡号保存到数据库。此类数据一旦泄露,可能导致每月 10 万美元罚款、强制取证审计,并永久丧失银行卡处理能力。
- 为调试而记录银行卡详情 — 为排查支付问题而添加
console.log(req.body)或console.log(card),却忘记删除。银行卡数据会进入日志文件、监控面板和日志聚合服务。即使在开发环境中,这也违反 PCI 规定。 - 移除 X-PROVIDER-Forward 标头 — 在请求 Secure Proxy 时未发送
X-PROVIDER-Forward-To标头。代理不知道应将请求转发到何处,因此会返回错误。 - 存储令牌值 — 将
card.numberToken、card.holderToken或card.cscToken写入数据库或缓存,因为它们是令牌就将其视为“安全”数据。令牌关联着真实银行卡数据,因此不得持久化存储。
审查清单
- 创建支付请求中包含
secureProxyUrl时,连接器是否使用它? -
SecureExternalClient是否仅用于创建支付(授权),而不用于取消/捕获/退款? - 授权后的操作(取消、捕获、退款)是否使用
ExternalClient直接调用 API? - 通过 Secure Proxy 调用时,
X-PROVIDER-Forward-To是否设置为收单机构的 API URL? - 通过代理发送时,收单机构的自定义标头是否加上
X-PROVIDER-Forward-前缀? - 数据库中是否仅存储
card.bin、card.numberLength和card.expiration? - 所有日志语句是否都排除了卡号、CVV、持卡人姓名和令牌值?
- 是否提供了用于安全记录支付请求数据的脱敏工具?
- 连接器是否同时支持 Secure Proxy(非 PCI)模式和直接调用(通过 PCI 认证)模式?
- 记录错误响应时,是否未包含收单机构请求正文(其中包含令牌)?
相关技能
payment-provider-framework— PPF IO 接线、双客户端模式(SecureExternalClient+ExternalClient)和outbound-access策略payment-provider-protocol— 端点契约和响应结构payment-idempotency—paymentId/requestId幂等性和状态机payment-async-flow— 异步支付方式、回调和 7 天重试窗口payment-ppp-pos— POS 刷卡支付;Gatewaycard为 null,且不适用 Secure Proxy
参考资料
- Secure Proxy — 完整的 Secure Proxy 文档,包含流程图、请求/响应示例、自定义令牌和受支持的 JsonLogic 运算符
- PCI DSS 合规性 — PCI 认证要求、AOC 提交流程,以及 Secure Proxy 的强制适用情形
- 实现支付服务提供商 — 端点实现指南,包含带有 secureProxyUrl 和令牌化卡字段的 Create Payment 请求正文
- 支付服务提供商协议(帮助中心) — 协议概述,包含 PCI 前提条件和 Secure Proxy 要求
- 支付服务提供商框架 — 用于支付连接器的 VTEX IO 框架(所有 IO 应用都必须使用 Secure Proxy)
- PCI 安全标准委员会 — 官方 PCI DSS 要求和合规文档