安全最佳实践
安全是 Shopify 应用开发中最重要的方面。你在处理商家数据、客户个人身份信息、支付信息和业务关键操作。安全漏洞不仅影响你的应用——它会影响每个信任你管理其商店的商家。Shopify 在应用审核期间执行严格的安全要求,违规可能导致立即从 App Store 移除。
OAuth 2.0 实现
Shopify 使用 OAuth 2.0 进行应用授权。深入理解该流程对于正确实现至关重要。
授权流程
Merchant clicks "Install" → Shopify redirects to your /auth endpoint
→ You validate the request → Redirect merchant to Shopify's permission screen
→ Merchant approves → Shopify redirects back with authorization code
→ You exchange code for access token → Store token securely
安全的 OAuth 实现
// routes/auth.js
import crypto from 'crypto';
import { shopifyApi } from '@shopify/shopify-api';
const shopify = shopifyApi({
apiKey: process.env.SHOPIFY_API_KEY,
apiSecretKey: process.env.SHOPIFY_API_SECRET,
scopes: ['read_products', 'write_products', 'read_orders'],
hostName: process.env.HOST.replace(/^https?:\/\//, ''),
});
// Step 1: Begin OAuth -- validate and redirect
export async function beginAuth(req, res) {
const shop = req.query.shop;
// CRITICAL: Validate the shop parameter
if (!shop || !/^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/.test(shop)) {
return res.status(400).send('Invalid shop parameter');
}
// Generate a cryptographically secure nonce
const nonce = crypto.randomBytes(16).toString('hex');
// Store nonce in session for CSRF protection
req.session.oauthNonce = nonce;
req.session.oauthShop = shop;
const authUrl = `https://${shop}/admin/oauth/authorize?` +
`client_id=${process.env.SHOPIFY_API_KEY}` +
`&scope=${shopify.config.scopes.toString()}` +
`&redirect_uri=${process.env.HOST}/auth/callback` +
`&state=${nonce}`;
res.redirect(authUrl);
}
// Step 2: Handle callback -- validate and exchange token
export async function authCallback(req, res) {
const { shop, code, state, hmac, timestamp } = req.query;
// Validate state matches our nonce (CSRF protection)
if (state !== req.session.oauthNonce) {
return res.status(403).send('State mismatch -- possible CSRF attack');
}
// Validate HMAC signature
if (!verifyHmac(req.query, process.env.SHOPIFY_API_SECRET)) {
return res.status(403).send('HMAC validation failed');
}
// Validate shop matches the original request
if (shop !== req.session.oauthShop) {
return res.status(403).send('Shop mismatch');
}
// Validate timestamp is recent (within 5 minutes)
const currentTime = Math.floor(Date.now() / 1000);
if (currentTime - parseInt(timestamp) > 300) {
return res.status(403).send('Request expired');
}
// Exchange authorization code for access token
const tokenResponse = await fetch(
`https://${shop}/admin/oauth/access_token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.SHOPIFY_API_KEY,
client_secret: process.env.SHOPIFY_API_SECRET,
code,
}),
}
);
const { access_token } = await tokenResponse.json();
// Store token securely (encrypted at rest)
await storeAccessToken(shop, access_token);
// Clean up session
delete req.session.oauthNonce;
delete req.session.oauthShop;
res.redirect(`/app?shop=${shop}`);
}
永远不要记录访问令牌、将其包含在 URL 中或以明文存储。访问令牌在你的应用权限范围内授予对商家商店的完整访问权限。像对待密码一样对待它们——静态加密,仅通过 HTTPS 传输。
Webhook 的 HMAC 验证
来自 Shopify 的每个 Webhook 都包含一个 X-Shopify-Hmac-Sha256 头。你必须验证此头以确保 Webhook 确实来自 Shopify。
// middleware/webhook-verification.js
import crypto from 'crypto';
export function verifyWebhookHmac(req, res, next) {
const hmacHeader = req.get('X-Shopify-Hmac-Sha256');
if (!hmacHeader) {
console.error('Missing HMAC header on webhook request');
return res.status(401).send('Unauthorized');
}
// IMPORTANT: Use the raw body, not the parsed JSON body
const rawBody = req.rawBody;
if (!rawBody) {
console.error('Raw body not available -- ensure body parser preserves it');
return res.status(500).send('Server configuration error');
}
const generatedHmac = crypto
.createHmac('sha256', process.env.SHOPIFY_API_SECRET)
.update(rawBody, 'utf8')
.digest('base64');
// Use timing-safe comparison to prevent timing attacks
const isValid = crypto.timingSafeEqual(
Buffer.from(hmacHeader),
Buffer.from(generatedHmac)
);
if (!isValid) {
console.error('HMAC validation failed for webhook');
return res.status(401).send('Unauthorized');
}
next();
}
// Express setup to preserve raw body
import express from 'express';
const app = express();
app.use('/webhooks', express.json({
verify: (req, _res, buf) => {
req.rawBody = buf.toString('utf8');
},
}));
app.post('/webhooks/:topic', verifyWebhookHmac, handleWebhook);
一个常见的错误是在 HMAC 验证之前解析 JSON 正文。JSON 解析可能会改变空白字符,从而更改签名。始终在任何解析之前对原始请求正文验证 HMAC。
内容安全策略
嵌入在管理后台中的 Shopify 应用运行在 iframe 内。你必须配置 CSP 头以允许 Shopify 的嵌入,同时阻止恶意内容注入。
// middleware/security-headers.js
export function securityHeaders(req, res, next) {
const shop = req.query.shop || req.session?.shop;
// Allow embedding by Shopify admin only
res.setHeader(
'Content-Security-Policy',
[
`frame-ancestors https://${shop} https://admin.shopify.com`,
"default-src 'self'",
"script-src 'self' https://cdn.shopify.com",
"style-src 'self' 'unsafe-inline' https://cdn.shopify.com",
"img-src 'self' https://cdn.shopify.com data:",
"connect-src 'self' https://*.myshopify.com https://*.shopify.com",
"font-src 'self' https://cdn.shopify.com",
].join('; ')
);
// Additional security headers
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY'); // Overridden by CSP for Shopify
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
}
GDPR 合规——强制性 Webhook
Shopify 要求所有应用处理三个强制性 GDPR Webhook。未能实现这些将导致应用被拒绝。
三个强制性 Webhook
// routes/gdpr.js
/**
* 1. Customer Data Request
* Shopify sends this when a customer requests their data.
* You must respond with all data you store about this customer.
*/
export async function handleCustomerDataRequest(req, res) {
const { shop_domain, customer } = req.body;
// Queue a job to gather all customer data
await queue.add('gdpr:customer-data-request', {
shopDomain: shop_domain,
customerId: customer.id,
email: customer.email,
});
// Respond immediately -- process asynchronously
res.status(200).send({ received: true });
}
/**
* 2. Customer Data Erasure
* Shopify sends this when a customer requests deletion of their data.
* You must delete all data you store about this customer.
*/
export async function handleCustomerRedact(req, res) {
const { shop_domain, customer, orders_to_redact } = req.body;
await queue.add('gdpr:customer-redact', {
shopDomain: shop_domain,
customerId: customer.id,
email: customer.email,
orderIds: orders_to_redact,
});
res.status(200).send({ received: true });
}
/**
* 3. Shop Data Erasure
* Shopify sends this 48 hours after a merchant uninstalls your app.
* You must delete ALL data related to this shop.
*/
export async function handleShopRedact(req, res) {
const { shop_domain, shop_id } = req.body;
await queue.add('gdpr:shop-redact', {
shopDomain: shop_domain,
shopId: shop_id,
});
res.status(200).send({ received: true });
}
GDPR Webhook 必须在收到后 30 天内处理。实现带有重试逻辑和监控的任务队列,确保你永远不会错过合规截止日期。记录每个 GDPR 请求的完成情况以供审计。
GDPR 任务处理器
// workers/gdpr-processor.js
export async function processShopRedact({ shopDomain, shopId }) {
const db = getDatabase();
try {
// Delete in dependency order to respect foreign keys
await db.transaction(async (tx) => {
await tx.delete('webhook_logs').where({ shopId });
await tx.delete('sync_records').where({ shopId });
await tx.delete('app_settings').where({ shopId });
await tx.delete('sessions').where({ shopId });
await tx.delete('shops').where({ id: shopId });
});
// Also delete from external services
await analyticsService.deleteShopData(shopDomain);
await cacheService.invalidateShop(shopDomain);
// Log completion for audit trail
await auditLog.record({
type: 'SHOP_REDACT_COMPLETED',
shopDomain,
completedAt: new Date().toISOString(),
});
} catch (error) {
// Re-throw to trigger retry
console.error(`Shop redact failed for ${shopDomain}:`, error);
throw error;
}
}
访问令牌轮换(自 2026 年 1 月起)
自 2026 年 1 月起,Shopify 支持访问令牌轮换以提高安全性。启用后,访问令牌具有可配置的过期时间,你的应用必须自动处理令牌刷新。强烈建议处理敏感数据的应用使用此功能,并且在未来版本中将对新应用强制执行。
// services/token-manager.js
import { encrypt, decrypt } from './encryption.js';
export class TokenManager {
constructor(db) {
this.db = db;
}
async getValidToken(shopDomain) {
const record = await this.db.findOne('tokens', { shopDomain });
if (!record) {
throw new Error(`No token found for ${shopDomain}`);
}
const token = decrypt(record.encryptedToken);
const expiresAt = new Date(record.expiresAt);
// Refresh if token expires within 5 minutes
if (expiresAt - Date.now() < 5 * 60 * 1000) {
return await this.rotateToken(shopDomain, token);
}
return token;
}
async rotateToken(shopDomain, currentToken) {
const response = await fetch(
`https://${shopDomain}/admin/oauth/access_token/rotate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': currentToken,
},
}
);
if (!response.ok) {
throw new Error(`Token rotation failed: ${response.statusText}`);
}
const { access_token, expires_at } = await response.json();
// Store the new token encrypted
await this.db.update('tokens', {
where: { shopDomain },
data: {
encryptedToken: encrypt(access_token),
expiresAt: expires_at,
rotatedAt: new Date().toISOString(),
},
});
return access_token;
}
}
应用审核安全检查清单
在提交应用之前,验证此列表中的每一项:
| Requirement | Status | Notes |
|---|---|---|
| OAuth HMAC validation | Required | Verify all incoming requests from Shopify |
| Webhook HMAC validation | Required | Timing-safe comparison on raw body |
| GDPR webhooks implemented | Required | All three endpoints functional |
| CSP headers configured | Required | frame-ancestors must include Shopify admin |
| Access tokens encrypted at rest | Required | Use AES-256 or equivalent |
| No secrets in client-side code | Required | No API keys in frontend bundles |
| HTTPS everywhere | Required | All endpoints must use TLS 1.2+ |
| Input validation | Required | Sanitize all user inputs |
| SQL injection prevention | Required | Use parameterized queries exclusively |
| Rate limiting on public endpoints | Recommended | Prevent abuse of your API |
| Access token rotation | Recommended | Will be mandatory in a future release |
| Dependency vulnerability scanning | Recommended | Run npm audit in CI |
在开发过程中使用 --verbose 标志运行 shopify app dev,以查看你的应用发送和接收的确切头信息。这有助于在 CSP 和认证问题到达应用审核团队之前捕获它们。
依赖安全扫描
将自动化漏洞扫描添加到你的 CI 流水线:
# .github/workflows/security.yml
name: Security Scan
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm audit --audit-level=high
- name: Run Snyk security scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
安全不是你在最后添加的功能。它必须从代码的第一行开始就融入应用的每一层。本模块中的模式代表最低基线——始终通过监控 Shopify Changelog 和 Partner Blog 来跟上 Shopify 不断演进的安全要求。