Aller au contenu principal

Security

Multi-tenant isolation, CSP nonce, rate limiting, encryption, IDOR protection.

Contents

Overview

HeartCo implements defense in depth with several layers of protection:

  1. Multi-tenant isolation — Each organization only sees its own data
  2. RBAC — Granular per-role permissions
  3. CSP — Content Security Policy with a per-request nonce
  4. Rate limiting — Brute-force protection
  5. Encryption — OAuth tokens encrypted at rest
  6. Security headers — HSTS, X-Frame-Options, etc.
  7. HMAC — Webhook validation

Multi-tenant isolation

Principle

Every piece of data belongs to an organization through the organizationId field. Access is controlled at two levels:

  1. Reads: ctx.orgDb automatically injects organizationId into queries
  2. Writes: organizationId must be explicitly added to every update/delete

Mandatory rules

// ✅ MANDATORY — organizationId in the where for update/delete
await ctx.db.invoice.update({
  where: { id: input.id, organizationId: ctx.session.user.organizationId },
  data: { status: "PAID" },
});
 
// ❌ IDOR VULNERABILITY — no organizationId
await ctx.db.invoice.update({
  where: { id: input.id },
  data: { status: "PAID" },
});

Sub-entities

For models without a direct organizationId (invoice lines, etc.), filter through the parent:

const line = await ctx.db.invoiceLine.findFirst({
  where: {
    id: input.lineId,
    invoice: { organizationId: ctx.session.user.organizationId },
  },
});

findFirst vs findUnique

Always use findFirst when filtering by organizationId + id:

// ✅ findFirst — supports composite filters
await ctx.orgDb.invoice.findFirst({ where: { id: input.id } });
 
// ❌ findUnique — does NOT filter by organizationId
await ctx.db.invoice.findUnique({ where: { id: input.id } });

File: src/lib/prisma-org-scope.ts — 125 auto-scoped models.

IDOR protection

Access errors must return NOT_FOUND (not FORBIDDEN) to avoid leaking information:

const invoice = await ctx.orgDb.invoice.findFirst({
  where: { id: input.id },
});
 
if (!invoice) {
  // ✅ NOT_FOUND — the attacker can't tell whether the resource exists
  throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" });
}
 
// ❌ FORBIDDEN — reveals that the resource exists but belongs to someone else
// throw new TRPCError({ code: "FORBIDDEN" });

assertOwnership

For resources with createdById, use the helper:

import { assertOwnership } from "~/server/api/trpc";
 
// Checks that ctx.session.user.id === resource.createdById
// Automatic bypass for roles ≥ MANAGER
assertOwnership(ctx, resource.createdById, "MANAGER");

Content Security Policy (CSP)

File: src/middleware.ts

A unique nonce is generated for each request via crypto.randomUUID():

const nonce = crypto.randomUUID();
// Injected into the CSP header and available to inline scripts

CSP directives

DirectiveValue
default-src'self'
script-src'self' + nonce (no unsafe-inline)
style-src'self' 'unsafe-inline' (required by Tailwind v4)
img-src'self' data: https: blob:
connect-srcStripe, Supabase, Pusher, Sentry, Mistral AI
frame-srcStripe (checkout iframe)
worker-src'self' blob:

In development, unsafe-eval is added for webpack hot module replacement (HMR).

Rate limiting

File: src/lib/rate-limit.ts

Backend: Upstash Redis

Distributed rate limiting with a sliding window:

import { checkRateLimit } from "~/lib/rate-limit";
 
// 10 requests max per IP in 15 minutes
const allowed = await checkRateLimit(
  `login:${ip}`,
  10,
  15 * 60 * 1000
);

In-memory fallback

If Redis is not configured (local development), an in-memory counter is used.

Rate-limiting points

EndpointLimitWindow
Login (Credentials)10 requests/IP15 minutes
Chat (Chatbot)10 messages/user1 minute
Demo modeVariable/user1 hour

Token encryption

Variable: TOKEN_ENCRYPTION_KEY (64 hex characters = 32 bytes)

OAuth tokens (Google, Microsoft) are encrypted with AES-256-GCM before being stored in the database:

// Encryption
const encrypted = encryptToken(googleAccessToken);
await db.emailAccount.update({ data: { accessToken: encrypted } });
 
// Decryption (automatic on refresh)
const decrypted = decryptToken(emailAccount.accessToken);

Generate a key: openssl rand -hex 32

Security headers

File: next.config.js

HeaderValueProtection
X-Frame-OptionsDENYClickjacking
X-Content-Type-OptionsnosniffMIME sniffing
X-XSS-Protection0Disabled (CSP is enough)
Referrer-Policystrict-origin-when-cross-originReferrer leakage
Permissions-Policycamera, microphone, geolocation disabledBrowser APIs
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadForced HTTPS (2 years)

Webhook validation (HMAC)

Incoming webhooks must be validated with HMAC-SHA256 and timingSafeEqual:

import crypto from "crypto";
 
function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const computed = crypto
    .createHmac("sha256", secret)
    .update(payload)
    .digest("hex");
 
  // ✅ timingSafeEqual — resistant to timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(computed, "hex"),
    Buffer.from(signature, "hex")
  );
 
  // ❌ FORBIDDEN — vulnerable to timing attacks
  // return computed === signature;
}

CVE-2025-29927 protection

File: src/middleware.ts

Protection against the Next.js middleware bypass via the x-middleware-subrequest header:

// Blocks requests carrying this header (returns 400)
if (request.headers.get("x-middleware-subrequest")) {
  return new Response("Forbidden", { status: 400 });
}

Maintenance mode

Variable: MAINTENANCE_MODE=true

When enabled:

  • All pages redirect to /maintenance
  • Exceptions: Stripe/Iopole webhooks, health API, crons, static assets
  • Bypass: query param or maintenance-bypass cookie with MAINTENANCE_BYPASS_SECRET

Audit trail

The AuditLog model records sensitive actions:

  • Who (userId, role)
  • What (action, resource, resourceId)
  • When (timestamp)
  • Context (IP, user agent)

Permission denials are automatically logged by the requirePermission() middleware.

tRPC error handling

Always use TRPCError, never throw new Error():

CodeUsage
UNAUTHORIZEDNo session
FORBIDDENNot allowed (missing permission)
NOT_FOUNDMissing resource or IDOR
BAD_REQUESTInvalid input
TOO_MANY_REQUESTSRate limit reached
throw new TRPCError({
  code: "NOT_FOUND",
  message: "Invoice not found",
});

Security checklist

Before each merge, verify:

  • organizationId in every update/delete
  • findFirst (not findUnique) for scoped resources
  • TRPCError (not throw new Error)
  • NOT_FOUND for IDOR (not FORBIDDEN)
  • requirePermission() on internal routes
  • timingSafeEqual for webhooks
  • No any — use unknown + type guards
  • No secrets in the code (use env vars)

◀ Customization · Contents · Deployment ▶