Security Standards

Security rules and patterns for the Eko codebase. These rules protect against common vulnerabilities and ensure safe handling of user data.


Core Rules

SEC-001: URL Validation (SSRF Prevention)

All external URLs must be validated before fetching to prevent Server-Side Request Forgery (SSRF).

import { verifyAndNormalizeUrl } from '@eko/shared'

// Good: Validate before fetch
async function fetchExternalUrl(url: string) {
  const result = await verifyAndNormalizeUrl(url)
  if (result.policy_decision !== 'allow') {
    throw new Error(`URL blocked: ${result.reason_code}`)
  }

  return fetch(result.final_url)
}

// Bad: Direct fetch without validation
async function fetchExternalUrl(url: string) {
  return fetch(url)  // SSRF risk!
}

Blocked URL patterns:

  • Internal IPs: 127.*, 10.*, 192.168.*, 172.16-31.*, 169.254.*
  • Localhost: localhost, 0.0.0.0
  • Cloud metadata: 169.254.169.254, metadata.google.internal
  • Non-HTTP protocols: file://, ftp://, gopher://

Validation function: packages/shared/src/url-verification.ts


SEC-002: Service Role Key Restrictions

The SUPABASE_SERVICE_ROLE_KEY bypasses Row Level Security and must be strictly controlled.

// ALLOWED: Worker processes (server-side only)
// apps/worker-ingest/src/index.ts
import { createServiceClient } from '@eko/db'
const supabase = createServiceClient() // Uses service role

// ALLOWED: Server actions with explicit auth checks
// apps/web/app/api/admin/route.ts
export async function POST(request: Request) {
  const user = await requireAuth(request)
  if (user.role !== 'admin') {
    return errorResponse(403, 'FORBIDDEN', 'Admin access required')
  }
  // Service role operations...
}

// FORBIDDEN: Client components
// apps/web/components/Dashboard.tsx
const supabase = createServiceClient() // NEVER in client code!

Allowed locations:

  • apps/worker-*/** — Worker processes
  • apps/web/app/api/** — API routes with auth checks
  • scripts/** — Admin scripts

Forbidden locations:

  • apps/web/components/** — Client components
  • apps/web/app/(dashboard)/** — Client pages
  • packages/ui/** — UI components

SEC-003: Secrets in Logs

Never log sensitive data that could be exposed in log aggregation.

// Bad: Logging full request with headers
logger.info('API request', { request })  // May contain auth headers!

// Bad: Logging credentials
logger.debug('Auth attempt', { email, password })  // NEVER log passwords!

// Good: Log only safe fields
logger.info('API request', {
  method: request.method,
  path: request.url,
  userId: user?.id,
})

// Good: Redact sensitive data
logger.info('External API call', {
  url: externalUrl,
  responseStatus: response.status,
  // headers intentionally omitted
})

Never log:

  • API keys and tokens
  • User passwords
  • Session tokens
  • Full request/response bodies with auth headers
  • Credit card numbers
  • Personal identification numbers

Safe to log:

  • User IDs (not emails unless necessary)
  • Request paths and methods
  • Response status codes
  • Timing information
  • Error codes (not full stack traces in production)

SEC-004: RLS Testing

Every table with Row Level Security (RLS) must have corresponding security tests.

// Example RLS test for card_interactions (user-scoped table)
import { describe, expect, it } from 'vitest'
import { createTestClient, seedTestUsers } from '../helpers'

describe('CardInteractions RLS', () => {
  const { userA, userB } = seedTestUsers()

  describe('SELECT policy', () => {
    it('should allow user to read own interactions', async () => {
      const client = createTestClient(userA.id)
      const { data } = await client
        .from('card_interactions')
        .select()
        .eq('user_id', userA.id)

      expect(data).toHaveLength(userA.interactionCount)
    })

    it('should NOT allow user to read other user interactions', async () => {
      const client = createTestClient(userA.id)
      const { data } = await client
        .from('card_interactions')
        .select()
        .eq('user_id', userB.id)

      expect(data).toHaveLength(0)  // RLS blocks access
    })
  })

  describe('INSERT policy', () => {
    it('should allow user to insert own interactions', async () => {
      // ...
    })

    it('should NOT allow inserting interactions for other users', async () => {
      // ...
    })
  })

  describe('DELETE policy', () => {
    it('should allow user to delete own interactions', async () => {
      // ...
    })

    it('should NOT allow deleting other user interactions', async () => {
      // ...
    })
  })
})

Test requirements:

  • Test all CRUD operations (SELECT, INSERT, UPDATE, DELETE)
  • Test with correct user (should succeed)
  • Test with wrong user (should fail/return empty)
  • Test without authentication (should fail)
  • Test edge cases (soft deletes, shared resources)

Location: packages/db/src/__tests__/security/


SEC-005: Input Validation

All user inputs must be validated with Zod schemas before use.

import { z } from 'zod'

// Define schema
const SeedEntrySchema = z.object({
  title: z.string().min(1).max(500),
  topicSlug: z.string().min(1),
  sourceUrl: z.string().url().max(2048).optional(),
})

// Validate in API route
export async function POST(request: Request) {
  const body = await request.json()

  const result = SeedEntrySchema.safeParse(body)
  if (!result.success) {
    return errorResponse(400, 'INVALID_INPUT', result.error.message)
  }

  const { title, topicSlug, sourceUrl } = result.data
  // Now safe to use...
}

Validation points:

  • API request bodies
  • URL parameters
  • Form submissions
  • Webhook payloads
  • Queue message data

Validation rules:

  • Use Zod for all validation
  • Define schemas in packages/shared/src/schemas.ts
  • Validate early (at API boundary)
  • Return 400 with clear error message on failure
  • Never trust client-side validation alone

SEC-006: Challenge URL Answer Leak Prevention

Challenge URLs visible in the browser address bar, shared links, or link previews must never contain semantic information that could reveal challenge answers.

Current architecture: Challenges are embedded in card detail pages at /card/[slug] where the slug is an opaque UUID. Challenge content is loaded server-side for authenticated users only. This is the safe pattern.

Safe URL patterns:

  • /card/[uuid] — opaque fact record ID
  • /feed?topic=music — broad category filtering, not answer-specific

Unsafe URL patterns (must never be reintroduced):

  • /challenges/[format]/[topicSlug] — topic slug reveals the answer subject
  • Any route with entity names, topic leaf slugs, or answer text as URL segments
  • OpenGraph/meta tags that include challenge answers or target fact values

API responses must not include in URLs or query params:

  • topic.slug or topic.path when they identify challenge targets
  • Entity names that are challenge answers
  • correctAnswer values in any URL-accessible location

Enforcement: INV-008 invariant check


Security Checklist for New Features

When adding new features, check:

  • Authentication: Does this endpoint require auth?
  • Authorization: Does the user have permission for this action?
  • Input validation: Are all inputs validated with Zod?
  • SQL injection: Using parameterized queries (Drizzle/Supabase)?
  • XSS: User content properly escaped in UI?
  • SSRF: External URLs validated before fetch?
  • RLS: New tables have appropriate policies?
  • Secrets: No sensitive data logged?
  • Challenge URLs: No answer data leaked in URLs or metadata?
  • Rate limiting: Endpoint protected against abuse?

Secret Scanning

The repository uses gitleaks for pre-commit secret scanning.

# Manual scan
gitleaks detect --config .gitleaks.toml

# Scan staged files
gitleaks protect --staged --config .gitleaks.toml

Configuration: .gitleaks.toml

Detected secrets:

  • Supabase keys (service role, anon)
  • Upstash Redis tokens
  • OpenAI/Anthropic API keys
  • Stripe API keys
  • Resend API keys

False positive handling:

  1. Add pattern to .gitleaks.toml allowlist
  2. Use gitleaks:allow comment on the line