first commit
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
# sf-input-validation: Always Validate Server Function Inputs
|
||||
|
||||
## Priority: CRITICAL
|
||||
|
||||
## Explanation
|
||||
|
||||
Server functions receive data across the network boundary. Always validate inputs before processing - never trust client data. Use schema validation libraries like Zod for type-safe validation.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// No validation - trusting client input directly
|
||||
export const updateUser = createServerFn({ method: 'POST' })
|
||||
.handler(async ({ data }) => {
|
||||
// data is unknown/any - no type safety
|
||||
// SQL injection, invalid data, type errors all possible
|
||||
await db.users.update({
|
||||
where: { id: data.id },
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
role: data.role, // Could be set to 'admin' by malicious client!
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Weak validation - type assertion without runtime check
|
||||
export const deletePost = createServerFn({ method: 'POST' })
|
||||
.handler(async ({ data }: { data: { id: string } }) => {
|
||||
// Type assertion doesn't validate at runtime
|
||||
await db.posts.delete({ where: { id: data.id } })
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: With Zod Validation
|
||||
|
||||
```tsx
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { z } from 'zod'
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
// Don't allow role updates from client input!
|
||||
})
|
||||
|
||||
export const updateUser = createServerFn({ method: 'POST' })
|
||||
.validator(updateUserSchema)
|
||||
.handler(async ({ data }) => {
|
||||
// data is fully typed: { id: string; name: string; email: string }
|
||||
const user = await db.users.update({
|
||||
where: { id: data.id },
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
},
|
||||
})
|
||||
return user
|
||||
})
|
||||
|
||||
// Validation errors are automatically returned to client
|
||||
// with proper status codes and messages
|
||||
```
|
||||
|
||||
## Good Example: Complex Validation
|
||||
|
||||
```tsx
|
||||
const createOrderSchema = z.object({
|
||||
items: z.array(z.object({
|
||||
productId: z.string().uuid(),
|
||||
quantity: z.number().int().min(1).max(100),
|
||||
})).min(1).max(50),
|
||||
shippingAddress: z.object({
|
||||
street: z.string().min(1),
|
||||
city: z.string().min(1),
|
||||
state: z.string().length(2),
|
||||
zip: z.string().regex(/^\d{5}(-\d{4})?$/),
|
||||
}),
|
||||
couponCode: z.string().optional(),
|
||||
})
|
||||
|
||||
export const createOrder = createServerFn({ method: 'POST' })
|
||||
.validator(createOrderSchema)
|
||||
.handler(async ({ data }) => {
|
||||
// All data is validated and typed
|
||||
// Process order safely
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Transform and Refine
|
||||
|
||||
```tsx
|
||||
const registrationSchema = z.object({
|
||||
email: z.string().email().toLowerCase(), // Transform to lowercase
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain uppercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain number'),
|
||||
confirmPassword: z.string(),
|
||||
}).refine(
|
||||
(data) => data.password === data.confirmPassword,
|
||||
{ message: 'Passwords must match', path: ['confirmPassword'] }
|
||||
)
|
||||
|
||||
export const register = createServerFn({ method: 'POST' })
|
||||
.validator(registrationSchema)
|
||||
.handler(async ({ data }) => {
|
||||
// Passwords match, email is lowercase
|
||||
// Only password needed (confirmPassword was for validation)
|
||||
const hashedPassword = await hashPassword(data.password)
|
||||
return await createUser({
|
||||
email: data.email,
|
||||
password: hashedPassword,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Sharing Schemas Between Client and Server
|
||||
|
||||
```tsx
|
||||
// lib/schemas/post.ts - Shared validation schema
|
||||
import { z } from 'zod'
|
||||
|
||||
export const createPostSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
content: z.string().min(1),
|
||||
tags: z.array(z.string()).max(10).optional(),
|
||||
})
|
||||
|
||||
export type CreatePostInput = z.infer<typeof createPostSchema>
|
||||
|
||||
// lib/posts.functions.ts - Server function
|
||||
import { createPostSchema } from './schemas/post'
|
||||
|
||||
export const createPost = createServerFn({ method: 'POST' })
|
||||
.validator(createPostSchema)
|
||||
.handler(async ({ data }) => { /* ... */ })
|
||||
|
||||
// components/CreatePostForm.tsx - Client form validation
|
||||
import { createPostSchema, type CreatePostInput } from '@/lib/schemas/post'
|
||||
|
||||
function CreatePostForm() {
|
||||
const form = useForm<CreatePostInput>({
|
||||
resolver: zodResolver(createPostSchema),
|
||||
})
|
||||
// Same validation client and server side
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Network boundary = trust boundary - always validate
|
||||
- Use `.validator()` before `.handler()` in the chain
|
||||
- Validation errors return proper HTTP status codes
|
||||
- Share schemas between client forms and server functions
|
||||
- Strip or ignore fields clients shouldn't control (like `role`, `isAdmin`)
|
||||
- Consider rate limiting for mutation endpoints
|
||||
Reference in New Issue
Block a user