mcpField
P
payment-pci-security

payment-pci-security

github.com/vtex安全471.1k 次安装2 周前更新
代码仓库

关于 payment-pci-security

面向开发 VTEX 支付连接器和无头店铺支付流程的开发者,指导 PCI DSS 合规与安全代理的使用。内容重点包括隔离卡数据、在非 PCI 环境通过 Secure Proxy 授权,以及避免存储或记录敏感数据。

主要功能

  1. 01判断 Secure Proxy 的使用条件
  2. 02授权与后续支付操作分流
  3. 03禁止 BFF 转发卡数据
  4. 04禁止持久化卡数据
  5. 05避免日志记录敏感卡信息

适用场景

  1. 01构建信用卡支付连接器
  2. 02审查卡数据的存储与日志
  3. 03设计无头店铺支付数据流
  4. 04区分授权和退款等后续操作

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/vtex/skills --skill payment-pci-security

安装后在 Claude Code 中输入/payment-pci-security确认已加载。

详细介绍

PCI 合规性与安全代理

适用场景

在以下情况使用此技能:

  • 构建接受信用卡、借记卡或联名卡的支付连接器
  • 连接器需要处理卡数据或与收单机构通信
  • 确定托管环境是否需要使用安全代理
  • 审核连接器是否符合 PCI DSS(数据存储、日志记录、传输)

以下情况不要使用此技能:

决策规则

  • 如果连接器托管在非 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 认证)模式?
  • 记录错误响应时,是否未包含收单机构请求正文(其中包含令牌)?

相关技能

参考资料

评论

支持 Markdown