cropintel / lib /security /validation.ts
Jaithra Polavarapu
feat: add Firebase auth/farms backend alongside existing frontend
fb030a8
Raw
History Blame Contribute Delete
7.55 kB
/**
* Input Validation Schemas
*
* Implements strict input validation and sanitization following OWASP best practices.
* Uses Zod for schema-based validation with strong type checking.
*
* Security Controls:
* - Schema-based validation (rejects unexpected fields)
* - Strong type checking
* - Maximum and minimum length limits
* - Sanitization to prevent injection attacks
* - Path traversal prevention
*
* OWASP Compliance:
* - Prevents injection attacks (A03:2021)
* - Implements "Secure Defaults" with strict validation
* - Follows "Fail Securely" by rejecting invalid input
*/
import { z } from 'zod'
/**
* Valid crop types (whitelist approach)
* Prevents injection attacks by only allowing known values
*/
const VALID_CROPS = ['corn', 'rice', 'soybean', 'wheat', 'tomato'] as const
/**
* Crop type schema
* Validates crop parameter with strict whitelist
*/
export const cropSchema = z.enum(VALID_CROPS, {
message: 'Invalid crop type. Must be one of: corn, rice, soybean, wheat, tomato',
})
/**
* File upload validation schema
* Validates image file uploads with security constraints
*
* Security Measures:
* - Maximum file size: 10MB (prevents DoS attacks)
* - MIME type validation: images only
* - Filename sanitization: prevents path traversal
*/
export const imageUploadSchema = z.object({
/**
* Image file validation
* - Must be a File object
* - Must be an image type
* - Maximum size: 10MB (10 * 1024 * 1024 bytes)
*/
image: z
.instanceof(File, { message: 'Image file is required' })
.refine((file) => file.size > 0, { message: 'Image file cannot be empty' })
.refine(
(file) => file.size <= 10 * 1024 * 1024,
{ message: 'Image file size must be less than 10MB' }
)
.refine(
(file) => file.type.startsWith('image/'),
{ message: 'File must be an image (JPEG, PNG, etc.)' }
),
/**
* Crop type validation
* Uses whitelist to prevent injection attacks
*/
crop: cropSchema,
})
/**
* Prediction request schema
* Validates the entire prediction API request
*
* Security: Rejects any extra fields (strict mode)
*/
export const predictionRequestSchema = imageUploadSchema.strict()
/**
* Sanitize filename to prevent path traversal attacks
*
* Security Measures:
* - Removes directory separators (/, \)
* - Removes null bytes
* - Limits filename length
* - Removes dangerous characters
*
* @param filename - Original filename
* @returns Sanitized filename safe for file system operations
*
* OWASP: Prevents A01:2021 (Broken Access Control) via path traversal
*/
export function sanitizeFilename(filename: string): string {
// Remove directory separators and null bytes (path traversal prevention)
let sanitized = filename
.replace(/[\/\\]/g, '') // Remove / and \
.replace(/\0/g, '') // Remove null bytes
.replace(/\.\./g, '') // Remove .. (double dot)
.trim()
// Limit filename length (prevent buffer overflow)
const MAX_FILENAME_LENGTH = 255
if (sanitized.length > MAX_FILENAME_LENGTH) {
const ext = sanitized.substring(sanitized.lastIndexOf('.'))
sanitized = sanitized.substring(0, MAX_FILENAME_LENGTH - ext.length) + ext
}
// Remove any remaining dangerous characters
sanitized = sanitized.replace(/[<>:"|?*\x00-\x1f]/g, '')
// Ensure filename is not empty
if (!sanitized || sanitized === '.') {
sanitized = `file-${Date.now()}`
}
return sanitized
}
/**
* Validate and sanitize crop parameter
*
* @param crop - Crop type string from user input
* @returns Validated and sanitized crop type
* @throws ZodError if validation fails
*/
export function validateCrop(crop: unknown): z.infer<typeof cropSchema> {
return cropSchema.parse(crop)
}
/**
* Validate prediction request
*
* @param formData - FormData from request
* @returns Validated and sanitized request data
* @throws ZodError if validation fails
*/
export async function validatePredictionRequest(
formData: FormData
): Promise<{ image: File; crop: z.infer<typeof cropSchema> }> {
const image = formData.get('image')
const crop = formData.get('crop')
// Validate using schema (will throw if invalid)
const validated = predictionRequestSchema.parse({
image,
crop,
})
// Additional security: Sanitize filename
const sanitizedFilename = sanitizeFilename(validated.image.name)
// Create new File object with sanitized name
// (File objects are immutable, so we create a new one)
const sanitizedFile = new File(
[validated.image],
sanitizedFilename,
{ type: validated.image.type }
)
return {
image: sanitizedFile,
crop: validated.crop,
}
}
/**
* Location validation schema
* Validates geographic coordinates for outbreak reporting
*
* Security: Prevents injection via coordinate values
*/
export const locationSchema = z.object({
lat: z.number().min(-90).max(90), // Valid latitude range
lng: z.number().min(-180).max(180), // Valid longitude range
})
/**
* Outbreak report validation schema
* Validates outbreak report data
*/
export const outbreakReportSchema = z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
crop: cropSchema,
disease: z.string().min(1).max(200), // Reasonable length limits
severity: z.enum(['low', 'medium', 'high']),
description: z.string().max(1000).optional(), // Optional description with length limit
}).strict()
/**
* Farmer registration validation schema
* Validates farmer registration data
*/
export const farmerRegistrationSchema = z.object({
name: z.string().min(1).max(200), // Name length limits
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
crops: z.array(cropSchema).min(1).max(10), // At least 1 crop, max 10
}).strict()
/**
* Farms/auth schemas (ported from the GitHub app's Firebase backend).
*/
const VALID_US_STATE_CODES = [
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY',
] as const
export const usStateCodeSchema = z.enum(VALID_US_STATE_CODES, {
message: 'Invalid state code.',
})
const trimmedString = (min: number, max: number, label: string) =>
z.string()
.transform((value) => value.trim())
.pipe(z.string().min(min, `${label} is required`).max(max, `${label} is too long`))
export const displayNameSchema = trimmedString(1, 100, 'Name')
export const createFarmSchema = z.object({
name: trimmedString(1, 120, 'Farm name'),
address: trimmedString(1, 240, 'Address'),
stateCode: usStateCodeSchema,
crops: z.array(cropSchema).min(1, 'Select at least one crop').max(5, 'Too many crops selected'),
acreage: z.number().min(0).max(1_000_000).nullable().optional(),
lat: z.number().min(-90).max(90).nullable().optional(),
lng: z.number().min(-180).max(180).nullable().optional(),
}).strict()
export const joinCodeSchema = z.string()
.transform((value) => value.trim().toUpperCase())
.pipe(z.string().regex(/^[A-Z2-9]{6}$/, 'Enter the six-character farm join code.'))
export const farmSearchSchema = z.object({
search: trimmedString(2, 80, 'Farm name or location'),
stateCode: usStateCodeSchema,
}).strict()
export const farmIdSchema = trimmedString(8, 128, 'Farm ID')
export const accessRequestStatusSchema = z.enum(['pending', 'approved', 'denied', 'expired'])