ship-safe 1.0.1 → 3.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 +281 -23
- 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 +44 -2
- package/cli/commands/fix.js +216 -0
- package/cli/commands/guard.js +297 -0
- package/cli/commands/mcp.js +303 -0
- package/cli/commands/scan.js +231 -39
- package/cli/utils/entropy.js +126 -0
- package/cli/utils/output.js +10 -1
- package/cli/utils/patterns.js +376 -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/ship-safeignore-template +50 -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,412 @@
|
|
|
1
|
+
# API Security Checklist
|
|
2
|
+
|
|
3
|
+
**Secure your API endpoints before launch.**
|
|
4
|
+
|
|
5
|
+
Based on [OWASP API Security Top 10 2023](https://owasp.org/API-Security/) and real-world incidents.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Critical: Authentication & Authorization
|
|
10
|
+
|
|
11
|
+
### 1. [ ] Authentication required on all sensitive endpoints
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
// GOOD: Auth check at the start
|
|
15
|
+
export async function POST(request: Request) {
|
|
16
|
+
const session = await auth();
|
|
17
|
+
if (!session) {
|
|
18
|
+
return new Response('Unauthorized', { status: 401 });
|
|
19
|
+
}
|
|
20
|
+
// ... proceed
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// BAD: No auth check
|
|
24
|
+
export async function POST(request: Request) {
|
|
25
|
+
const { userId } = await request.json();
|
|
26
|
+
// Anyone can access this!
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### 2. [ ] Authorization checked for resource access
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
// GOOD: Check ownership before action
|
|
34
|
+
async function deletePost(postId: string, userId: string) {
|
|
35
|
+
const post = await db.post.findUnique({ where: { id: postId } });
|
|
36
|
+
|
|
37
|
+
if (!post) {
|
|
38
|
+
throw new Error('Post not found');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check ownership
|
|
42
|
+
if (post.authorId !== userId) {
|
|
43
|
+
throw new Error('Not authorized to delete this post');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await db.post.delete({ where: { id: postId } });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// BAD: No ownership check (IDOR vulnerability)
|
|
50
|
+
async function deletePost(postId: string) {
|
|
51
|
+
await db.post.delete({ where: { id: postId } }); // Anyone can delete!
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 3. [ ] Object-level authorization (IDOR prevention)
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
// Always verify user has access to the specific resource
|
|
59
|
+
async function getDocument(documentId: string, userId: string) {
|
|
60
|
+
const doc = await db.document.findFirst({
|
|
61
|
+
where: {
|
|
62
|
+
id: documentId,
|
|
63
|
+
// Include ownership/access check in query
|
|
64
|
+
OR: [
|
|
65
|
+
{ ownerId: userId },
|
|
66
|
+
{ sharedWith: { some: { userId } } },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!doc) {
|
|
72
|
+
// Don't reveal if document exists
|
|
73
|
+
return new Response('Not found', { status: 404 });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return doc;
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 4. [ ] Function-level authorization
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// Check user has permission for the action
|
|
84
|
+
const ADMIN_ONLY_ACTIONS = ['delete_user', 'view_all_users', 'modify_settings'];
|
|
85
|
+
|
|
86
|
+
function authorizeAction(user: User, action: string) {
|
|
87
|
+
if (ADMIN_ONLY_ACTIONS.includes(action) && user.role !== 'admin') {
|
|
88
|
+
throw new Error('Forbidden');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Critical: Input Validation
|
|
96
|
+
|
|
97
|
+
### 5. [ ] All input validated with strict schemas
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { z } from 'zod';
|
|
101
|
+
|
|
102
|
+
const createUserSchema = z.object({
|
|
103
|
+
email: z.string().email().max(255),
|
|
104
|
+
name: z.string().min(1).max(100),
|
|
105
|
+
age: z.number().int().min(13).max(120).optional(),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export async function POST(request: Request) {
|
|
109
|
+
const body = await request.json();
|
|
110
|
+
const result = createUserSchema.safeParse(body);
|
|
111
|
+
|
|
112
|
+
if (!result.success) {
|
|
113
|
+
return Response.json({ error: result.error.issues }, { status: 400 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Safe to use result.data
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 6. [ ] Query parameters validated
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
// Validate pagination to prevent resource exhaustion
|
|
124
|
+
const paginationSchema = z.object({
|
|
125
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
126
|
+
limit: z.coerce.number().int().min(1).max(100).default(20), // Cap limit!
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export async function GET(request: Request) {
|
|
130
|
+
const url = new URL(request.url);
|
|
131
|
+
const params = Object.fromEntries(url.searchParams);
|
|
132
|
+
|
|
133
|
+
const { page, limit } = paginationSchema.parse(params);
|
|
134
|
+
// ...
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### 7. [ ] File uploads validated
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
const ALLOWED_TYPES = ['image/jpeg', 'image/png'];
|
|
142
|
+
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
|
143
|
+
|
|
144
|
+
function validateFile(file: File) {
|
|
145
|
+
if (!ALLOWED_TYPES.includes(file.type)) {
|
|
146
|
+
throw new Error('Invalid file type');
|
|
147
|
+
}
|
|
148
|
+
if (file.size > MAX_SIZE) {
|
|
149
|
+
throw new Error('File too large');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 8. [ ] No SQL injection via parameterized queries
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
// GOOD: Parameterized (with Prisma)
|
|
158
|
+
const user = await prisma.user.findUnique({ where: { id: userId } });
|
|
159
|
+
|
|
160
|
+
// GOOD: Parameterized (with raw SQL)
|
|
161
|
+
const [user] = await sql`SELECT * FROM users WHERE id = ${userId}`;
|
|
162
|
+
|
|
163
|
+
// BAD: String concatenation
|
|
164
|
+
const query = `SELECT * FROM users WHERE id = '${userId}'`; // SQL INJECTION!
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## High: Rate Limiting
|
|
170
|
+
|
|
171
|
+
### 9. [ ] Rate limiting per user
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
import { Ratelimit } from '@upstash/ratelimit';
|
|
175
|
+
|
|
176
|
+
const ratelimit = new Ratelimit({
|
|
177
|
+
redis,
|
|
178
|
+
limiter: Ratelimit.slidingWindow(100, '1 m'), // 100 req/min
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
async function handler(request: Request, userId: string) {
|
|
182
|
+
const { success, remaining } = await ratelimit.limit(userId);
|
|
183
|
+
|
|
184
|
+
if (!success) {
|
|
185
|
+
return new Response('Too Many Requests', { status: 429 });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### 10. [ ] Stricter limits on sensitive endpoints
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
const authRatelimit = new Ratelimit({
|
|
194
|
+
limiter: Ratelimit.slidingWindow(5, '15 m'), // 5 attempts per 15 min
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
async function login(email: string) {
|
|
198
|
+
const { success } = await authRatelimit.limit(`login:${email}`);
|
|
199
|
+
if (!success) {
|
|
200
|
+
throw new Error('Too many login attempts');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### 11. [ ] Global rate limiting as backup
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
const globalRatelimit = new Ratelimit({
|
|
209
|
+
limiter: Ratelimit.fixedWindow(10000, '1 h'), // 10k total req/hour
|
|
210
|
+
prefix: 'global',
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## High: Security Headers & CORS
|
|
217
|
+
|
|
218
|
+
### 12. [ ] CORS configured with specific origins
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
// GOOD: Specific origins
|
|
222
|
+
const ALLOWED_ORIGINS = ['https://yourapp.com'];
|
|
223
|
+
|
|
224
|
+
// BAD: Wildcard (allows any origin)
|
|
225
|
+
// 'Access-Control-Allow-Origin': '*'
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### 13. [ ] Security headers set
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
// next.config.js
|
|
232
|
+
const securityHeaders = [
|
|
233
|
+
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
|
234
|
+
{ key: 'X-Frame-Options', value: 'DENY' },
|
|
235
|
+
{ key: 'X-XSS-Protection', value: '1; mode=block' },
|
|
236
|
+
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
|
237
|
+
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
|
238
|
+
];
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## High: Error Handling
|
|
244
|
+
|
|
245
|
+
### 14. [ ] Errors don't leak internal details
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
// GOOD: Generic error message
|
|
249
|
+
catch (error) {
|
|
250
|
+
console.error('Internal error:', error); // Log full error
|
|
251
|
+
return Response.json(
|
|
252
|
+
{ error: 'An error occurred' }, // Return generic message
|
|
253
|
+
{ status: 500 }
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// BAD: Leaking internal details
|
|
258
|
+
catch (error) {
|
|
259
|
+
return Response.json(
|
|
260
|
+
{ error: error.message, stack: error.stack }, // DON'T!
|
|
261
|
+
{ status: 500 }
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### 15. [ ] Consistent error response format
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
interface ErrorResponse {
|
|
270
|
+
error: string;
|
|
271
|
+
code?: string;
|
|
272
|
+
details?: { field: string; message: string }[];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 400: Validation errors
|
|
276
|
+
{ error: 'Validation failed', details: [...] }
|
|
277
|
+
|
|
278
|
+
// 401: Authentication required
|
|
279
|
+
{ error: 'Authentication required' }
|
|
280
|
+
|
|
281
|
+
// 403: Permission denied
|
|
282
|
+
{ error: 'Permission denied' }
|
|
283
|
+
|
|
284
|
+
// 404: Resource not found
|
|
285
|
+
{ error: 'Resource not found' }
|
|
286
|
+
|
|
287
|
+
// 429: Rate limited
|
|
288
|
+
{ error: 'Too many requests', retryAfter: 60 }
|
|
289
|
+
|
|
290
|
+
// 500: Server error
|
|
291
|
+
{ error: 'Internal server error' }
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## Medium: Data Protection
|
|
297
|
+
|
|
298
|
+
### 16. [ ] Sensitive data not exposed in responses
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
// GOOD: Select only needed fields
|
|
302
|
+
const user = await prisma.user.findUnique({
|
|
303
|
+
where: { id },
|
|
304
|
+
select: {
|
|
305
|
+
id: true,
|
|
306
|
+
name: true,
|
|
307
|
+
email: true,
|
|
308
|
+
// NOT: password, apiKeys, internalNotes
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Or use DTOs
|
|
313
|
+
function toPublicUser(user: User) {
|
|
314
|
+
return {
|
|
315
|
+
id: user.id,
|
|
316
|
+
name: user.name,
|
|
317
|
+
email: user.email,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### 17. [ ] Audit logging for sensitive actions
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
async function sensitiveAction(userId: string, action: string, data: any) {
|
|
326
|
+
await db.auditLog.create({
|
|
327
|
+
data: {
|
|
328
|
+
userId,
|
|
329
|
+
action,
|
|
330
|
+
metadata: JSON.stringify(data),
|
|
331
|
+
ip: request.headers.get('x-forwarded-for'),
|
|
332
|
+
timestamp: new Date(),
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## Medium: Resource Management
|
|
341
|
+
|
|
342
|
+
### 18. [ ] Response size limits
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
// Limit array sizes in responses
|
|
346
|
+
const items = await db.item.findMany({
|
|
347
|
+
take: Math.min(limit, 100), // Never return more than 100
|
|
348
|
+
});
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### 19. [ ] Request timeout configured
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
// Vercel
|
|
355
|
+
export const maxDuration = 10; // 10 seconds max
|
|
356
|
+
|
|
357
|
+
// Express
|
|
358
|
+
app.use(timeout('10s'));
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### 20. [ ] Expensive operations limited
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
// Limit complex queries
|
|
365
|
+
const MAX_FILTER_DEPTH = 3;
|
|
366
|
+
const MAX_INCLUDES = 5;
|
|
367
|
+
|
|
368
|
+
function validateQuery(query: any) {
|
|
369
|
+
if (countNesting(query.where) > MAX_FILTER_DEPTH) {
|
|
370
|
+
throw new Error('Query too complex');
|
|
371
|
+
}
|
|
372
|
+
if (Object.keys(query.include || {}).length > MAX_INCLUDES) {
|
|
373
|
+
throw new Error('Too many includes');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## Quick Reference
|
|
381
|
+
|
|
382
|
+
| Vulnerability | Mitigation |
|
|
383
|
+
|---------------|------------|
|
|
384
|
+
| Broken Authentication | Require auth, validate sessions |
|
|
385
|
+
| Broken Authorization | Check ownership, role-based access |
|
|
386
|
+
| IDOR | Include user context in queries |
|
|
387
|
+
| SQL Injection | Parameterized queries only |
|
|
388
|
+
| Mass Assignment | Explicit field selection |
|
|
389
|
+
| Rate Limit Bypass | Per-user + global limits |
|
|
390
|
+
| CORS Misconfiguration | Explicit origin allowlist |
|
|
391
|
+
| Verbose Errors | Log details, return generic messages |
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## Testing Checklist
|
|
396
|
+
|
|
397
|
+
```
|
|
398
|
+
1. Try accessing resources without authentication
|
|
399
|
+
2. Try accessing other users' resources with your auth
|
|
400
|
+
3. Try SQL injection: ' OR 1=1 --
|
|
401
|
+
4. Try sending requests faster than rate limits
|
|
402
|
+
5. Try uploading files with spoofed MIME types
|
|
403
|
+
6. Try extremely large payloads
|
|
404
|
+
7. Try malformed JSON/XML
|
|
405
|
+
8. Try accessing admin endpoints as regular user
|
|
406
|
+
9. Try IDOR by changing IDs in requests
|
|
407
|
+
10. Check error responses for sensitive info
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
**Remember: Every endpoint is an attack surface. Validate everything, trust nothing.**
|