Fix security issues: path traversal, typed errors, file validation
- Fix path traversal in file serve endpoint (validate company prefix, block ..) - Add typed error classes: ValidationError, NotFoundError, ForbiddenError, ConflictError, StorageError - Global error handler catches AppError subclasses with correct status codes - 4xx logged as warn, 5xx as error with request ID - File upload validates entityType whitelist, UUID format, category pattern - Remove fragile string-matching error handling from routes - Services throw typed errors instead of plain Error - Health endpoint documented as intentionally public
This commit is contained in:
@@ -3,6 +3,7 @@ import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import { files } from '../db/schema/files.js'
|
||||
import type { StorageProvider } from '../storage/index.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { ValidationError } from '../lib/errors.js'
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||
const ALLOWED_PDF_TYPES = ['application/pdf']
|
||||
@@ -38,13 +39,13 @@ export const FileService = {
|
||||
) {
|
||||
// Validate content type
|
||||
if (!ALLOWED_TYPES.includes(input.contentType)) {
|
||||
throw new Error(`File type not allowed: ${input.contentType}`)
|
||||
throw new ValidationError(`File type not allowed: ${input.contentType}`)
|
||||
}
|
||||
|
||||
// Validate size
|
||||
const maxSize = ALLOWED_IMAGE_TYPES.includes(input.contentType) ? MAX_IMAGE_SIZE : MAX_PDF_SIZE
|
||||
if (input.data.length > maxSize) {
|
||||
throw new Error(`File too large: ${input.data.length} bytes (max ${maxSize})`)
|
||||
throw new ValidationError(`File too large: ${input.data.length} bytes (max ${maxSize})`)
|
||||
}
|
||||
|
||||
// Check per-entity limit
|
||||
@@ -59,7 +60,7 @@ export const FileService = {
|
||||
),
|
||||
)
|
||||
if (existing.total >= MAX_FILES_PER_ENTITY) {
|
||||
throw new Error(`Maximum ${MAX_FILES_PER_ENTITY} files per entity`)
|
||||
throw new ValidationError(`Maximum ${MAX_FILES_PER_ENTITY} files per entity`)
|
||||
}
|
||||
|
||||
// Generate path
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import { ForbiddenError } from '../lib/errors.js'
|
||||
import {
|
||||
inventoryUnitStatuses,
|
||||
itemConditions,
|
||||
@@ -73,7 +74,7 @@ function createLookupService(
|
||||
|
||||
if (!existing[0]) return null
|
||||
if (existing[0].isSystem && input.isActive === false) {
|
||||
throw new Error('Cannot deactivate a system status')
|
||||
throw new ForbiddenError('Cannot deactivate a system status')
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
@@ -93,7 +94,7 @@ function createLookupService(
|
||||
|
||||
if (!existing[0]) return null
|
||||
if (existing[0].isSystem) {
|
||||
throw new Error('Cannot delete a system status')
|
||||
throw new ForbiddenError('Cannot delete a system status')
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { eq, and, count } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import { products, inventoryUnits, priceHistory } from '../db/schema/inventory.js'
|
||||
import { ValidationError } from '../lib/errors.js'
|
||||
import type {
|
||||
ProductCreateInput,
|
||||
ProductUpdateInput,
|
||||
@@ -119,11 +120,11 @@ export const InventoryUnitService = {
|
||||
async create(db: PostgresJsDatabase, companyId: string, input: InventoryUnitCreateInput) {
|
||||
if (input.condition) {
|
||||
const valid = await ItemConditionService.validateSlug(db, companyId, input.condition)
|
||||
if (!valid) throw new Error(`Invalid condition: "${input.condition}"`)
|
||||
if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`)
|
||||
}
|
||||
if (input.status) {
|
||||
const valid = await UnitStatusService.validateSlug(db, companyId, input.status)
|
||||
if (!valid) throw new Error(`Invalid status: "${input.status}"`)
|
||||
if (!valid) throw new ValidationError(`Invalid status: "${input.status}"`)
|
||||
}
|
||||
|
||||
const [unit] = await db
|
||||
@@ -190,11 +191,11 @@ export const InventoryUnitService = {
|
||||
) {
|
||||
if (input.condition) {
|
||||
const valid = await ItemConditionService.validateSlug(db, companyId, input.condition)
|
||||
if (!valid) throw new Error(`Invalid condition: "${input.condition}"`)
|
||||
if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`)
|
||||
}
|
||||
if (input.status) {
|
||||
const valid = await UnitStatusService.validateSlug(db, companyId, input.status)
|
||||
if (!valid) throw new Error(`Invalid status: "${input.status}"`)
|
||||
if (!valid) throw new ValidationError(`Invalid status: "${input.status}"`)
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { ...input }
|
||||
|
||||
Reference in New Issue
Block a user