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.
- package/README.md +175 -19
- package/ai-defense/cost-protection.md +292 -0
- package/ai-defense/llm-security-checklist.md +324 -0
- package/ai-defense/prompt-injection-patterns.js +283 -0
- package/cli/bin/ship-safe.js +8 -1
- package/cli/utils/patterns.js +345 -24
- package/configs/firebase/firestore-rules.txt +215 -0
- package/configs/firebase/security-checklist.md +236 -0
- package/configs/firebase/storage-rules.txt +206 -0
- package/configs/supabase/rls-templates.sql +242 -0
- package/configs/supabase/secure-client.ts +225 -0
- package/configs/supabase/security-checklist.md +278 -0
- package/package.json +11 -2
- package/snippets/README.md +89 -25
- package/snippets/api-security/api-security-checklist.md +412 -0
- package/snippets/api-security/cors-config.ts +322 -0
- package/snippets/api-security/input-validation.ts +430 -0
- package/snippets/auth/jwt-checklist.md +322 -0
- package/snippets/rate-limiting/nextjs-middleware.ts +211 -0
- package/snippets/rate-limiting/upstash-ratelimit.ts +229 -0
|
@@ -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
|
+
};
|