@od-oneapp/security 2026.1.1301
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 +807 -0
- package/dist/client-next.d.mts +2 -0
- package/dist/client-next.mjs +17 -0
- package/dist/client-next.mjs.map +1 -0
- package/dist/client.d.mts +5 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +48 -0
- package/dist/client.mjs.map +1 -0
- package/dist/env-DvTVXAjh.d.mts +163 -0
- package/dist/env-DvTVXAjh.d.mts.map +1 -0
- package/dist/rate-limit-DStYbhoa.mjs +736 -0
- package/dist/rate-limit-DStYbhoa.mjs.map +1 -0
- package/dist/server-next.d.mts +30 -0
- package/dist/server-next.d.mts.map +1 -0
- package/dist/server-next.mjs +269 -0
- package/dist/server-next.mjs.map +1 -0
- package/dist/server.d.mts +2 -0
- package/dist/server.mjs +3 -0
- package/package.json +80 -0
- package/src/client-next.ts +13 -0
- package/src/client.ts +47 -0
- package/src/server-next.ts +347 -0
- package/src/server.ts +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
# @repo/security
|
|
2
|
+
|
|
3
|
+
Comprehensive security infrastructure for Next.js and Node.js applications, providing rate limiting, bot detection, and
|
|
4
|
+
security headers.
|
|
5
|
+
|
|
6
|
+
## Package Information
|
|
7
|
+
|
|
8
|
+
- **Can build:** NO (library package)
|
|
9
|
+
- **Framework:** Next.js 16+ (optional), Node.js 22+
|
|
10
|
+
- **Dependencies:** Arcjet, Nosecone, Upstash Redis
|
|
11
|
+
|
|
12
|
+
## Exports
|
|
13
|
+
|
|
14
|
+
The package uses conditional exports for framework-specific implementations:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
// Next.js Server-Side
|
|
18
|
+
import { secure, createRateLimiter } from "@repo/security/server/next";
|
|
19
|
+
|
|
20
|
+
// Generic Server-Side (Node.js, workers, etc.)
|
|
21
|
+
import { createRateLimiter } from "@repo/security/server";
|
|
22
|
+
|
|
23
|
+
// Next.js Client-Side (limited functionality)
|
|
24
|
+
import { getSecurityHeaders } from "@repo/security/client/next";
|
|
25
|
+
|
|
26
|
+
// Middleware (Next.js)
|
|
27
|
+
import { noseconeMiddleware, noseconeOptions } from "@repo/security/middleware";
|
|
28
|
+
|
|
29
|
+
// Environment utilities
|
|
30
|
+
import { env, safeEnv } from "@repo/security/keys";
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Core Features
|
|
34
|
+
|
|
35
|
+
### 🤖 Bot Detection (Arcjet)
|
|
36
|
+
|
|
37
|
+
AI-powered bot detection with customizable allow/deny lists, Shield protection, and decision logging.
|
|
38
|
+
|
|
39
|
+
### 🛡️ Security Headers (Nosecone)
|
|
40
|
+
|
|
41
|
+
Production-ready security headers including CSP, HSTS, X-Frame-Options, and more.
|
|
42
|
+
|
|
43
|
+
### ⏱️ Rate Limiting (Upstash)
|
|
44
|
+
|
|
45
|
+
Distributed rate limiting with multiple strategies:
|
|
46
|
+
|
|
47
|
+
- **Sliding Window**: Smooth rate limiting over rolling time windows
|
|
48
|
+
- **Fixed Window**: Traditional rate limiting per fixed time period
|
|
49
|
+
- **Token Bucket**: Burst handling with token refill
|
|
50
|
+
|
|
51
|
+
### 🌍 Environment Aware
|
|
52
|
+
|
|
53
|
+
Graceful degradation in development with production validation:
|
|
54
|
+
|
|
55
|
+
- Development: Logs warnings, allows requests without configuration
|
|
56
|
+
- Production: Fails closed, requires proper configuration
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
### 1. Environment Setup
|
|
61
|
+
|
|
62
|
+
Create a `.env` file with the following variables:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Development (optional - graceful degradation without these)
|
|
66
|
+
ARCJET_KEY=ajkey_test_xxxxxxxxxxxxx
|
|
67
|
+
UPSTASH_REDIS_REST_URL=https://xxxxx.upstash.io
|
|
68
|
+
UPSTASH_REDIS_REST_TOKEN=xxxxxxxxxx
|
|
69
|
+
|
|
70
|
+
# Production (required for full functionality)
|
|
71
|
+
ARCJET_KEY=ajkey_xxxxxxxxxxxxx
|
|
72
|
+
UPSTASH_REDIS_REST_URL=https://xxxxx.upstash.io
|
|
73
|
+
UPSTASH_REDIS_REST_TOKEN=xxxxxxxxxx
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Getting API Keys:**
|
|
77
|
+
|
|
78
|
+
- Arcjet: https://app.arcjet.com
|
|
79
|
+
- Upstash Redis: https://console.upstash.com
|
|
80
|
+
|
|
81
|
+
### 2. Middleware Configuration (Next.js)
|
|
82
|
+
|
|
83
|
+
Add security headers middleware to your Next.js application:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
// middleware.ts
|
|
87
|
+
import { noseconeMiddleware, noseconeOptions } from "@repo/security/middleware";
|
|
88
|
+
|
|
89
|
+
export const config = {
|
|
90
|
+
matcher: [
|
|
91
|
+
/*
|
|
92
|
+
* Match all request paths except:
|
|
93
|
+
* - _next/static (static files)
|
|
94
|
+
* - _next/image (image optimization files)
|
|
95
|
+
* - favicon.ico (favicon file)
|
|
96
|
+
*/
|
|
97
|
+
"/((?!_next/static|_next/image|favicon.ico).*)"
|
|
98
|
+
]
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export default noseconeMiddleware(noseconeOptions);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 3. API Route Protection
|
|
105
|
+
|
|
106
|
+
Protect API routes with bot detection and rate limiting:
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
// app/api/protected/route.ts
|
|
110
|
+
import { secure, createRateLimiter } from "@repo/security/server/next";
|
|
111
|
+
import { Ratelimit } from "@upstash/ratelimit";
|
|
112
|
+
|
|
113
|
+
const limiter = createRateLimiter({
|
|
114
|
+
limiter: Ratelimit.slidingWindow(10, "1 m"),
|
|
115
|
+
prefix: "api:protected"
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
export async function POST(request: Request) {
|
|
119
|
+
// 1. Bot Detection - Allow only Google and Bing bots
|
|
120
|
+
await secure(["GOOGLEBOT", "BINGBOT"], request);
|
|
121
|
+
|
|
122
|
+
// 2. Rate Limiting - 10 requests per minute
|
|
123
|
+
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
|
|
124
|
+
const result = await limiter.limit(`api:protected:${ip}`);
|
|
125
|
+
|
|
126
|
+
if (!result.success) {
|
|
127
|
+
return new Response("Too many requests", {
|
|
128
|
+
status: 429,
|
|
129
|
+
headers: {
|
|
130
|
+
"Retry-After": String(Math.ceil((result.reset - Date.now()) / 1000))
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 3. Process request
|
|
136
|
+
return Response.json({ success: true });
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Detailed Usage
|
|
141
|
+
|
|
142
|
+
### Bot Detection
|
|
143
|
+
|
|
144
|
+
The `secure()` function provides Arcjet-powered bot detection with Shield protection.
|
|
145
|
+
|
|
146
|
+
#### Allow Specific Bots
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
import { secure } from "@repo/security/server/next";
|
|
150
|
+
|
|
151
|
+
// Allow search engine crawlers
|
|
152
|
+
await secure(["GOOGLEBOT", "BINGBOT", "DUCKDUCKBOT"], request);
|
|
153
|
+
|
|
154
|
+
// Allow social media preview bots
|
|
155
|
+
await secure(["FACEBOOKBOT", "TWITTERBOT", "LINKEDINBOT"], request);
|
|
156
|
+
|
|
157
|
+
// Allow monitoring tools
|
|
158
|
+
await secure(["MONITOR"], request); // Category: monitoring tools
|
|
159
|
+
|
|
160
|
+
// Allow AI crawlers
|
|
161
|
+
await secure(["AI"], request); // Category: AI scrapers
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
#### Block All Bots
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
// Block all automated traffic
|
|
168
|
+
await secure([], request);
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
#### Available Bot Categories
|
|
172
|
+
|
|
173
|
+
**Individual Bots:**
|
|
174
|
+
|
|
175
|
+
- `GOOGLEBOT` - Google search crawler
|
|
176
|
+
- `BINGBOT` - Bing search crawler
|
|
177
|
+
- `DUCKDUCKBOT` - DuckDuckGo crawler
|
|
178
|
+
- `FACEBOOKBOT` - Facebook link preview
|
|
179
|
+
- `TWITTERBOT` - Twitter card preview
|
|
180
|
+
- `LINKEDINBOT` - LinkedIn preview
|
|
181
|
+
- `SLACKBOT` - Slack link preview
|
|
182
|
+
- `DISCORDBOT` - Discord embed preview
|
|
183
|
+
|
|
184
|
+
**Categories:**
|
|
185
|
+
|
|
186
|
+
- `CRAWLER` - All web crawlers
|
|
187
|
+
- `PREVIEW` - Social media preview bots
|
|
188
|
+
- `MONITOR` - Monitoring and uptime tools
|
|
189
|
+
- `AI` - AI training scrapers
|
|
190
|
+
- `SEO` - SEO analysis tools
|
|
191
|
+
- `ARCHIVE` - Web archiving services
|
|
192
|
+
|
|
193
|
+
#### Error Handling
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
import { secure, SecurityDenialError } from "@repo/security/server/next";
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
await secure(["GOOGLEBOT"], request);
|
|
200
|
+
// Request is allowed
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error instanceof SecurityDenialError) {
|
|
203
|
+
console.error("Security denial:", {
|
|
204
|
+
reason: error.reason, // 'bot' | 'rate_limit' | 'shield' | 'unknown'
|
|
205
|
+
ip: error.metadata?.ip,
|
|
206
|
+
path: error.metadata?.path,
|
|
207
|
+
userAgent: error.metadata?.userAgent,
|
|
208
|
+
decisionId: error.metadata?.decisionId
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return new Response("Access denied", { status: 403 });
|
|
212
|
+
}
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Rate Limiting
|
|
218
|
+
|
|
219
|
+
#### Basic Usage
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
import { applyRateLimit } from "@repo/security/server/next";
|
|
223
|
+
|
|
224
|
+
// Apply rate limit using pre-configured limiter
|
|
225
|
+
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
|
|
226
|
+
const result = await applyRateLimit(ip, "api");
|
|
227
|
+
|
|
228
|
+
if (!result.success) {
|
|
229
|
+
return new Response("Too many requests", {
|
|
230
|
+
status: 429,
|
|
231
|
+
headers: {
|
|
232
|
+
"X-RateLimit-Limit": String(result.limit),
|
|
233
|
+
"X-RateLimit-Remaining": String(result.remaining),
|
|
234
|
+
"X-RateLimit-Reset": String(result.reset),
|
|
235
|
+
"Retry-After": String(Math.ceil((result.reset - Date.now()) / 1000))
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
#### Pre-configured Rate Limiters
|
|
242
|
+
|
|
243
|
+
The package provides pre-configured rate limiters for common use cases:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
import { rateLimiters } from "@repo/security/server/next";
|
|
247
|
+
|
|
248
|
+
// API endpoints - 100 requests per minute
|
|
249
|
+
const apiResult = await rateLimiters.api.limit(identifier);
|
|
250
|
+
|
|
251
|
+
// Authentication - 5 attempts per 15 minutes
|
|
252
|
+
const authResult = await rateLimiters.auth.limit(userId);
|
|
253
|
+
|
|
254
|
+
// File uploads - 10 uploads per hour
|
|
255
|
+
const uploadResult = await rateLimiters.upload.limit(userId);
|
|
256
|
+
|
|
257
|
+
// Webhooks - 1000 requests per hour
|
|
258
|
+
const webhookResult = await rateLimiters.webhook.limit(webhookId);
|
|
259
|
+
|
|
260
|
+
// Search - 500 requests per minute
|
|
261
|
+
const searchResult = await rateLimiters.search.limit(ip);
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
#### Custom Rate Limiters
|
|
265
|
+
|
|
266
|
+
Create custom rate limiters with different strategies:
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
import { createRateLimiter, slidingWindow, fixedWindow, tokenBucket } from "@repo/security/server/next";
|
|
270
|
+
|
|
271
|
+
// Sliding window - 50 requests per 10 seconds
|
|
272
|
+
const customLimiter = createRateLimiter({
|
|
273
|
+
limiter: slidingWindow(50, "10 s"),
|
|
274
|
+
prefix: "custom:api"
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Fixed window - 1000 requests per hour
|
|
278
|
+
const hourlyLimiter = createRateLimiter({
|
|
279
|
+
limiter: fixedWindow(1000, "1 h"),
|
|
280
|
+
prefix: "hourly"
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Token bucket - 100 requests with 10 per second refill
|
|
284
|
+
const burstLimiter = createRateLimiter({
|
|
285
|
+
limiter: tokenBucket(100, "10 s"),
|
|
286
|
+
prefix: "burst"
|
|
287
|
+
});
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
#### Rate Limit Utilities
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
import { applyRateLimit, isRateLimited, getRateLimitInfo } from "@repo/security/server/next";
|
|
294
|
+
|
|
295
|
+
// Check if request should be blocked
|
|
296
|
+
const isBlocked = await isRateLimited(identifier, "api");
|
|
297
|
+
if (isBlocked) {
|
|
298
|
+
return new Response("Rate limited", { status: 429 });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Get rate limit info without applying limits
|
|
302
|
+
const info = await getRateLimitInfo(identifier, "api");
|
|
303
|
+
console.log(`Remaining: ${info.remaining}/${info.limit}`);
|
|
304
|
+
|
|
305
|
+
// Apply rate limit and get full result
|
|
306
|
+
const result = await applyRateLimit(identifier, "api");
|
|
307
|
+
console.log(result);
|
|
308
|
+
// {
|
|
309
|
+
// success: true,
|
|
310
|
+
// limit: 100,
|
|
311
|
+
// remaining: 99,
|
|
312
|
+
// reset: 1234567890,
|
|
313
|
+
// retryAfter: undefined
|
|
314
|
+
// }
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### Security Headers
|
|
318
|
+
|
|
319
|
+
Configure security headers via Nosecone middleware:
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
// middleware.ts
|
|
323
|
+
import { noseconeMiddleware } from "@repo/security/middleware";
|
|
324
|
+
import type { NoseconeOptions } from "@nosecone/next";
|
|
325
|
+
|
|
326
|
+
// Custom configuration
|
|
327
|
+
const customOptions: NoseconeOptions = {
|
|
328
|
+
// Content Security Policy
|
|
329
|
+
contentSecurityPolicy: {
|
|
330
|
+
directives: {
|
|
331
|
+
defaultSrc: ["'self'"],
|
|
332
|
+
scriptSrc: ["'self'", "'unsafe-inline'"],
|
|
333
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
334
|
+
imgSrc: ["'self'", "data:", "https:"],
|
|
335
|
+
fontSrc: ["'self'", "data:"],
|
|
336
|
+
connectSrc: ["'self'"],
|
|
337
|
+
frameSrc: ["'none'"]
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
// Strict Transport Security
|
|
342
|
+
strictTransportSecurity: {
|
|
343
|
+
maxAge: 31536000,
|
|
344
|
+
includeSubDomains: true,
|
|
345
|
+
preload: true
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
// X-Frame-Options
|
|
349
|
+
xFrameOptions: "DENY",
|
|
350
|
+
|
|
351
|
+
// X-Content-Type-Options
|
|
352
|
+
xContentTypeOptions: "nosniff",
|
|
353
|
+
|
|
354
|
+
// Referrer Policy
|
|
355
|
+
referrerPolicy: "strict-origin-when-cross-origin",
|
|
356
|
+
|
|
357
|
+
// Permissions Policy
|
|
358
|
+
permissionsPolicy: {
|
|
359
|
+
camera: ["none"],
|
|
360
|
+
microphone: ["none"],
|
|
361
|
+
geolocation: ["none"]
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
export default noseconeMiddleware(customOptions);
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### Environment Management
|
|
369
|
+
|
|
370
|
+
The package uses `@t3-oss/env-core` for type-safe environment variable validation:
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
import { env, safeEnv, isProduction, hasArcjetConfig, hasUpstashConfig } from "@repo/security/keys";
|
|
374
|
+
|
|
375
|
+
// Access validated environment variables
|
|
376
|
+
console.log(env.ARCJET_KEY); // string | undefined
|
|
377
|
+
console.log(env.NODE_ENV); // 'development' | 'test' | 'production'
|
|
378
|
+
|
|
379
|
+
// Safe access (always returns object, no exceptions)
|
|
380
|
+
const config = safeEnv();
|
|
381
|
+
console.log(config.UPSTASH_REDIS_REST_URL);
|
|
382
|
+
|
|
383
|
+
// Helper functions
|
|
384
|
+
if (isProduction()) {
|
|
385
|
+
// Production-only logic
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (hasArcjetConfig()) {
|
|
389
|
+
// Arcjet is configured
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (hasUpstashConfig()) {
|
|
393
|
+
// Redis is configured
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### Custom Logger
|
|
398
|
+
|
|
399
|
+
Replace the default logger with your own implementation:
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
import { setLogger } from "@repo/security/keys";
|
|
403
|
+
import pino from "pino";
|
|
404
|
+
|
|
405
|
+
const logger = pino({
|
|
406
|
+
level: "info",
|
|
407
|
+
transport: {
|
|
408
|
+
target: "pino-pretty"
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
setLogger({
|
|
413
|
+
warn: (message, context) => logger.warn(context, message),
|
|
414
|
+
error: (message, context) => logger.error(context, message)
|
|
415
|
+
});
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
## Architecture
|
|
419
|
+
|
|
420
|
+
### Module Organization
|
|
421
|
+
|
|
422
|
+
```
|
|
423
|
+
@repo/security/
|
|
424
|
+
├── src/
|
|
425
|
+
│ ├── server.ts # Generic server exports
|
|
426
|
+
│ ├── server-next.ts # Next.js server exports (Arcjet, rate limiting)
|
|
427
|
+
│ ├── client.ts # Generic client exports
|
|
428
|
+
│ └── client-next.ts # Next.js client exports
|
|
429
|
+
├── env.ts # Environment variable validation
|
|
430
|
+
├── rate-limit.ts # Rate limiting logic (Upstash)
|
|
431
|
+
├── middleware.ts # Security headers middleware (Nosecone)
|
|
432
|
+
└── __tests__/ # Test suites
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### Dependency Graph
|
|
436
|
+
|
|
437
|
+
```
|
|
438
|
+
src/server-next.ts
|
|
439
|
+
├─> env.ts (environment validation)
|
|
440
|
+
├─> rate-limit.ts (rate limiting)
|
|
441
|
+
│ └─> env.ts
|
|
442
|
+
│ └─> @repo/db-upstash-redis/server
|
|
443
|
+
└─> middleware.ts (security headers)
|
|
444
|
+
└─> @nosecone/next
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### Design Principles
|
|
448
|
+
|
|
449
|
+
1. **Graceful Degradation**: Works without configuration in development
|
|
450
|
+
2. **Fail Closed**: Denies requests on errors in production
|
|
451
|
+
3. **Framework Agnostic**: Core logic works with any framework
|
|
452
|
+
4. **Type Safe**: Full TypeScript support with branded types
|
|
453
|
+
5. **Observable**: Structured logging for monitoring
|
|
454
|
+
|
|
455
|
+
## Edge Cases & Failure Scenarios
|
|
456
|
+
|
|
457
|
+
### Missing Environment Variables
|
|
458
|
+
|
|
459
|
+
**Development:**
|
|
460
|
+
|
|
461
|
+
- Rate limiting: Returns no-op limiter, logs warning
|
|
462
|
+
- Bot detection: Skips validation, allows all requests
|
|
463
|
+
|
|
464
|
+
**Production:**
|
|
465
|
+
|
|
466
|
+
- Rate limiting: Throws error on limiter creation
|
|
467
|
+
- Bot detection: Throws error on validation failure
|
|
468
|
+
|
|
469
|
+
### Redis Connection Failures
|
|
470
|
+
|
|
471
|
+
**Development:**
|
|
472
|
+
|
|
473
|
+
- Falls back to allowing requests
|
|
474
|
+
- Logs error with context
|
|
475
|
+
|
|
476
|
+
**Production:**
|
|
477
|
+
|
|
478
|
+
- Denies requests (fail closed)
|
|
479
|
+
- Returns 500 error
|
|
480
|
+
|
|
481
|
+
### Arcjet API Failures
|
|
482
|
+
|
|
483
|
+
**Development:**
|
|
484
|
+
|
|
485
|
+
- Allows requests after logging error
|
|
486
|
+
- Useful for local development
|
|
487
|
+
|
|
488
|
+
**Production:**
|
|
489
|
+
|
|
490
|
+
- Denies requests (fail closed)
|
|
491
|
+
- Returns 403 error
|
|
492
|
+
|
|
493
|
+
### Rate Limit Identifier Validation
|
|
494
|
+
|
|
495
|
+
```typescript
|
|
496
|
+
// Valid identifiers (alphanumeric, hyphens, underscores, colons, dots)
|
|
497
|
+
await applyRateLimit("user-123", "api"); // ✓
|
|
498
|
+
await applyRateLimit("192.168.1.1", "api"); // ✓
|
|
499
|
+
await applyRateLimit("api:v1:users", "api"); // ✓
|
|
500
|
+
|
|
501
|
+
// Invalid identifiers (contains special characters)
|
|
502
|
+
await applyRateLimit("user@example.com", "api"); // ✗ Throws error
|
|
503
|
+
await applyRateLimit("user/123", "api"); // ✗ Throws error
|
|
504
|
+
|
|
505
|
+
// Too long (>255 characters)
|
|
506
|
+
await applyRateLimit("a".repeat(256), "api"); // ✗ Throws error
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
## Common Patterns
|
|
510
|
+
|
|
511
|
+
### Authenticated vs Anonymous Rate Limiting
|
|
512
|
+
|
|
513
|
+
```typescript
|
|
514
|
+
import { applyRateLimit } from "@repo/security/server/next";
|
|
515
|
+
|
|
516
|
+
export async function POST(request: Request) {
|
|
517
|
+
const session = await getSession(request);
|
|
518
|
+
|
|
519
|
+
// Different rate limits for authenticated vs anonymous
|
|
520
|
+
const identifier = session?.userId
|
|
521
|
+
? `user:${session.userId}`
|
|
522
|
+
: `ip:${request.headers.get("x-forwarded-for") ?? "unknown"}`;
|
|
523
|
+
|
|
524
|
+
const limiterType = session?.userId ? "api" : "auth";
|
|
525
|
+
|
|
526
|
+
const result = await applyRateLimit(identifier, limiterType);
|
|
527
|
+
|
|
528
|
+
if (!result.success) {
|
|
529
|
+
return new Response("Rate limit exceeded", { status: 429 });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Process request
|
|
533
|
+
}
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
### Progressive Rate Limiting
|
|
537
|
+
|
|
538
|
+
```typescript
|
|
539
|
+
import { createRateLimiter, slidingWindow } from "@repo/security/server/next";
|
|
540
|
+
|
|
541
|
+
// Tier 1: 10 requests per minute (free tier)
|
|
542
|
+
const freeLimiter = createRateLimiter({
|
|
543
|
+
limiter: slidingWindow(10, "1 m"),
|
|
544
|
+
prefix: "free"
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// Tier 2: 100 requests per minute (pro tier)
|
|
548
|
+
const proLimiter = createRateLimiter({
|
|
549
|
+
limiter: slidingWindow(100, "1 m"),
|
|
550
|
+
prefix: "pro"
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// Tier 3: 1000 requests per minute (enterprise)
|
|
554
|
+
const enterpriseLimiter = createRateLimiter({
|
|
555
|
+
limiter: slidingWindow(1000, "1 m"),
|
|
556
|
+
prefix: "enterprise"
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
export async function GET(request: Request) {
|
|
560
|
+
const user = await getUser(request);
|
|
561
|
+
const limiter = user.tier === "enterprise" ? enterpriseLimiter : user.tier === "pro" ? proLimiter : freeLimiter;
|
|
562
|
+
|
|
563
|
+
const result = await limiter.limit(`user:${user.id}`);
|
|
564
|
+
// ... handle result
|
|
565
|
+
}
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### Combined Protection
|
|
569
|
+
|
|
570
|
+
```typescript
|
|
571
|
+
import { secure, applyRateLimit, SecurityDenialError } from "@repo/security/server/next";
|
|
572
|
+
|
|
573
|
+
export async function POST(request: Request) {
|
|
574
|
+
try {
|
|
575
|
+
// 1. Shield Protection (Arcjet)
|
|
576
|
+
await secure([], request); // Block all bots
|
|
577
|
+
|
|
578
|
+
// 2. Rate Limiting
|
|
579
|
+
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
|
|
580
|
+
const result = await applyRateLimit(ip, "api");
|
|
581
|
+
|
|
582
|
+
if (!result.success) {
|
|
583
|
+
return new Response("Rate limit exceeded", {
|
|
584
|
+
status: 429,
|
|
585
|
+
headers: {
|
|
586
|
+
"Retry-After": String(Math.ceil((result.reset - Date.now()) / 1000))
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// 3. Process request
|
|
592
|
+
return Response.json({ success: true });
|
|
593
|
+
} catch (error) {
|
|
594
|
+
if (error instanceof SecurityDenialError) {
|
|
595
|
+
// Log security event
|
|
596
|
+
console.error("Security denial:", error.metadata);
|
|
597
|
+
|
|
598
|
+
return new Response("Access denied", {
|
|
599
|
+
status: 403,
|
|
600
|
+
headers: {
|
|
601
|
+
"X-Denial-Reason": error.reason
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
### Rate Limit Headers
|
|
611
|
+
|
|
612
|
+
```typescript
|
|
613
|
+
import { getRateLimitInfo } from "@repo/security/server/next";
|
|
614
|
+
|
|
615
|
+
export async function GET(request: Request) {
|
|
616
|
+
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
|
|
617
|
+
const info = await getRateLimitInfo(ip, "api");
|
|
618
|
+
|
|
619
|
+
return new Response("OK", {
|
|
620
|
+
headers: {
|
|
621
|
+
"X-RateLimit-Limit": String(info.limit),
|
|
622
|
+
"X-RateLimit-Remaining": String(info.remaining),
|
|
623
|
+
"X-RateLimit-Reset": String(info.reset)
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
## Testing
|
|
630
|
+
|
|
631
|
+
### Running Tests
|
|
632
|
+
|
|
633
|
+
```bash
|
|
634
|
+
# Run all tests
|
|
635
|
+
pnpm test
|
|
636
|
+
|
|
637
|
+
# Run tests in watch mode
|
|
638
|
+
pnpm test:watch
|
|
639
|
+
|
|
640
|
+
# Run with coverage
|
|
641
|
+
pnpm test:coverage
|
|
642
|
+
|
|
643
|
+
# Generate coverage report
|
|
644
|
+
pnpm coverage:collect
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
### Test Structure
|
|
648
|
+
|
|
649
|
+
```typescript
|
|
650
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
651
|
+
import { applyRateLimit } from "@repo/security/server/next";
|
|
652
|
+
|
|
653
|
+
describe("rate limiting", () => {
|
|
654
|
+
beforeEach(() => {
|
|
655
|
+
vi.resetModules();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("should enforce rate limits", async () => {
|
|
659
|
+
const result = await applyRateLimit("test-user", "api");
|
|
660
|
+
expect(result.success).toBe(true);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("should return retryAfter when limit exceeded", async () => {
|
|
664
|
+
// Exhaust rate limit
|
|
665
|
+
for (let i = 0; i < 100; i++) {
|
|
666
|
+
await applyRateLimit("test-user", "api");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const result = await applyRateLimit("test-user", "api");
|
|
670
|
+
expect(result.success).toBe(false);
|
|
671
|
+
expect(result.retryAfter).toBeGreaterThan(0);
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
## Troubleshooting
|
|
677
|
+
|
|
678
|
+
### Rate Limiting Not Working
|
|
679
|
+
|
|
680
|
+
**Symptoms:** All requests are allowed, no rate limiting
|
|
681
|
+
|
|
682
|
+
**Causes:**
|
|
683
|
+
|
|
684
|
+
1. Redis not configured (check `UPSTASH_REDIS_REST_TOKEN` and `UPSTASH_REDIS_REST_URL`)
|
|
685
|
+
2. Running in development mode (graceful degradation)
|
|
686
|
+
3. Identifier is different for each request (check IP extraction)
|
|
687
|
+
|
|
688
|
+
**Solution:**
|
|
689
|
+
|
|
690
|
+
```typescript
|
|
691
|
+
import { hasUpstashConfig } from "@repo/security/keys";
|
|
692
|
+
|
|
693
|
+
if (!hasUpstashConfig()) {
|
|
694
|
+
console.error("Redis not configured - rate limiting disabled");
|
|
695
|
+
}
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
### Bot Detection Not Blocking
|
|
699
|
+
|
|
700
|
+
**Symptoms:** Bots are not being blocked
|
|
701
|
+
|
|
702
|
+
**Causes:**
|
|
703
|
+
|
|
704
|
+
1. Arcjet not configured (check `ARCJET_KEY`)
|
|
705
|
+
2. Bot is in allow list
|
|
706
|
+
3. Running in development mode
|
|
707
|
+
|
|
708
|
+
**Solution:**
|
|
709
|
+
|
|
710
|
+
```typescript
|
|
711
|
+
import { hasArcjetConfig } from "@repo/security/keys";
|
|
712
|
+
|
|
713
|
+
if (!hasArcjetConfig()) {
|
|
714
|
+
console.error("Arcjet not configured - bot detection disabled");
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Check bot allow list
|
|
718
|
+
await secure([], request); // Empty array = block all bots
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
### Memory Leaks
|
|
722
|
+
|
|
723
|
+
**Symptoms:** Server memory usage growing over time
|
|
724
|
+
|
|
725
|
+
**Causes:**
|
|
726
|
+
|
|
727
|
+
1. Rate limit info cache not being cleaned up
|
|
728
|
+
2. Too many unique identifiers
|
|
729
|
+
|
|
730
|
+
**Solution:**
|
|
731
|
+
|
|
732
|
+
- Monitor cache size
|
|
733
|
+
- Implement LRU cache with size limit
|
|
734
|
+
- Use consistent identifier format
|
|
735
|
+
|
|
736
|
+
## Performance Considerations
|
|
737
|
+
|
|
738
|
+
### Rate Limiting Overhead
|
|
739
|
+
|
|
740
|
+
- **Redis latency**: ~2-5ms per rate limit check
|
|
741
|
+
- **Cache hit rate**: ~60-80% for typical usage
|
|
742
|
+
- **Timeout**: 5 seconds (configurable)
|
|
743
|
+
|
|
744
|
+
### Optimization Tips
|
|
745
|
+
|
|
746
|
+
1. **Use cache**: `getRateLimitInfo()` caches for 1 second
|
|
747
|
+
2. **Batch requests**: Check rate limit once per session
|
|
748
|
+
3. **Use sliding window**: More efficient than token bucket
|
|
749
|
+
4. **Pre-warm cache**: Prime cache on server start
|
|
750
|
+
|
|
751
|
+
## Migration Guide
|
|
752
|
+
|
|
753
|
+
### From v1 to v2
|
|
754
|
+
|
|
755
|
+
**Breaking Changes:**
|
|
756
|
+
|
|
757
|
+
1. `secure()` now throws `SecurityDenialError` instead of returning boolean
|
|
758
|
+
2. Rate limiters require Redis configuration in production
|
|
759
|
+
3. Environment variables must follow new validation schema
|
|
760
|
+
|
|
761
|
+
**Migration Steps:**
|
|
762
|
+
|
|
763
|
+
```typescript
|
|
764
|
+
// Before (v1)
|
|
765
|
+
const isAllowed = await secure(["GOOGLEBOT"], request);
|
|
766
|
+
if (!isAllowed) {
|
|
767
|
+
return new Response("Denied", { status: 403 });
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// After (v2)
|
|
771
|
+
try {
|
|
772
|
+
await secure(["GOOGLEBOT"], request);
|
|
773
|
+
} catch (error) {
|
|
774
|
+
if (error instanceof SecurityDenialError) {
|
|
775
|
+
return new Response("Denied", { status: 403 });
|
|
776
|
+
}
|
|
777
|
+
throw error;
|
|
778
|
+
}
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
## Related Packages
|
|
782
|
+
|
|
783
|
+
- `@repo/auth` - Authentication package (uses `@repo/security` for rate limiting)
|
|
784
|
+
- `@repo/db-upstash-redis` - Shared Redis client
|
|
785
|
+
- `@arcjet/next` - Arcjet Next.js SDK
|
|
786
|
+
- `@nosecone/next` - Nosecone security headers
|
|
787
|
+
- `@upstash/ratelimit` - Upstash rate limiting SDK
|
|
788
|
+
|
|
789
|
+
## Additional Resources
|
|
790
|
+
|
|
791
|
+
- [Arcjet Documentation](https://docs.arcjet.com)
|
|
792
|
+
- [Nosecone Documentation](https://docs.arcjet.com/nosecone/quick-start)
|
|
793
|
+
- [Upstash Rate Limiting](https://upstash.com/docs/oss/sdks/ts/ratelimit/overview)
|
|
794
|
+
- [Security Best Practices](../../apps/docs/packages/security.mdx)
|
|
795
|
+
|
|
796
|
+
## License
|
|
797
|
+
|
|
798
|
+
Private package - not for external distribution.
|
|
799
|
+
|
|
800
|
+
## 📚 Comprehensive Documentation
|
|
801
|
+
|
|
802
|
+
For detailed documentation, see:
|
|
803
|
+
|
|
804
|
+
- **[Audit Reports](../../apps/docs/content/docs/audits/security/)** - Comprehensive audits, fixes, and security reviews
|
|
805
|
+
- **[Technical Guides](../../apps/docs/content/docs/packages/security/)** - Implementation guides and best practices
|
|
806
|
+
|
|
807
|
+
All comprehensive documentation has been centralized in the docs app.
|