Skip to content

Instantly share code, notes, and snippets.

@devinschumacher
Created June 4, 2025 14:32
Show Gist options
  • Save devinschumacher/2166f20639854603b348a47f5add393c to your computer and use it in GitHub Desktop.
Save devinschumacher/2166f20639854603b348a47f5add393c to your computer and use it in GitHub Desktop.
Why you need Zod even if you have Typescript & Drizzle setup in your app

Why You Need TypeScript + Zod: A Real Example from Your Project

Here's a critical vulnerability in the "entity submission endpoint" of a real nuxt project that demonstrates why TypeScript alone isn't enough - you need runtime validation with Zod.

The Problem

Here's what's happening in your /api/entity/submit.post.ts endpoint:

1. Type File (packages/types/types/Entity.ts)

export interface BaseEntity {
  id: number
  name: string
  slug: string
  module: string
  categories?: Category[]
  topics?: Topic[]
  // ... other fields
}

2. Drizzle Schema (packages/db/server/database/schema/submit-form.ts)

export const submitForm = userSchema.table('submit_form', {
  formData: jsonb('form_data').notNull(),
  identifier: varchar('identifier', { length: 255 }).notNull(),
  module: varchar('module', { length: 255 }).notNull(),
  // ... other fields
})

3. API Route (packages/api/server/api/entity/submit.post.ts)

const data = await readBody(event) // ❌ No validation!

// Manual validation scattered throughout
if (data.categories) {
  if (!Array.isArray(data.categories)) {
    return { status: 400, message: 'Categories must be an array' }
  }
}

// Direct database insertion with unvalidated data
await getDb()
  .insert(submitForm)
  .values({
    formData: JSON.stringify(data), // ❌ Whatever the user sends!
    identifier: data.slug || data.domain,
    // ...
  })

4. Frontend (packages/ui/components/SPage/Users/Submit/Company.vue)

// Frontend has Zod validation ✅
const schema = z.object({
  name: z.string().min(1, "Name is required"),
  domain: z.string().regex(/^[\w.-]+\.[a-z]{2,}$/i, "Enter a valid domain"),
  pricing: z.array(z.string()).min(1, "Pick at least one pricing option"),
  // ...
})

// But API can be called directly, bypassing frontend validation!

The Security Issues

  1. SQL Injection via JSONB - Malformed JSON could break queries
  2. XSS Attacks - Unvalidated HTML/scripts stored in database
  3. Data Corruption - Invalid data types crash the application
  4. Domain Spoofing - No validation on domain format
  5. Array Injection - Categories/topics could contain invalid IDs

The Solution: Add Zod to Your API

Create a shared validation schema:

packages/db/validations/entity.ts

import { z } from 'zod'

export const entitySubmitSchema = z.object({
  name: z.string().min(1).max(255),
  domain: z.string()
    .regex(/^[\w.-]+\.[a-z]{2,}$/i)
    .transform(val => val.toLowerCase()),
  slug: z.string().optional(),
  pricing: z.array(z.enum(['Free', 'Paid', 'Subscription', 'Free Trial'])),
  tags: z.union([
    z.string().transform(val => val.split(',').map(t => t.trim())),
    z.array(z.string())
  ]),
  oneLiner: z.string().min(1).max(75),
  description: z.string().min(1).max(5000),
  categories: z.array(z.number().int().positive()),
  topics: z.array(z.number().int().positive()).optional(),
  logo: z.string().url().optional().nullable(),
  uuid: z.string().uuid(),
})

export type EntitySubmitInput = z.infer<typeof entitySubmitSchema>

Update your API endpoint:

// packages/api/server/api/entity/submit.post.ts
import { entitySubmitSchema } from '../../../../db/validations/entity'
import { validateBody } from '../../../../utils/server/utils/bodyValidation'

export default defineEventHandler(async (event) => {
  try {
    const session = await requireUserSession(event)
    if (!session?.user?.id) {
      throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
    }

    // ✅ Validate and transform data
    const validatedData = await validateBody(event, entitySubmitSchema)

    // ✅ Type-safe from here on
    const { categories, topics, domain, tags } = validatedData

    // Now TypeScript knows exact types AND runtime validates them!
  } catch (error) {
    if (error instanceof z.ZodError) {
      throw createError({
        statusCode: 400,
        statusMessage: `Validation error: ${error.errors[0].message}`
      })
    }
    throw error
  }
})

Why This Matters

Without Zod, a malicious user could:

# Send invalid data directly to your API
curl -X POST https://yoursite.com/api/entity/submit \
  -H "Content-Type: application/json" \
  -d '{
    "name": "<script>alert(\"XSS\")</script>",
    "domain": "not-a-domain",
    "categories": "not-an-array",
    "tags": {"malicious": "object"},
    "description": null
  }'

With Zod, this request would be rejected with clear error messages before touching your database.

Benefits You Get

  1. Runtime Safety - Catches errors TypeScript can't
  2. Data Transformation - Normalize domains, parse tags
  3. Better Error Messages - Users get helpful validation feedback
  4. Single Source of Truth - Share schemas between frontend/backend
  5. Type Inference - Generate TypeScript types from schemas
  6. Security - Prevent injection attacks and data corruption

Your codebase already uses Zod in auth endpoints - extending this pattern to all user input endpoints would significantly improve security and reliability.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment