安全最佳實務
安全是 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 傳輸。
Webhooks 的 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 合規——強制性 Webhooks
Shopify 要求所有應用程式處理三個強制性的 GDPR webhooks。未實作這些將導致應用程式被拒絕。
三個強制性 Webhooks
// 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 webhooks 必須在收到後 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;
}
}
應用程式審核安全檢查清單
提交應用程式前,請驗證此清單上的每個項目:
| 需求 | 狀態 | 備註 |
|---|---|---|
| OAuth HMAC validation | 必要 | Verify all incoming requests from Shopify |
| Webhook HMAC validation | 必要 | Timing-safe comparison on raw body |
| GDPR webhooks implemented | 必要 | All three endpoints functional |
| CSP headers configured | 必要 | frame-ancestors must include Shopify admin |
| Access tokens encrypted at rest | 必要 | Use AES-256 or equivalent |
| No secrets in client-side code | 必要 | No API keys in frontend bundles |
| HTTPS everywhere | 必要 | All endpoints must use TLS 1.2+ |
| Input validation | 必要 | Sanitize all user inputs |
| SQL injection prevention | 必要 | Use parameterized queries exclusively |
| Rate limiting on public endpoints | 建議 | Prevent abuse of your API |
| Access token rotation | 建議 | Will be mandatory in a future release |
| Dependency vulnerability scanning | 建議 | 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 變更日誌和 Partner 部落格來跟上 Shopify 不斷演進的安全要求。