ship-safe 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Input Validation Patterns
3
+ * =========================
4
+ *
5
+ * Validation patterns and utilities for API endpoints.
6
+ *
7
+ * VALIDATION PRINCIPLES:
8
+ * 1. Validate ALL input - query params, body, headers, cookies
9
+ * 2. Whitelist, don't blacklist - define what's allowed
10
+ * 3. Validate type, format, length, and range
11
+ * 4. Fail fast - reject invalid input early
12
+ * 5. Never trust client-side validation alone
13
+ */
14
+
15
+ // =============================================================================
16
+ // ZOD SCHEMAS (RECOMMENDED)
17
+ // =============================================================================
18
+
19
+ import { z } from 'zod';
20
+
21
+ /**
22
+ * Common validation schemas
23
+ */
24
+ export const schemas = {
25
+ // User ID - UUID format
26
+ userId: z.string().uuid('Invalid user ID format'),
27
+
28
+ // Email - standard email format
29
+ email: z.string()
30
+ .email('Invalid email format')
31
+ .max(255, 'Email too long')
32
+ .transform(email => email.toLowerCase().trim()),
33
+
34
+ // Password - secure requirements
35
+ password: z.string()
36
+ .min(8, 'Password must be at least 8 characters')
37
+ .max(128, 'Password too long')
38
+ .regex(/[a-z]/, 'Password must contain a lowercase letter')
39
+ .regex(/[A-Z]/, 'Password must contain an uppercase letter')
40
+ .regex(/[0-9]/, 'Password must contain a number'),
41
+
42
+ // Username - alphanumeric with limits
43
+ username: z.string()
44
+ .min(3, 'Username must be at least 3 characters')
45
+ .max(30, 'Username too long')
46
+ .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and hyphens'),
47
+
48
+ // URL - valid URL format
49
+ url: z.string()
50
+ .url('Invalid URL format')
51
+ .max(2048, 'URL too long'),
52
+
53
+ // Safe URL - prevents SSRF
54
+ safeUrl: z.string()
55
+ .url('Invalid URL format')
56
+ .max(2048, 'URL too long')
57
+ .refine(
58
+ (url) => {
59
+ try {
60
+ const parsed = new URL(url);
61
+ // Block internal addresses
62
+ const blocked = [
63
+ 'localhost',
64
+ '127.0.0.1',
65
+ '0.0.0.0',
66
+ '169.254.', // Link-local
67
+ '10.', // Private Class A
68
+ '172.16.', // Private Class B
69
+ '192.168.', // Private Class C
70
+ ];
71
+ return !blocked.some(b => parsed.hostname.startsWith(b));
72
+ } catch {
73
+ return false;
74
+ }
75
+ },
76
+ 'URL not allowed'
77
+ ),
78
+
79
+ // Pagination
80
+ pagination: z.object({
81
+ page: z.coerce.number().int().min(1).default(1),
82
+ limit: z.coerce.number().int().min(1).max(100).default(20),
83
+ }),
84
+
85
+ // Sort order
86
+ sortOrder: z.enum(['asc', 'desc']).default('desc'),
87
+
88
+ // Date - ISO format
89
+ isoDate: z.string().datetime('Invalid date format'),
90
+
91
+ // Phone number - E.164 format
92
+ phone: z.string()
93
+ .regex(/^\+[1-9]\d{1,14}$/, 'Invalid phone format (use E.164: +1234567890)'),
94
+
95
+ // Positive integer
96
+ positiveInt: z.coerce.number().int().positive(),
97
+
98
+ // Non-empty string with max length
99
+ nonEmptyString: (maxLength = 1000) =>
100
+ z.string()
101
+ .min(1, 'Field cannot be empty')
102
+ .max(maxLength, `Field too long (max ${maxLength} characters)`)
103
+ .transform(s => s.trim()),
104
+
105
+ // Safe HTML text (strips dangerous content)
106
+ safeText: z.string()
107
+ .max(10000, 'Text too long')
108
+ .transform(text => {
109
+ // Remove script tags and event handlers
110
+ return text
111
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
112
+ .replace(/on\w+="[^"]*"/gi, '')
113
+ .replace(/javascript:/gi, '');
114
+ }),
115
+ };
116
+
117
+ // =============================================================================
118
+ // REQUEST VALIDATION MIDDLEWARE
119
+ // =============================================================================
120
+
121
+ /**
122
+ * Validate request body with Zod schema
123
+ */
124
+ export function validateBody<T>(schema: z.ZodSchema<T>) {
125
+ return async (request: Request): Promise<{ data: T } | { error: string; details: z.ZodError }> => {
126
+ try {
127
+ const body = await request.json();
128
+ const data = schema.parse(body);
129
+ return { data };
130
+ } catch (error) {
131
+ if (error instanceof z.ZodError) {
132
+ return {
133
+ error: 'Validation failed',
134
+ details: error,
135
+ };
136
+ }
137
+ return { error: 'Invalid JSON body', details: error as z.ZodError };
138
+ }
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Validate query parameters with Zod schema
144
+ */
145
+ export function validateQuery<T>(schema: z.ZodSchema<T>) {
146
+ return (url: URL): { data: T } | { error: string; details: z.ZodError } => {
147
+ try {
148
+ const params = Object.fromEntries(url.searchParams.entries());
149
+ const data = schema.parse(params);
150
+ return { data };
151
+ } catch (error) {
152
+ if (error instanceof z.ZodError) {
153
+ return {
154
+ error: 'Validation failed',
155
+ details: error,
156
+ };
157
+ }
158
+ return { error: 'Invalid query parameters', details: error as z.ZodError };
159
+ }
160
+ };
161
+ }
162
+
163
+ // =============================================================================
164
+ // NEXT.JS API ROUTE EXAMPLE
165
+ // =============================================================================
166
+
167
+ export const nextjsValidationExample = `
168
+ // app/api/users/route.ts
169
+ import { NextResponse } from 'next/server';
170
+ import type { NextRequest } from 'next/server';
171
+ import { z } from 'zod';
172
+
173
+ // Define schema for this endpoint
174
+ const createUserSchema = z.object({
175
+ email: z.string().email().max(255).transform(e => e.toLowerCase()),
176
+ password: z.string().min(8).max(128),
177
+ name: z.string().min(1).max(100).trim(),
178
+ });
179
+
180
+ export async function POST(request: NextRequest) {
181
+ try {
182
+ // Parse and validate
183
+ const body = await request.json();
184
+ const result = createUserSchema.safeParse(body);
185
+
186
+ if (!result.success) {
187
+ return NextResponse.json(
188
+ {
189
+ error: 'Validation failed',
190
+ issues: result.error.issues.map(i => ({
191
+ field: i.path.join('.'),
192
+ message: i.message,
193
+ })),
194
+ },
195
+ { status: 400 }
196
+ );
197
+ }
198
+
199
+ const { email, password, name } = result.data;
200
+
201
+ // Now safe to use validated data
202
+ // ... create user logic
203
+
204
+ return NextResponse.json({ success: true });
205
+
206
+ } catch (error) {
207
+ return NextResponse.json(
208
+ { error: 'Invalid request body' },
209
+ { status: 400 }
210
+ );
211
+ }
212
+ }
213
+ `;
214
+
215
+ // =============================================================================
216
+ // EXPRESS VALIDATION EXAMPLE
217
+ // =============================================================================
218
+
219
+ export const expressValidationExample = `
220
+ // Express with Zod validation middleware
221
+ import express from 'express';
222
+ import { z } from 'zod';
223
+
224
+ const app = express();
225
+ app.use(express.json());
226
+
227
+ // Validation middleware factory
228
+ function validate(schema) {
229
+ return (req, res, next) => {
230
+ const result = schema.safeParse({
231
+ body: req.body,
232
+ query: req.query,
233
+ params: req.params,
234
+ });
235
+
236
+ if (!result.success) {
237
+ return res.status(400).json({
238
+ error: 'Validation failed',
239
+ issues: result.error.issues,
240
+ });
241
+ }
242
+
243
+ req.validated = result.data;
244
+ next();
245
+ };
246
+ }
247
+
248
+ // Define route schema
249
+ const updateUserSchema = z.object({
250
+ params: z.object({
251
+ id: z.string().uuid(),
252
+ }),
253
+ body: z.object({
254
+ name: z.string().min(1).max(100).optional(),
255
+ email: z.string().email().optional(),
256
+ }),
257
+ });
258
+
259
+ // Use in route
260
+ app.put('/users/:id', validate(updateUserSchema), (req, res) => {
261
+ const { params, body } = req.validated;
262
+ // Safe to use validated data
263
+ res.json({ success: true });
264
+ });
265
+ `;
266
+
267
+ // =============================================================================
268
+ // FILE UPLOAD VALIDATION
269
+ // =============================================================================
270
+
271
+ export const fileValidation = {
272
+ // Allowed MIME types by category
273
+ mimeTypes: {
274
+ images: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
275
+ documents: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
276
+ spreadsheets: ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
277
+ },
278
+
279
+ // Validate file upload
280
+ validateFile: (
281
+ file: { type: string; size: number; name: string },
282
+ options: {
283
+ allowedTypes: string[];
284
+ maxSizeBytes: number;
285
+ allowedExtensions?: string[];
286
+ }
287
+ ) => {
288
+ const errors: string[] = [];
289
+
290
+ // Check MIME type
291
+ if (!options.allowedTypes.includes(file.type)) {
292
+ errors.push(`File type ${file.type} not allowed`);
293
+ }
294
+
295
+ // Check size
296
+ if (file.size > options.maxSizeBytes) {
297
+ const maxMB = options.maxSizeBytes / (1024 * 1024);
298
+ errors.push(`File too large (max ${maxMB}MB)`);
299
+ }
300
+
301
+ // Check extension
302
+ if (options.allowedExtensions) {
303
+ const ext = file.name.split('.').pop()?.toLowerCase();
304
+ if (!ext || !options.allowedExtensions.includes(ext)) {
305
+ errors.push(`File extension .${ext} not allowed`);
306
+ }
307
+ }
308
+
309
+ return errors.length === 0 ? { valid: true } : { valid: false, errors };
310
+ },
311
+ };
312
+
313
+ export const fileUploadExample = `
314
+ // File upload validation
315
+ import { NextResponse } from 'next/server';
316
+ import type { NextRequest } from 'next/server';
317
+
318
+ const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
319
+ const MAX_SIZE = 5 * 1024 * 1024; // 5MB
320
+
321
+ export async function POST(request: NextRequest) {
322
+ const formData = await request.formData();
323
+ const file = formData.get('file') as File | null;
324
+
325
+ if (!file) {
326
+ return NextResponse.json({ error: 'No file provided' }, { status: 400 });
327
+ }
328
+
329
+ // Validate MIME type
330
+ if (!ALLOWED_TYPES.includes(file.type)) {
331
+ return NextResponse.json(
332
+ { error: 'Invalid file type. Allowed: JPEG, PNG, WebP' },
333
+ { status: 400 }
334
+ );
335
+ }
336
+
337
+ // Validate size
338
+ if (file.size > MAX_SIZE) {
339
+ return NextResponse.json(
340
+ { error: 'File too large. Maximum size: 5MB' },
341
+ { status: 400 }
342
+ );
343
+ }
344
+
345
+ // Additional: Verify file signature (magic bytes)
346
+ const buffer = await file.arrayBuffer();
347
+ const bytes = new Uint8Array(buffer.slice(0, 4));
348
+
349
+ const signatures = {
350
+ jpeg: [0xFF, 0xD8, 0xFF],
351
+ png: [0x89, 0x50, 0x4E, 0x47],
352
+ };
353
+
354
+ const isValidJpeg = signatures.jpeg.every((b, i) => bytes[i] === b);
355
+ const isValidPng = signatures.png.every((b, i) => bytes[i] === b);
356
+
357
+ if (!isValidJpeg && !isValidPng) {
358
+ return NextResponse.json(
359
+ { error: 'File content does not match declared type' },
360
+ { status: 400 }
361
+ );
362
+ }
363
+
364
+ // Safe to process file
365
+ // ...
366
+
367
+ return NextResponse.json({ success: true });
368
+ }
369
+ `;
370
+
371
+ // =============================================================================
372
+ // SQL INJECTION PREVENTION
373
+ // =============================================================================
374
+
375
+ export const sqlInjectionPrevention = `
376
+ // SQL Injection Prevention
377
+
378
+ // BAD: String concatenation (SQL injection vulnerable)
379
+ const query = \`SELECT * FROM users WHERE id = '\${userId}'\`;
380
+
381
+ // GOOD: Parameterized queries
382
+
383
+ // With Prisma (recommended)
384
+ const user = await prisma.user.findUnique({
385
+ where: { id: userId },
386
+ });
387
+
388
+ // With raw SQL (use parameters)
389
+ const [user] = await sql\`
390
+ SELECT * FROM users WHERE id = \${userId}
391
+ \`;
392
+
393
+ // With pg (node-postgres)
394
+ const { rows } = await pool.query(
395
+ 'SELECT * FROM users WHERE id = $1',
396
+ [userId]
397
+ );
398
+
399
+ // With mysql2
400
+ const [rows] = await connection.execute(
401
+ 'SELECT * FROM users WHERE id = ?',
402
+ [userId]
403
+ );
404
+
405
+ // NEVER build queries with template literals or concatenation!
406
+ `;
407
+
408
+ // =============================================================================
409
+ // COMMON VALIDATION PATTERNS
410
+ // =============================================================================
411
+
412
+ export const validationPatterns = {
413
+ // Slug - URL-safe string
414
+ slug: /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
415
+
416
+ // Hex color
417
+ hexColor: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
418
+
419
+ // Credit card (basic - use payment provider's validation)
420
+ creditCard: /^\d{13,19}$/,
421
+
422
+ // IP address (v4)
423
+ ipv4: /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
424
+
425
+ // Semantic version
426
+ semver: /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,
427
+
428
+ // JWT (basic structure check)
429
+ jwt: /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/,
430
+ };