@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.
@@ -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';