Specification
6. Security

6. Security

6.1 Authentication & Authorisation

Three-layer RBAC on every protected request:

1. Authentication  → is the user logged in? (valid JWT in cookie)
2. Role check      → are they staff or admin?
3. Ownership check → do they own this specific resource?
  • IDOR prevention — UUIDs for all IDs, ownership validated server-side on every request
  • JWT stored in HttpOnly cookie — not accessible via JavaScript
  • middleware.ts guards all /account/* and /checkout/* — redirects to /login if no valid token

6.2 Rate Limiting

EndpointLimitResponse
POST /auth/*/emailpass (login)5 attempts / 15 min / IP429 + Retry-After header
POST /auth/*/reset-password3 attempts / hour / IP429 + Retry-After header
GET /store/downloads/:token10 attempts / min / IP429 + Retry-After header
POST /webhooks/stripeStripe IP whitelist only403 for unknown IPs

Library: express-rate-limit. Account lockout (5 failed logins → 30 min lock) is enforced at the database level via customers.failed_login_attempts + locked_until — not just in memory. This survives server restarts.

6.3 Input Validation

All custom endpoints validate with Zod before any business logic runs:

const acceptInviteSchema = z.object({
  first_name: z.string().min(1).max(50),
  last_name:  z.string().min(1).max(50),
  password:   z.string().min(8).max(100),
})
FieldRule
EmailFormat check + max 255 chars
Password8–100 chars
Names1–50 chars
TokensUUID format (regex validated)
PricesPositive integers only

SQL injection is prevented by MikroORM parameterised queries — no raw SQL strings anywhere in the codebase.

6.4 File Upload Security

Upload pipeline:
  1. Extension check (.zip only)
  2. MIME type check (application/zip)
  3. Filename sanitisation (sanitize-filename library)
  4. Size limit: 500MB max (multer)
  5. Zip bomb check: uncompressed size < 2GB
  6. Store at: products/{productId}/{randomUUID()}.zip
     └── User never controls the storage path
⚠️

The storage key is always server-generated — products/{productId}/{randomUUID()}.zip. A user-supplied filename is sanitised before use as a display name only, never as a storage path.

Malware scanning roadmap:

  • Phase 1: Admin-only uploads — low risk, skip scanning
  • Phase 2: ClamAV on the server
  • Phase 3: VirusTotal API for cloud-based scanning

6.5 Security Headers

Set in next.config.ts:

const securityHeaders = [
  { key: 'X-Frame-Options',           value: 'DENY' },
  { key: 'X-Content-Type-Options',    value: 'nosniff' },
  { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
  { key: 'Referrer-Policy',           value: 'strict-origin-when-cross-origin' },
  {
    key: 'Content-Security-Policy',
    value: [
      "default-src 'self'",
      "script-src 'self' https://js.stripe.com",
      "frame-src https://js.stripe.com",
      `img-src 'self' ${process.env.NEXT_PUBLIC_R2_URL}`,
    ].join('; ')
  },
]
HeaderProtects Against
X-Frame-Options: DENYClickjacking
X-Content-Type-Options: nosniffMIME type sniffing
Strict-Transport-SecurityHTTP downgrade attacks
Content-Security-PolicyXSS, data injection
Referrer-PolicyReferrer leakage
💡

Test with securityheaders.com (opens in a new tab) before public launch. Target A or A+.

6.6 CORS & Bot Protection

// medusa-config.ts
module.exports = defineConfig({
  projectConfig: {
    http: {
      storeCors:  process.env.STORE_CORS,   // https://shop.vespertene.com
      adminCors:  process.env.ADMIN_CORS,   // https://admin.vespertene.com
      authCors:   process.env.AUTH_CORS,    // https://shop.vespertene.com
    }
  }
})
  • Never use wildcard (*) in production CORS config
  • Cloudflare Bot Fight Mode — free, one-click, blocks known bot IPs
  • Phase 2: Cloudflare Turnstile CAPTCHA on login and registration