@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
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Server-side security exports for Next.js
|
|
3
|
+
*
|
|
4
|
+
* This file provides server-side security functionality specifically for Next.js applications.
|
|
5
|
+
* Includes Arcjet integration for bot detection, rate limiting, and Shield protection.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Bot detection and filtering
|
|
9
|
+
* - Rate limiting
|
|
10
|
+
* - Shield protection against common attacks
|
|
11
|
+
* - Security denial error handling
|
|
12
|
+
*
|
|
13
|
+
* @module @od-oneapp/security/server/next
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
arcjet,
|
|
18
|
+
type ArcjetBotCategory,
|
|
19
|
+
type ArcjetWellKnownBot,
|
|
20
|
+
detectBot,
|
|
21
|
+
request,
|
|
22
|
+
shield,
|
|
23
|
+
} from '@integrations/arcjet/security-client';
|
|
24
|
+
|
|
25
|
+
import { getLogger, safeEnv } from '../env';
|
|
26
|
+
|
|
27
|
+
import type { NextRequest } from 'next/server';
|
|
28
|
+
import 'server-only';
|
|
29
|
+
|
|
30
|
+
// Re-export server functionality (explicit exports to avoid circular dependencies)
|
|
31
|
+
export { createRateLimiter, env, safeEnv } from './server';
|
|
32
|
+
|
|
33
|
+
// Re-export rate limiting functionality
|
|
34
|
+
export {
|
|
35
|
+
applyRateLimit,
|
|
36
|
+
fixedWindow,
|
|
37
|
+
getRateLimitInfo,
|
|
38
|
+
hashIdentifier,
|
|
39
|
+
isRateLimited,
|
|
40
|
+
rateLimitConfigs,
|
|
41
|
+
rateLimiters,
|
|
42
|
+
slidingWindow,
|
|
43
|
+
tokenBucket,
|
|
44
|
+
type RateLimitResult,
|
|
45
|
+
} from '../rate-limit';
|
|
46
|
+
|
|
47
|
+
// Re-export middleware utilities for Next.js
|
|
48
|
+
export { noseconeMiddleware, noseconeOptions } from '../middleware';
|
|
49
|
+
|
|
50
|
+
// Re-export environment helpers
|
|
51
|
+
export { getLogger, hasArcjetConfig, hasUpstashConfig, isProduction, setLogger } from '../env';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Custom error class for security denials.
|
|
55
|
+
*
|
|
56
|
+
* @remarks
|
|
57
|
+
* Thrown when a request is denied by Arcjet security checks (bot detection,
|
|
58
|
+
* rate limiting, or Shield protection). Includes detailed metadata for logging
|
|
59
|
+
* and debugging. The `reason` field indicates the type of denial, allowing
|
|
60
|
+
* for specific error handling logic.
|
|
61
|
+
*
|
|
62
|
+
* **Denial Reasons**:
|
|
63
|
+
* - `'bot'`: Request was identified as a bot and blocked
|
|
64
|
+
* - `'rate_limit'`: Request exceeded rate limits
|
|
65
|
+
* - `'shield'`: Request triggered Shield protection (malicious pattern detected)
|
|
66
|
+
* - `'unknown'`: Request was denied for an unspecified reason
|
|
67
|
+
*
|
|
68
|
+
* **Metadata Fields**:
|
|
69
|
+
* - `ip`: Client IP address (useful for blocking/whitelisting)
|
|
70
|
+
* - `path`: Request path (useful for route-specific logging)
|
|
71
|
+
* - `userAgent`: User agent string (useful for bot identification)
|
|
72
|
+
* - `decisionId`: Arcjet decision ID (useful for support/debugging)
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```typescript
|
|
76
|
+
* import { secure, SecurityDenialError } from '@od-oneapp/security/server/next';
|
|
77
|
+
*
|
|
78
|
+
* try {
|
|
79
|
+
* await secure([], request); // Block all bots
|
|
80
|
+
* } catch (error) {
|
|
81
|
+
* if (error instanceof SecurityDenialError) {
|
|
82
|
+
* console.log(`Denied: ${error.reason}`, error.metadata);
|
|
83
|
+
* return new Response('Access denied', { status: 403 });
|
|
84
|
+
* }
|
|
85
|
+
* }
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```typescript
|
|
90
|
+
* // Handle specific denial reasons
|
|
91
|
+
* catch (error) {
|
|
92
|
+
* if (error instanceof SecurityDenialError) {
|
|
93
|
+
* if (error.reason === 'rate_limit') {
|
|
94
|
+
* return new Response('Too many requests', { status: 429 });
|
|
95
|
+
* }
|
|
96
|
+
* return new Response('Forbidden', { status: 403 });
|
|
97
|
+
* }
|
|
98
|
+
* }
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export class SecurityDenialError extends Error {
|
|
102
|
+
/**
|
|
103
|
+
* Creates a new SecurityDenialError.
|
|
104
|
+
*
|
|
105
|
+
* @param message - Human-readable error message
|
|
106
|
+
* @param reason - Type of security denial
|
|
107
|
+
* @param metadata - Optional metadata about the denied request
|
|
108
|
+
*/
|
|
109
|
+
constructor(
|
|
110
|
+
message: string,
|
|
111
|
+
public readonly reason: 'bot' | 'rate_limit' | 'shield' | 'unknown',
|
|
112
|
+
public readonly metadata?: {
|
|
113
|
+
/** Client IP address. */
|
|
114
|
+
ip?: string;
|
|
115
|
+
/** Request path. */
|
|
116
|
+
path?: string;
|
|
117
|
+
/** User agent string. */
|
|
118
|
+
userAgent?: string;
|
|
119
|
+
/** Arcjet decision ID for debugging. */
|
|
120
|
+
decisionId?: string;
|
|
121
|
+
},
|
|
122
|
+
) {
|
|
123
|
+
super(message);
|
|
124
|
+
this.name = 'SecurityDenialError';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Secure a request using Arcjet bot detection and Shield protection.
|
|
130
|
+
*
|
|
131
|
+
* @remarks
|
|
132
|
+
* This function provides comprehensive security protection for Next.js routes:
|
|
133
|
+
* - **Bot Detection**: Blocks or allows bots based on the `allow` parameter
|
|
134
|
+
* - **Shield Protection**: Protects against common attacks (SQL injection, XSS, etc.)
|
|
135
|
+
* - **Rate Limiting**: Integrated rate limiting via Arcjet Shield
|
|
136
|
+
*
|
|
137
|
+
* The function uses Arcjet's decision system to evaluate requests. If a request
|
|
138
|
+
* is denied, a `SecurityDenialError` is thrown with detailed metadata for logging.
|
|
139
|
+
*
|
|
140
|
+
* **Error Handling**:
|
|
141
|
+
* - Network errors in development allow requests through with warnings (fail-open)
|
|
142
|
+
* - Non-network errors and all production errors fail closed for security
|
|
143
|
+
* - Detailed error logging helps debug configuration issues
|
|
144
|
+
*
|
|
145
|
+
* **Performance**: This function makes an external API call to Arcjet, so it
|
|
146
|
+
* adds latency. Consider caching decisions for high-traffic routes.
|
|
147
|
+
*
|
|
148
|
+
* @param allow - Array of bot categories or well-known bots to allow.
|
|
149
|
+
* Use empty array to block all bots. Common values:
|
|
150
|
+
* - `'GOOGLEBOT'`, `'BINGBOT'` for search engines
|
|
151
|
+
* - `'FACEBOOKBOT'`, `'TWITTERBOT'` for social media crawlers
|
|
152
|
+
* - `'AUTOMATED'` for all automated bots
|
|
153
|
+
* @param sourceRequest - Optional Next.js request object. If not provided,
|
|
154
|
+
* will use Arcjet's request() helper to get the current request.
|
|
155
|
+
* @returns Promise that resolves if request is allowed
|
|
156
|
+
* @throws {SecurityDenialError} If request is denied (bot, rate limit, or shield protection)
|
|
157
|
+
* @throws {Error} If security check fails in production or for non-network errors in development
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```typescript
|
|
161
|
+
* // Allow only Google and Bing bots
|
|
162
|
+
* await secure(['GOOGLEBOT', 'BINGBOT'], request);
|
|
163
|
+
* ```
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```typescript
|
|
167
|
+
* // Block all bots
|
|
168
|
+
* await secure([], request);
|
|
169
|
+
* ```
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* ```typescript
|
|
173
|
+
* // In a Next.js API route handler
|
|
174
|
+
* import { secure } from '@od-oneapp/security/server/next';
|
|
175
|
+
* import { NextRequest } from 'next/server';
|
|
176
|
+
*
|
|
177
|
+
* export async function POST(request: NextRequest) {
|
|
178
|
+
* try {
|
|
179
|
+
* await secure([], request); // Block all bots
|
|
180
|
+
* // Process request...
|
|
181
|
+
* } catch (error) {
|
|
182
|
+
* if (error instanceof SecurityDenialError) {
|
|
183
|
+
* return Response.json(
|
|
184
|
+
* { error: 'Access denied' },
|
|
185
|
+
* { status: 403 }
|
|
186
|
+
* );
|
|
187
|
+
* }
|
|
188
|
+
* throw error;
|
|
189
|
+
* }
|
|
190
|
+
* }
|
|
191
|
+
* ```
|
|
192
|
+
*/
|
|
193
|
+
export const secure = async (
|
|
194
|
+
allow: (ArcjetBotCategory | ArcjetWellKnownBot)[],
|
|
195
|
+
sourceRequest?: NextRequest | Request,
|
|
196
|
+
): Promise<void> => {
|
|
197
|
+
const env = safeEnv();
|
|
198
|
+
const arcjetKey = env.ARCJET_KEY;
|
|
199
|
+
|
|
200
|
+
if (!arcjetKey) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const base = arcjet({
|
|
206
|
+
// Identify the user by their IP address
|
|
207
|
+
characteristics: ['ip.src'],
|
|
208
|
+
// Get your site key from https://app.arcjet.com
|
|
209
|
+
key: arcjetKey,
|
|
210
|
+
rules: [
|
|
211
|
+
// Protect against common attacks with Arcjet Shield
|
|
212
|
+
shield({
|
|
213
|
+
// Will block requests. Use "DRY_RUN" to log only
|
|
214
|
+
mode: 'LIVE',
|
|
215
|
+
}),
|
|
216
|
+
// Other rules are added in different routes
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const req = sourceRequest ?? (await request());
|
|
221
|
+
const aj = base.withRule(detectBot({ allow, mode: 'LIVE' }));
|
|
222
|
+
const decision = await aj.protect(req);
|
|
223
|
+
|
|
224
|
+
if (decision.isDenied()) {
|
|
225
|
+
// Extract metadata for logging
|
|
226
|
+
const getIp = (request: NextRequest | Request): string => {
|
|
227
|
+
if ('ip' in request && request.ip && typeof request.ip === 'string') {
|
|
228
|
+
return request.ip;
|
|
229
|
+
}
|
|
230
|
+
const headers = 'headers' in request ? request.headers : null;
|
|
231
|
+
if (headers && typeof headers.get === 'function') {
|
|
232
|
+
const forwardedFor = headers.get('x-forwarded-for');
|
|
233
|
+
if (forwardedFor) {
|
|
234
|
+
return forwardedFor.split(',')[0]?.trim() ?? 'unknown';
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return 'unknown';
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const getHeader = (name: string): string => {
|
|
241
|
+
const headers = 'headers' in req ? req.headers : null;
|
|
242
|
+
if (headers && typeof headers.get === 'function') {
|
|
243
|
+
return headers.get(name) ?? 'unknown';
|
|
244
|
+
}
|
|
245
|
+
return 'unknown';
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const metadata = {
|
|
249
|
+
ip: getIp(req as NextRequest | Request),
|
|
250
|
+
path: req.url ? new URL(req.url).pathname : 'unknown',
|
|
251
|
+
userAgent: getHeader('user-agent'),
|
|
252
|
+
decisionId: decision.id,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
if (decision.reason.isBot()) {
|
|
256
|
+
throw new SecurityDenialError(
|
|
257
|
+
`Bot access denied: ${metadata.ip} attempted to access ${metadata.path}`,
|
|
258
|
+
'bot',
|
|
259
|
+
metadata,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (decision.reason.isRateLimit()) {
|
|
264
|
+
throw new SecurityDenialError(
|
|
265
|
+
`Rate limit exceeded: ${metadata.ip} exceeded limits for ${metadata.path}`,
|
|
266
|
+
'rate_limit',
|
|
267
|
+
metadata,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (decision.reason.isShield()) {
|
|
272
|
+
throw new SecurityDenialError(
|
|
273
|
+
`Shield protection triggered: ${metadata.ip} blocked from ${metadata.path}`,
|
|
274
|
+
'shield',
|
|
275
|
+
metadata,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
throw new SecurityDenialError(
|
|
280
|
+
`Access denied: ${metadata.ip} denied access to ${metadata.path}`,
|
|
281
|
+
'unknown',
|
|
282
|
+
metadata,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error instanceof SecurityDenialError) {
|
|
287
|
+
getLogger().error('Arcjet protection denied request', {
|
|
288
|
+
error: error.message,
|
|
289
|
+
nodeEnv: env.NODE_ENV,
|
|
290
|
+
reason: error.reason,
|
|
291
|
+
decisionId: error.metadata?.decisionId,
|
|
292
|
+
});
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Categorize error type for better handling
|
|
297
|
+
// Prefer robust error.code check (Node.js standard) before falling back to message matching
|
|
298
|
+
// This handles both Node.js network errors (with error.code) and fetch/Arcjet errors
|
|
299
|
+
// (which may only have error.message). The dual approach ensures we catch network failures
|
|
300
|
+
// reliably across different error sources.
|
|
301
|
+
//
|
|
302
|
+
// Note: In production, all errors result in fail-closed behavior for security.
|
|
303
|
+
// In development, only confirmed network errors allow requests through (with warnings).
|
|
304
|
+
// Non-network errors fail closed even in development to surface configuration issues early.
|
|
305
|
+
const isNetworkError =
|
|
306
|
+
error instanceof Error &&
|
|
307
|
+
(('code' in error &&
|
|
308
|
+
typeof (error as { code?: unknown }).code === 'string' &&
|
|
309
|
+
((error as { code: string }).code === 'ECONNREFUSED' ||
|
|
310
|
+
(error as { code: string }).code === 'ETIMEDOUT' ||
|
|
311
|
+
(error as { code: string }).code === 'ENOTFOUND')) ||
|
|
312
|
+
// Fallback to message matching for fetch/Arcjet errors without error.code
|
|
313
|
+
error.message.includes('ECONNREFUSED') ||
|
|
314
|
+
error.message.includes('ETIMEDOUT') ||
|
|
315
|
+
error.message.includes('ENOTFOUND') ||
|
|
316
|
+
error.message.includes('fetch failed') ||
|
|
317
|
+
error.message.includes('network'));
|
|
318
|
+
|
|
319
|
+
const errorContext = {
|
|
320
|
+
error: error instanceof Error ? error.message : String(error),
|
|
321
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
322
|
+
nodeEnv: env.NODE_ENV,
|
|
323
|
+
errorType: isNetworkError ? 'network' : 'unknown',
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
getLogger().error('Arcjet protection failed', errorContext);
|
|
327
|
+
|
|
328
|
+
// In production, always fail closed for security
|
|
329
|
+
if (env.NODE_ENV === 'production') {
|
|
330
|
+
throw new Error('Security check failed. Request denied.');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// In development, fail closed for non-network errors
|
|
334
|
+
if (!isNetworkError) {
|
|
335
|
+
getLogger().warn('Failing closed in development for non-network error');
|
|
336
|
+
throw new Error('Security check failed. See logs for details.');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Development + network error: allow with warning
|
|
340
|
+
getLogger().warn('Allowing request in development due to network error', {
|
|
341
|
+
error: error instanceof Error ? error.message : String(error),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// Re-export Arcjet types for Next.js
|
|
347
|
+
export type { ArcjetBotCategory, ArcjetWellKnownBot };
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Server-side security exports (non-Next.js)
|
|
3
|
+
*
|
|
4
|
+
* This file provides server-side security functionality for non-Next.js environments.
|
|
5
|
+
* For Next.js applications, use '@repo/security/server/next' instead.
|
|
6
|
+
*
|
|
7
|
+
* @module @repo/security/server
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Re-export rate limiting functionality
|
|
11
|
+
export { createRateLimiter } from '../rate-limit';
|
|
12
|
+
|
|
13
|
+
// Re-export env helpers for server use
|
|
14
|
+
export { env, safeEnv } from '../env';
|