security-hardening
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSecurity Hardening for Shopify Apps
Shopify应用安全加固
This skill covers essential security practices for building secure Shopify apps. Security is non-negotiable when handling merchant data, customer PII, and payment information.
本内容介绍构建安全Shopify应用的核心安全实践。在处理商户数据、客户PII和支付信息时,安全是不可妥协的要求。
Why Security Matters for Shopify Apps
为什么Shopify应用的安全至关重要
- Merchant Trust: Apps handle sensitive business data
- Customer PII: Access to customer names, emails, addresses
- Payment Data: Some apps process or display financial information
- App Store Requirements: Shopify reviews apps for security compliance
- Legal Liability: GDPR, CCPA, and data protection regulations apply
- 商户信任:应用会处理敏感业务数据
- 客户PII:可访问客户姓名、邮箱、地址信息
- 支付数据:部分应用会处理或展示财务信息
- 应用商店要求:Shopify会审核应用的安全合规性
- 法律责任:适用GDPR、CCPA等数据保护法规
1. Authentication & Authorization
1. 身份验证与授权
Session Management
会话管理
typescript
// app/routes/app.tsx - Secure session handling
import { authenticate } from '~/shopify.server';
export async function loader({ request }: LoaderFunctionArgs) {
// ALWAYS authenticate every request
const { admin, session } = await authenticate.admin(request);
// Session contains sensitive data - never expose to client
return json({
shop: session.shop,
// DON'T return: accessToken, session object directly
});
}typescript
// app/routes/app.tsx - 安全会话处理
import { authenticate } from '~/shopify.server';
export async function loader({ request }: LoaderFunctionArgs) {
// 务必对每个请求做身份验证
const { admin, session } = await authenticate.admin(request);
// 会话包含敏感数据 - 永远不要暴露给客户端
return json({
shop: session.shop,
// 不要直接返回:accessToken、session对象本身
});
}Route Protection Pattern
路由保护模式
typescript
// app/lib/auth.server.ts
import { authenticate } from '~/shopify.server';
import { redirect } from '@remix-run/node';
export async function requireAuth(request: Request) {
try {
return await authenticate.admin(request);
} catch (error) {
// Log for monitoring, don't expose details
console.error('Authentication failed:', error);
throw redirect('/auth/login');
}
}
// Protect all app routes
// app/routes/app._index.tsx
export async function loader({ request }: LoaderFunctionArgs) {
const { admin, session } = await requireAuth(request);
// ... rest of loader
}typescript
// app/lib/auth.server.ts
import { authenticate } from '~/shopify.server';
import { redirect } from '@remix-run/node';
export async function requireAuth(request: Request) {
try {
return await authenticate.admin(request);
} catch (error) {
// 记录日志用于监控,不要暴露错误细节
console.error('Authentication failed:', error);
throw redirect('/auth/login');
}
}
// 保护所有应用路由
// app/routes/app._index.tsx
export async function loader({ request }: LoaderFunctionArgs) {
const { admin, session } = await requireAuth(request);
// ... loader其余逻辑
}API Route Protection
API路由保护
typescript
// app/routes/api.products.tsx
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { authenticate } from '~/shopify.server';
export async function action({ request }: ActionFunctionArgs) {
// ALWAYS authenticate API routes
const { admin, session } = await authenticate.admin(request);
// Validate request method
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
// ... handle request
}
// Block unauthenticated GET requests
export async function loader({ request }: LoaderFunctionArgs) {
await authenticate.admin(request);
return json({ error: 'Use POST' }, { status: 405 });
}typescript
// app/routes/api.products.tsx
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { authenticate } from '~/shopify.server';
export async function action({ request }: ActionFunctionArgs) {
// 务必对API路由做身份验证
const { admin, session } = await authenticate.admin(request);
// 验证请求方法
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
// ... 处理请求
}
// 阻止未认证的GET请求
export async function loader({ request }: LoaderFunctionArgs) {
await authenticate.admin(request);
return json({ error: 'Use POST' }, { status: 405 });
}2. Webhook Security
2. Webhook安全
HMAC Verification (Critical)
HMAC验证(核心要求)
typescript
// app/routes/webhooks.tsx
import crypto from 'crypto';
import { json, type ActionFunctionArgs } from '@remix-run/node';
export async function action({ request }: ActionFunctionArgs) {
// Get raw body for HMAC verification
const { topic, session, admin, payload } = await authenticate.webhook(request)
// Process webhook...
return json({ success: true });
}typescript
// app/routes/webhooks.tsx
import crypto from 'crypto';
import { json, type ActionFunctionArgs } from '@remix-run/node';
export async function action({ request }: ActionFunctionArgs) {
// 获取原始请求体用于HMAC验证
const { topic, session, admin, payload } = await authenticate.webhook(request)
// 处理webhook...
return json({ success: true });
}2. Input Validation & Sanitization
2. 输入验证与清理
Never Trust User Input
永远不要信任用户输入
typescript
// app/lib/validation.server.ts
import { z } from 'zod';
import DOMPurify from 'isomorphic-dompurify';
// Sanitize HTML content
export function sanitizeHtml(dirty: string): string {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
});
}
// Validate Shopify GID format
export const shopifyGidSchema = z
.string()
.regex(
/^gid:\/\/shopify\/[A-Za-z]+\/\d+$/,
'Invalid Shopify GID format'
);
// Validate and sanitize product input
export const productInputSchema = z.object({
title: z
.string()
.min(1)
.max(255)
.transform(val => val.trim()),
description: z
.string()
.max(5000)
.transform(sanitizeHtml)
.optional(),
vendor: z
.string()
.max(255)
.transform(val => val.trim())
.optional(),
tags: z
.array(z.string().max(50).transform(val => val.trim()))
.max(100)
.optional(),
});typescript
// app/lib/validation.server.ts
import { z } from 'zod';
import DOMPurify from 'isomorphic-dompurify';
// 清理HTML内容
export function sanitizeHtml(dirty: string): string {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
});
}
// 验证Shopify GID格式
export const shopifyGidSchema = z
.string()
.regex(
/^gid:\/\/shopify\/[A-Za-z]+\/\d+$/,
'Invalid Shopify GID format'
);
// 验证并清理商品输入
export const productInputSchema = z.object({
title: z
.string()
.min(1)
.max(255)
.transform(val => val.trim()),
description: z
.string()
.max(5000)
.transform(sanitizeHtml)
.optional(),
vendor: z
.string()
.max(255)
.transform(val => val.trim())
.optional(),
tags: z
.array(z.string().max(50).transform(val => val.trim()))
.max(100)
.optional(),
});SQL Injection Prevention
SQL注入防护
typescript
// ALWAYS use parameterized queries with Prisma
// app/services/product.server.ts
// GOOD - Parameterized query (Prisma handles escaping)
export async function findProducts(shop: string, search: string) {
return db.product.findMany({
where: {
shop,
title: {
contains: search, // Prisma escapes this automatically
mode: 'insensitive',
},
},
});
}
// BAD - Never do this!
// const products = await db.$queryRaw`
// SELECT * FROM products WHERE title LIKE '%${search}%'
// `;
// If you MUST use raw queries, use Prisma.sql
export async function rawSearch(shop: string, search: string) {
return db.$queryRaw`
SELECT * FROM products
WHERE shop = ${shop}
AND title ILIKE ${`%${search}%`}
`; // Prisma.sql handles escaping
}typescript
// 永远配合Prisma使用参数化查询
// app/services/product.server.ts
// 正确写法 - 参数化查询(Prisma自动处理转义)
export async function findProducts(shop: string, search: string) {
return db.product.findMany({
where: {
shop,
title: {
contains: search, // Prisma自动转义该参数
mode: 'insensitive',
},
},
});
}
// 错误写法 - 永远不要这么做!
// const products = await db.$queryRaw`
// SELECT * FROM products WHERE title LIKE '%${search}%'
// `;
// 如果必须使用原生查询,使用Prisma.sql
export async function rawSearch(shop: string, search: string) {
return db.$queryRaw`
SELECT * FROM products
WHERE shop = ${shop}
AND title ILIKE ${`%${search}%`}
`; // Prisma.sql会处理转义
}Command Injection Prevention
命令注入防护
typescript
// app/services/export.server.ts
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
// GOOD - Use execFile with arguments array
export async function generatePdf(templatePath: string, outputPath: string) {
// Validate paths
if (!templatePath.startsWith('/app/templates/')) {
throw new Error('Invalid template path');
}
// execFile doesn't use shell, so command injection is not possible
await execFileAsync('wkhtmltopdf', [
'--quiet',
templatePath,
outputPath,
]);
}
// BAD - Never use exec with user input
// exec(`wkhtmltopdf ${templatePath} ${outputPath}`); // VULNERABLE!typescript
// app/services/export.server.ts
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
// 正确写法 - 使用带参数数组的execFile
export async function generatePdf(templatePath: string, outputPath: string) {
// 验证路径
if (!templatePath.startsWith('/app/templates/')) {
throw new Error('Invalid template path');
}
// execFile不使用shell,因此不存在命令注入风险
await execFileAsync('wkhtmltopdf', [
'--quiet',
templatePath,
outputPath,
]);
}
// 错误写法 - 永远不要配合用户输入使用exec
// exec(`wkhtmltopdf ${templatePath} ${outputPath}`); // 存在漏洞!4. XSS Prevention
4. XSS防护
React/Remix Built-in Protection
React/Remix内置防护
typescript
// React automatically escapes content - this is SAFE
function ProductTitle({ title }: { title: string }) {
return <h1>{title}</h1>; // Escaped automatically
}
// DANGER - dangerouslySetInnerHTML
function ProductDescription({ html }: { html: string }) {
// Only use with sanitized content!
const sanitized = DOMPurify.sanitize(html);
return (
<div
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
);
}typescript
// React自动转义内容 - 该写法安全
function ProductTitle({ title }: { title: string }) {
return <h1>{title}</h1>; // 自动转义
}
// 危险写法 - dangerouslySetInnerHTML
function ProductDescription({ html }: { html: string }) {
// 仅配合经过清理的内容使用!
const sanitized = DOMPurify.sanitize(html);
return (
<div
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
);
}Content Security Policy
内容安全策略
typescript
// app/entry.server.tsx
import { renderToString } from 'react-dom/server';
import { RemixServer } from '@remix-run/react';
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
const markup = renderToString(
<RemixServer context={remixContext} url={request.url} />
);
// Set security headers
responseHeaders.set('Content-Type', 'text/html');
responseHeaders.set(
'Content-Security-Policy',
[
"default-src 'self'",
"script-src 'self' https://cdn.shopify.com",
"style-src 'self' 'unsafe-inline' https://cdn.shopify.com",
"img-src 'self' data: https://cdn.shopify.com https://*.shopifycdn.com",
"connect-src 'self' https://*.shopify.com",
"frame-ancestors https://*.myshopify.com https://admin.shopify.com",
].join('; ')
);
return new Response('<!DOCTYPE html>' + markup, {
status: responseStatusCode,
headers: responseHeaders,
});
}typescript
// app/entry.server.tsx
import { renderToString } from 'react-dom/server';
import { RemixServer } from '@remix-run/react';
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
const markup = renderToString(
<RemixServer context={remixContext} url={request.url} />
);
// 设置安全头
responseHeaders.set('Content-Type', 'text/html');
responseHeaders.set(
'Content-Security-Policy',
[
"default-src 'self'",
"script-src 'self' https://cdn.shopify.com",
"style-src 'self' 'unsafe-inline' https://cdn.shopify.com",
"img-src 'self' data: https://cdn.shopify.com https://*.shopifycdn.com",
"connect-src 'self' https://*.shopify.com",
"frame-ancestors https://*.myshopify.com https://admin.shopify.com",
].join('; ')
);
return new Response('<!DOCTYPE html>' + markup, {
status: responseStatusCode,
headers: responseHeaders,
});
}5. Data Protection
5. 数据保护
Environment Variables Security
环境变量安全
typescript
// .env - NEVER commit this file
SHOPIFY_API_KEY=your_api_key
SHOPIFY_API_SECRET=your_api_secret
DATABASE_URL=postgresql://user:pass@host:5432/db
ENCRYPTION_KEY=your_32_byte_encryption_key
// app/lib/env.server.ts
import { z } from 'zod';
const envSchema = z.object({
SHOPIFY_API_KEY: z.string().min(1),
SHOPIFY_API_SECRET: z.string().min(1),
DATABASE_URL: z.string().url(),
ENCRYPTION_KEY: z.string().length(32),
NODE_ENV: z.enum(['development', 'production', 'test']),
});
// Validate at startup
export const env = envSchema.parse(process.env);
// NEVER log secrets
console.log('Starting app with API key:', env.SHOPIFY_API_KEY.slice(0, 4) + '...');typescript
// .env - 永远不要提交该文件
SHOPIFY_API_KEY=your_api_key
SHOPIFY_API_SECRET=your_api_secret
DATABASE_URL=postgresql://user:pass@host:5432/db
ENCRYPTION_KEY=your_32_byte_encryption_key
// app/lib/env.server.ts
import { z } from 'zod';
const envSchema = z.object({
SHOPIFY_API_KEY: z.string().min(1),
SHOPIFY_API_SECRET: z.string().min(1),
DATABASE_URL: z.string().url(),
ENCRYPTION_KEY: z.string().length(32),
NODE_ENV: z.enum(['development', 'production', 'test']),
});
// 启动时验证
export const env = envSchema.parse(process.env);
// 永远不要打印密钥
console.log('Starting app with API key:', env.SHOPIFY_API_KEY.slice(0, 4) + '...');Encrypting Sensitive Data
敏感数据加密
typescript
// app/lib/encryption.server.ts
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const ENCRYPTION_KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');
export function encrypt(plaintext: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Return iv:authTag:encrypted
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
export function decrypt(ciphertext: string): string {
const [ivHex, authTagHex, encrypted] = ciphertext.split(':');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// Usage: Encrypt sensitive metafield values
export async function storeSecureMetafield(
admin: AdminApiClient,
ownerId: string,
key: string,
value: string
) {
const encryptedValue = encrypt(value);
return admin.graphql(`
mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { id }
userErrors { field message }
}
}
`, {
variables: {
metafields: [{
ownerId,
namespace: 'app_secure',
key,
value: encryptedValue,
type: 'single_line_text_field',
}],
},
});
}typescript
// app/lib/encryption.server.ts
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const ENCRYPTION_KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');
export function encrypt(plaintext: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// 返回格式 iv:authTag:encrypted
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
export function decrypt(ciphertext: string): string {
const [ivHex, authTagHex, encrypted] = ciphertext.split(':');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// 用法:加密敏感元字段值
export async function storeSecureMetafield(
admin: AdminApiClient,
ownerId: string,
key: string,
value: string
) {
const encryptedValue = encrypt(value);
return admin.graphql(`
mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { id }
userErrors { field message }
}
}
`, {
variables: {
metafields: [{
ownerId,
namespace: 'app_secure',
key,
value: encryptedValue,
type: 'single_line_text_field',
}],
},
});
}Data Retention & Deletion
数据留存与删除
typescript
// app/services/gdpr.server.ts
import { db } from '~/db.server';
import { encrypt, decrypt } from '~/lib/encryption.server';
// Handle customers/data_request webhook
export async function handleDataRequest(shop: string, customerId: string) {
const customerData = await db.customerData.findMany({
where: { shop, customerId },
});
// Decrypt sensitive fields before returning
return customerData.map(record => ({
...record,
email: record.encryptedEmail ? decrypt(record.encryptedEmail) : null,
phone: record.encryptedPhone ? decrypt(record.encryptedPhone) : null,
}));
}
// Handle customers/redact webhook
export async function handleCustomerRedact(shop: string, customerId: string) {
// Delete all customer data
await db.customerData.deleteMany({
where: { shop, customerId },
});
// Log for compliance
await db.auditLog.create({
data: {
shop,
action: 'customer_redact',
targetId: customerId,
timestamp: new Date(),
},
});
}
// Handle shop/redact webhook (app uninstall + 48h)
export async function handleShopRedact(shop: string) {
// Delete ALL shop data
await db.$transaction([
db.product.deleteMany({ where: { shop } }),
db.order.deleteMany({ where: { shop } }),
db.customer.deleteMany({ where: { shop } }),
db.session.deleteMany({ where: { shop } }),
db.settings.deleteMany({ where: { shop } }),
]);
console.log(`Shop data redacted: ${shop}`);
}typescript
// app/services/gdpr.server.ts
import { db } from '~/db.server';
import { encrypt, decrypt } from '~/lib/encryption.server';
// 处理customers/data_request webhook
export async function handleDataRequest(shop: string, customerId: string) {
const customerData = await db.customerData.findMany({
where: { shop, customerId },
});
// 返回前解密敏感字段
return customerData.map(record => ({
...record,
email: record.encryptedEmail ? decrypt(record.encryptedEmail) : null,
phone: record.encryptedPhone ? decrypt(record.encryptedPhone) : null,
}));
}
// 处理customers/redact webhook
export async function handleCustomerRedact(shop: string, customerId: string) {
// 删除所有客户数据
await db.customerData.deleteMany({
where: { shop, customerId },
});
// 记录日志用于合规审计
await db.auditLog.create({
data: {
shop,
action: 'customer_redact',
targetId: customerId,
timestamp: new Date(),
},
});
}
// 处理shop/redact webhook(应用卸载+48小时)
export async function handleShopRedact(shop: string) {
// 删除该商户的所有数据
await db.$transaction([
db.product.deleteMany({ where: { shop } }),
db.order.deleteMany({ where: { shop } }),
db.customer.deleteMany({ where: { shop } }),
db.session.deleteMany({ where: { shop } }),
db.settings.deleteMany({ where: { shop } }),
]);
console.log(`Shop data redacted: ${shop}`);
}6. Rate Limiting & DoS Prevention
6. 限流与DoS防护
Request Rate Limiting
请求限流
typescript
// app/lib/rate-limit.server.ts
import { LRUCache } from 'lru-cache';
interface RateLimitEntry {
count: number;
resetAt: number;
}
const cache = new LRUCache<string, RateLimitEntry>({
max: 10000, // Max entries
ttl: 60000, // 1 minute TTL
});
interface RateLimitOptions {
maxRequests: number;
windowMs: number;
}
export function rateLimit(
identifier: string,
options: RateLimitOptions = { maxRequests: 100, windowMs: 60000 }
): { allowed: boolean; remaining: number; resetAt: number } {
const now = Date.now();
const entry = cache.get(identifier);
if (!entry || now > entry.resetAt) {
// New window
const newEntry = {
count: 1,
resetAt: now + options.windowMs,
};
cache.set(identifier, newEntry);
return {
allowed: true,
remaining: options.maxRequests - 1,
resetAt: newEntry.resetAt,
};
}
if (entry.count >= options.maxRequests) {
return {
allowed: false,
remaining: 0,
resetAt: entry.resetAt,
};
}
entry.count++;
cache.set(identifier, entry);
return {
allowed: true,
remaining: options.maxRequests - entry.count,
resetAt: entry.resetAt,
};
}
// Usage in routes
export async function action({ request }: ActionFunctionArgs) {
const { session } = await authenticate.admin(request);
const { allowed, remaining, resetAt } = rateLimit(session.shop, {
maxRequests: 50,
windowMs: 60000,
});
if (!allowed) {
return json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'Retry-After': String(Math.ceil((resetAt - Date.now()) / 1000)),
'X-RateLimit-Remaining': '0',
},
}
);
}
// Process request...
}typescript
// app/lib/rate-limit.server.ts
import { LRUCache } from 'lru-cache';
interface RateLimitEntry {
count: number;
resetAt: number;
}
const cache = new LRUCache<string, RateLimitEntry>({
max: 10000, // 最大条目数
ttl: 60000, // 1分钟过期
});
interface RateLimitOptions {
maxRequests: number;
windowMs: number;
}
export function rateLimit(
identifier: string,
options: RateLimitOptions = { maxRequests: 100, windowMs: 60000 }
): { allowed: boolean; remaining: number; resetAt: number } {
const now = Date.now();
const entry = cache.get(identifier);
if (!entry || now > entry.resetAt) {
// 新的时间窗口
const newEntry = {
count: 1,
resetAt: now + options.windowMs,
};
cache.set(identifier, newEntry);
return {
allowed: true,
remaining: options.maxRequests - 1,
resetAt: newEntry.resetAt,
};
}
if (entry.count >= options.maxRequests) {
return {
allowed: false,
remaining: 0,
resetAt: entry.resetAt,
};
}
entry.count++;
cache.set(identifier, entry);
return {
allowed: true,
remaining: options.maxRequests - entry.count,
resetAt: entry.resetAt,
};
}
// 在路由中使用
export async function action({ request }: ActionFunctionArgs) {
const { session } = await authenticate.admin(request);
const { allowed, remaining, resetAt } = rateLimit(session.shop, {
maxRequests: 50,
windowMs: 60000,
});
if (!allowed) {
return json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'Retry-After': String(Math.ceil((resetAt - Date.now()) / 1000)),
'X-RateLimit-Remaining': '0',
},
}
);
}
// 处理请求...
}7. Secure Headers
7. 安全响应头
typescript
// app/lib/security-headers.server.ts
export const securityHeaders = {
// Prevent clickjacking (Shopify embedded apps need specific frame-ancestors)
'X-Frame-Options': 'DENY', // For non-embedded pages
// Prevent MIME type sniffing
'X-Content-Type-Options': 'nosniff',
// XSS Protection (legacy, but still useful)
'X-XSS-Protection': '1; mode=block',
// Referrer policy
'Referrer-Policy': 'strict-origin-when-cross-origin',
// Permissions policy
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
// HSTS (only in production)
...(process.env.NODE_ENV === 'production' && {
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
}),
};
// Apply in entry.server.tsx
Object.entries(securityHeaders).forEach(([key, value]) => {
responseHeaders.set(key, value);
});typescript
// app/lib/security-headers.server.ts
export const securityHeaders = {
// 防止点击劫持(Shopify嵌入应用需要特定的frame-ancestors配置)
'X-Frame-Options': 'DENY', // 用于非嵌入页面
// 防止MIME类型嗅探
'X-Content-Type-Options': 'nosniff',
// XSS防护(老旧浏览器兼容,仍有实用价值)
'X-XSS-Protection': '1; mode=block',
// 来源策略
'Referrer-Policy': 'strict-origin-when-cross-origin',
// 权限策略
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
// HSTS(仅生产环境启用)
...(process.env.NODE_ENV === 'production' && {
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
}),
};
// 在entry.server.tsx中应用
Object.entries(securityHeaders).forEach(([key, value]) => {
responseHeaders.set(key, value);
});8. Logging & Monitoring
8. 日志与监控
Secure Logging Practices
安全日志实践
typescript
// app/lib/logger.server.ts
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
redact: {
// Never log these fields
paths: [
'accessToken',
'password',
'secret',
'apiKey',
'authorization',
'*.accessToken',
'*.password',
'headers.authorization',
'headers.x-shopify-access-token',
],
censor: '[REDACTED]',
},
});
export { logger };
// Usage
logger.info({ shop: session.shop, action: 'product_created' }, 'Product created');
// DON'T do this
// logger.info({ session }); // May leak accessToken!typescript
// app/lib/logger.server.ts
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
redact: {
// 永远不要打印这些字段
paths: [
'accessToken',
'password',
'secret',
'apiKey',
'authorization',
'*.accessToken',
'*.password',
'headers.authorization',
'headers.x-shopify-access-token',
],
censor: '[REDACTED]',
},
});
export { logger };
// 用法
logger.info({ shop: session.shop, action: 'product_created' }, 'Product created');
// 错误用法
// logger.info({ session }); // 可能泄露accessToken!Security Event Logging
安全事件日志
typescript
// app/lib/security-events.server.ts
import { logger } from './logger.server';
import { db } from '~/db.server';
export async function logSecurityEvent(
event: {
type: 'auth_failure' | 'rate_limit' | 'suspicious_activity' | 'data_access';
shop?: string;
ip?: string;
userAgent?: string;
details: Record<string, unknown>;
}
) {
// Log to application logs
logger.warn({ event }, `Security event: ${event.type}`);
// Store in database for audit trail
await db.securityEvent.create({
data: {
type: event.type,
shop: event.shop,
ip: event.ip,
userAgent: event.userAgent,
details: JSON.stringify(event.details),
timestamp: new Date(),
},
});
// Alert on critical events (integrate with your alerting system)
if (event.type === 'suspicious_activity') {
// Send alert to Slack, PagerDuty, etc.
}
}typescript
// app/lib/security-events.server.ts
import { logger } from './logger.server';
import { db } from '~/db.server';
export async function logSecurityEvent(
event: {
type: 'auth_failure' | 'rate_limit' | 'suspicious_activity' | 'data_access';
shop?: string;
ip?: string;
userAgent?: string;
details: Record<string, unknown>;
}
) {
// 记录到应用日志
logger.warn({ event }, `Security event: ${event.type}`);
// 存储到数据库用于审计追踪
await db.securityEvent.create({
data: {
type: event.type,
shop: event.shop,
ip: event.ip,
userAgent: event.userAgent,
details: JSON.stringify(event.details),
timestamp: new Date(),
},
});
// 高危事件告警(对接你的告警系统)
if (event.type === 'suspicious_activity') {
// 发送告警到Slack、PagerDuty等
}
}9. Security Checklist
9. 安全检查清单
Before Deployment
部署前
- All routes authenticated with
authenticate.admin() - Webhook HMAC verification implemented
- Input validation on all user inputs (Zod schemas)
- No secrets in code or logs
- Environment variables validated at startup
- Rate limiting on API endpoints
- Security headers configured
- CSP configured for embedded app
- GDPR webhooks implemented (data_request, redact)
- 所有路由都通过做身份验证
authenticate.admin() - 已实现Webhook HMAC验证
- 所有用户输入都做了验证(Zod schema)
- 代码和日志中没有密钥
- 启动时已验证环境变量
- API端点已配置限流
- 已配置安全响应头
- 嵌入应用已配置CSP
- 已实现GDPR webhook(data_request、redact)
Regular Audits
定期审计
- Dependency audit:
npm audit - Check for outdated packages:
npm outdated - Review access logs for anomalies
- Test webhook verification
- Verify encryption keys are rotated periodically
- Review and remove unused API scopes
- 依赖审计:
npm audit - 检查过时依赖:
npm outdated - 检查访问日志是否有异常
- 测试Webhook验证逻辑
- 确认加密密钥定期轮换
- 审核并移除未使用的API权限
Shopify-Specific
Shopify专属检查
- Only request necessary API scopes
- Implement app proxy authentication if used
- Handle webhook to clean up data
app/uninstalled - Implement session token verification for App Bridge
- Test with Shopify's security review checklist
- 仅申请必要的API权限
- 如使用应用代理已实现对应身份验证
- 已处理webhook清理数据
app/uninstalled - 已为App Bridge实现会话令牌验证
- 已通过Shopify安全检查清单测试
Anti-Patterns to Avoid
需要避免的反模式
- DON'T store access tokens in localStorage or cookies
- DON'T log full request/response bodies
- DON'T use or
eval()constructorFunction() - DON'T trust client-side validation alone
- DON'T hardcode secrets in source code
- DON'T disable HTTPS in production
- DON'T ignore security warnings from
npm audit - DON'T use outdated dependencies with known vulnerabilities
- 不要将访问令牌存储在localStorage或Cookie中
- 不要打印完整的请求/响应体日志
- 不要使用或
eval()构造函数Function() - 不要仅依赖客户端验证
- 不要在代码中硬编码密钥
- 不要在生产环境禁用HTTPS
- 不要忽略的安全警告
npm audit - 不要使用存在已知漏洞的过时依赖