security-hardening

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Security 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
    app/uninstalled
    webhook to clean up data
  • Implement session token verification for App Bridge
  • Test with Shopify's security review checklist
  • 仅申请必要的API权限
  • 如使用应用代理已实现对应身份验证
  • 已处理
    app/uninstalled
    webhook清理数据
  • 已为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
    eval()
    or
    Function()
    constructor
  • 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
    的安全警告
  • 不要使用存在已知漏洞的过时依赖