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.
@@ -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.**