@nexus_js/server 0.9.29 → 0.9.30

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.
Files changed (80) hide show
  1. package/dist/actions.d.ts +71 -11
  2. package/dist/actions.d.ts.map +1 -1
  3. package/dist/actions.js +442 -51
  4. package/dist/actions.js.map +1 -1
  5. package/dist/build-id.d.ts +14 -0
  6. package/dist/build-id.d.ts.map +1 -0
  7. package/dist/build-id.js +40 -0
  8. package/dist/build-id.js.map +1 -0
  9. package/dist/context.d.ts +38 -4
  10. package/dist/context.d.ts.map +1 -1
  11. package/dist/context.js +13 -3
  12. package/dist/context.js.map +1 -1
  13. package/dist/csrf.d.ts +16 -2
  14. package/dist/csrf.d.ts.map +1 -1
  15. package/dist/csrf.js +68 -30
  16. package/dist/csrf.js.map +1 -1
  17. package/dist/dev-assets.d.ts +31 -0
  18. package/dist/dev-assets.d.ts.map +1 -1
  19. package/dist/dev-assets.js +372 -38
  20. package/dist/dev-assets.js.map +1 -1
  21. package/dist/dev-assets.test.d.ts +2 -0
  22. package/dist/dev-assets.test.d.ts.map +1 -0
  23. package/dist/dev-error-html.d.ts.map +1 -1
  24. package/dist/dev-error-html.js +24 -0
  25. package/dist/dev-error-html.js.map +1 -1
  26. package/dist/devradar.d.ts +1 -1
  27. package/dist/devradar.d.ts.map +1 -1
  28. package/dist/devradar.js.map +1 -1
  29. package/dist/head-renderer.test.d.ts +2 -0
  30. package/dist/head-renderer.test.d.ts.map +1 -0
  31. package/dist/head-renderer.test.js +78 -0
  32. package/dist/head-renderer.test.js.map +1 -0
  33. package/dist/index.d.ts +97 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +442 -47
  36. package/dist/index.js.map +1 -1
  37. package/dist/legacy-wrapper.d.ts +88 -0
  38. package/dist/legacy-wrapper.d.ts.map +1 -0
  39. package/dist/legacy-wrapper.js +104 -0
  40. package/dist/legacy-wrapper.js.map +1 -0
  41. package/dist/lib-assets.d.ts +5 -0
  42. package/dist/lib-assets.d.ts.map +1 -0
  43. package/dist/lib-assets.js +95 -0
  44. package/dist/lib-assets.js.map +1 -0
  45. package/dist/load-module.d.ts +6 -0
  46. package/dist/load-module.d.ts.map +1 -1
  47. package/dist/load-module.js +40 -53
  48. package/dist/load-module.js.map +1 -1
  49. package/dist/metadata.d.ts +95 -0
  50. package/dist/metadata.d.ts.map +1 -0
  51. package/dist/metadata.js +132 -0
  52. package/dist/metadata.js.map +1 -0
  53. package/dist/navigate.d.ts +0 -5
  54. package/dist/navigate.d.ts.map +1 -1
  55. package/dist/navigate.js +0 -1
  56. package/dist/navigate.js.map +1 -1
  57. package/dist/rate-limit.d.ts.map +1 -1
  58. package/dist/rate-limit.js +27 -14
  59. package/dist/rate-limit.js.map +1 -1
  60. package/dist/renderer.d.ts +27 -7
  61. package/dist/renderer.d.ts.map +1 -1
  62. package/dist/renderer.js +152 -25
  63. package/dist/renderer.js.map +1 -1
  64. package/dist/renderer.test.d.ts +2 -0
  65. package/dist/renderer.test.d.ts.map +1 -0
  66. package/dist/renderer.test.js +251 -0
  67. package/dist/renderer.test.js.map +1 -0
  68. package/dist/streaming.d.ts +3 -3
  69. package/dist/streaming.d.ts.map +1 -1
  70. package/dist/streaming.js +33 -13
  71. package/dist/streaming.js.map +1 -1
  72. package/dist/tenancy.d.ts +17 -0
  73. package/dist/tenancy.d.ts.map +1 -0
  74. package/dist/tenancy.js +132 -0
  75. package/dist/tenancy.js.map +1 -0
  76. package/dist/tenancy.test.d.ts +2 -0
  77. package/dist/tenancy.test.d.ts.map +1 -0
  78. package/dist/tenancy.test.js +38 -0
  79. package/dist/tenancy.test.js.map +1 -0
  80. package/package.json +26 -8
package/dist/actions.js CHANGED
@@ -30,8 +30,10 @@
30
30
  import { createContext, NotFoundSignal, RedirectSignal } from './context.js';
31
31
  import { serialize, deserialize } from '@nexus_js/serialize';
32
32
  import { validateActionToken, extractSessionId, ACTION_TOKEN_HEADER, } from './csrf.js';
33
- import { createRateLimiter, registerLimiter, RateLimitError, } from './rate-limit.js';
33
+ import { createRateLimiter, registerLimiter, getLimiter, RateLimitError, } from './rate-limit.js';
34
+ import { randomUUID, createHmac, timingSafeEqual } from 'node:crypto';
34
35
  import { emitDevRadar, newTraceId, sanitizeTelemetryValue } from './devradar.js';
36
+ import { getExpectedNexusBuildId } from './build-id.js';
35
37
  const ACTION_PREFIX = '/_nexus/action/';
36
38
  const idempotencyCache = new Map();
37
39
  const IDEMPOTENCY_TTL = 30_000; // 30 seconds
@@ -40,14 +42,49 @@ const IDEMPOTENCY_TTL = 30_000; // 30 seconds
40
42
  const inFlightActions = new Map();
41
43
  const actionQueues = new Map();
42
44
  const actionRegistry = new Map();
45
+ /**
46
+ * Generates an HMAC-SHA256 signature for an action name.
47
+ * Used to sign action URLs at SSR time so the server can verify they originated
48
+ * from a server-rendered page — not from an attacker enumerating action names.
49
+ * Skipped in dev mode (NODE_ENV !== 'production').
50
+ */
51
+ function signActionName(name) {
52
+ if (process.env['NODE_ENV'] !== 'production')
53
+ return null; // dev mode — no signing
54
+ const secret = process.env['NEXUS_SECRET'];
55
+ if (!secret)
56
+ return null; // no secret — skip signing
57
+ return createHmac('sha256', secret).update(`action:${name}`).digest('base64url').slice(0, 16);
58
+ }
59
+ /**
60
+ * Verifies an action name signature. Returns true if the signature is valid or
61
+ * if we are in dev mode (NODE_ENV !== 'production' — signature is optional in dev).
62
+ */
63
+ export function verifyActionSig(name, sig) {
64
+ const expected = signActionName(name);
65
+ if (expected === null)
66
+ return true; // dev mode — skip verification
67
+ if (!sig)
68
+ return false;
69
+ // Constant-time comparison (sig is 16 base64url chars = 96 bits)
70
+ try {
71
+ return timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
43
77
  /**
44
78
  * SSR and islands coerce `action={myAction}` with `String(myAction)`. The value is the
45
79
  * wrapper from `createAction`, whose default `Function#toString()` is the entire wrapper
46
80
  * source (CSRF, rate limit, …) — that string was being used as the form URL. After
47
81
  * registration we pin `toString` / `@@toPrimitive` to the real POST path.
82
+ * In production (NEXUS_SECRET is set), the URL carries an HMAC query param
83
+ * (`?__sig=…`) so the server can reject calls with unsigned / forged action URLs.
48
84
  */
49
85
  function patchRegisteredActionStringCoercion(fn, name) {
50
- const url = ACTION_PREFIX + name;
86
+ const sig = signActionName(name);
87
+ const url = sig ? `${ACTION_PREFIX}${name}?__sig=${sig}` : `${ACTION_PREFIX}${name}`;
51
88
  Object.defineProperty(fn, 'toString', {
52
89
  value: function nexusActionUrlString() {
53
90
  return url;
@@ -73,7 +110,7 @@ function patchRegisteredActionStringCoercion(fn, name) {
73
110
  * and ready to be called by the client.
74
111
  *
75
112
  * Security layers applied (in order):
76
- * 1. CSRF token validation (x-nexus-action-token header)
113
+ * 1. CSRF: custom header `x-nexus-action: 1` (Tier 1) + optional HMAC token (Tier 2)
77
114
  * 2. Rate limiting (sliding window, per-IP or per-user)
78
115
  * 3. Input schema validation (Zod or any .parse() compatible schema)
79
116
  * 4. AbortController (client disconnect + timeout)
@@ -109,14 +146,33 @@ export function createAction(optsOrFn, legacyOpts = {}) {
109
146
  throw new RateLimitError(result);
110
147
  }
111
148
  }
112
- // 3. Schema validation
149
+ // 3. Schema validation — fail-fast before any business logic or DB access
113
150
  if (opts.schema) {
114
- try {
115
- input = opts.schema.parse(input);
151
+ if (typeof opts.schema.safeParse === 'function') {
152
+ // Structured path: extract field-level errors for the client
153
+ const result = opts.schema.safeParse(input);
154
+ if (!result.success) {
155
+ const issues = result.error?.issues ?? [];
156
+ const fieldErrors = {};
157
+ for (const issue of issues) {
158
+ const path = issue.path.join('.') || '_root';
159
+ fieldErrors[path] = issue.message;
160
+ }
161
+ throw new ActionError(issues.length > 0
162
+ ? `Validation failed: ${issues.map((i) => `${i.path.join('.') || 'input'} — ${i.message}`).join('; ')}`
163
+ : 'Input validation failed', 400, 'VALIDATION_ERROR', fieldErrors);
164
+ }
165
+ if (result.data !== undefined)
166
+ input = result.data;
116
167
  }
117
- catch (err) {
118
- const msg = err instanceof Error ? err.message : 'Input validation failed';
119
- throw new ActionError(`Invalid input: ${msg}`, 400, 'VALIDATION_ERROR');
168
+ else {
169
+ try {
170
+ input = opts.schema.parse(input);
171
+ }
172
+ catch (err) {
173
+ const msg = err instanceof Error ? err.message : 'Input validation failed';
174
+ throw new ActionError(`Invalid input: ${msg}`, 400, 'VALIDATION_ERROR');
175
+ }
120
176
  }
121
177
  }
122
178
  return fn(input, ctx);
@@ -136,11 +192,25 @@ export function getRegisteredActionNames() {
136
192
  export class ActionError extends Error {
137
193
  status;
138
194
  code;
139
- constructor(message, status = 400, code) {
195
+ fieldErrors;
196
+ constructor(message, optionsOrStatus, code, fieldErrors) {
140
197
  super(message);
141
- this.status = status;
142
- this.code = code;
143
198
  this.name = 'ActionError';
199
+ // Support both positional and options object formats
200
+ if (typeof optionsOrStatus === 'object') {
201
+ this.status = optionsOrStatus.status ?? 400;
202
+ if (optionsOrStatus.code !== undefined)
203
+ this.code = optionsOrStatus.code;
204
+ if (optionsOrStatus.fieldErrors !== undefined)
205
+ this.fieldErrors = optionsOrStatus.fieldErrors;
206
+ }
207
+ else {
208
+ this.status = optionsOrStatus ?? 400;
209
+ if (code !== undefined)
210
+ this.code = code;
211
+ if (fieldErrors !== undefined)
212
+ this.fieldErrors = fieldErrors;
213
+ }
144
214
  }
145
215
  }
146
216
  export class ActionAbortedError extends ActionError {
@@ -148,6 +218,41 @@ export class ActionAbortedError extends ActionError {
148
218
  super('Action was superseded by a newer request', 409, 'ABORTED');
149
219
  }
150
220
  }
221
+ /**
222
+ * Detects whether a request originates from a plain HTML form (no JS).
223
+ *
224
+ * A "native form" request:
225
+ * - Has `Content-Type: application/x-www-form-urlencoded`
226
+ * - Does NOT have the `x-nexus-action` custom header (added by the JS runtime)
227
+ * - Does NOT explicitly request `application/json` in Accept
228
+ *
229
+ * When true, the action handler responds with 303 redirects instead of JSON
230
+ * (Progressive Enhancement — the action works even when JS is disabled).
231
+ */
232
+ function isNativeFormRequest(request) {
233
+ const ct = request.headers.get('content-type') ?? '';
234
+ const hasNexusHeader = request.headers.has('x-nexus-action');
235
+ const acceptsJson = (request.headers.get('accept') ?? '').includes('application/json');
236
+ return (ct.includes('application/x-www-form-urlencoded') &&
237
+ !hasNexusHeader &&
238
+ !acceptsJson);
239
+ }
240
+ /**
241
+ * Builds a 303 See Other redirect for progressive-enhancement form submissions.
242
+ * Follows the Post/Redirect/Get pattern to prevent duplicate submissions on refresh.
243
+ *
244
+ * Redirect target priority:
245
+ * 1. `__redirectTo` hidden field in the form
246
+ * 2. `Referer` header (the page that submitted the form)
247
+ * 3. `/` as final fallback
248
+ */
249
+ function formRedirect(request, formData, status = 303) {
250
+ const redirectTo = formData.get('__redirectTo') ??
251
+ request.headers.get('referer') ??
252
+ '/';
253
+ const headers = new Headers({ location: redirectTo });
254
+ return new Response(null, { status, headers });
255
+ }
151
256
  /**
152
257
  * Main HTTP handler for /_nexus/action/:name
153
258
  * This is where all the race-condition logic runs.
@@ -163,54 +268,146 @@ export async function handleActionRequest(request) {
163
268
  headers: { allow: 'POST' },
164
269
  });
165
270
  }
166
- const actionName = url.pathname.slice(ACTION_PREFIX.length);
271
+ const nativeForm = isNativeFormRequest(request);
272
+ // ── Guard: opaque (null) Origin ────────────────────────────────────────────
273
+ // Sandboxed iframes (<iframe sandbox> without allow-same-origin) and data:
274
+ // URIs send the string "null" as the Origin header. This is never a
275
+ // legitimate same-origin action call and must be rejected before any CSRF
276
+ // header check — otherwise the custom-header tier can be bypassed.
277
+ const rawOrigin = request.headers.get('origin');
278
+ if (rawOrigin === 'null') {
279
+ emitDevRadar({
280
+ type: 'security:audit',
281
+ payload: { kind: 'csrf_blocked', message: 'Opaque (null) origin rejected', action: '?' },
282
+ });
283
+ return jsonResponse({ error: 'Forbidden: opaque origin', status: 403, code: 'OPAQUE_ORIGIN' }, 403);
284
+ }
285
+ // ── Guard: action name validation ──────────────────────────────────────────
286
+ // Ensure the action name extracted from the URL only contains characters that
287
+ // can appear in a valid registered action name. Rejects path-traversal
288
+ // sequences (../../ etc.) and injection attempts before any registry lookup.
289
+ const rawActionName = url.pathname.slice(ACTION_PREFIX.length);
290
+ if (!/^[\w][\w.-]*$/.test(rawActionName) || rawActionName.includes('..')) {
291
+ return jsonResponse({ error: 'Invalid action name', status: 400, code: 'INVALID_ACTION_NAME' }, 400);
292
+ }
293
+ const actionName = rawActionName;
167
294
  const registered = actionRegistry.get(actionName);
168
295
  if (!registered) {
169
296
  return jsonResponse({ error: `Action "${actionName}" not found`, status: 404 }, 404);
170
297
  }
171
- const { fn, opts } = registered;
172
- const race = opts.race ?? 'cancel';
173
- const timeout = opts.timeout ?? 30_000;
174
- // ── CSRF token validation ──────────────────────────────────────────────────
175
- if (opts.csrf !== false) {
176
- const token = request.headers.get(ACTION_TOKEN_HEADER);
177
- const secret = process.env['NEXUS_SECRET'] ?? 'nexus-dev-secret-change-me';
178
- const sessionId = extractSessionId(request);
179
- if (!token) {
298
+ // ── Action URL signature verification (production only) ───────────────────
299
+ // In production (NEXUS_SECRET is set), action URLs are HMAC-signed during SSR.
300
+ // A request without a valid signature means the URL was manually crafted or
301
+ // the action name was enumerated — not server-rendered.
302
+ // In dev mode (no secret), signature check is skipped so hot-reload works.
303
+ const actionSig = url.searchParams.get('__sig');
304
+ if (!verifyActionSig(actionName, actionSig)) {
305
+ emitDevRadar({
306
+ type: 'security:audit',
307
+ payload: {
308
+ kind: 'csrf_blocked',
309
+ message: 'Action URL signature missing or invalid — possible action enumeration',
310
+ action: actionName,
311
+ },
312
+ });
313
+ return jsonResponse({ error: 'Invalid action URL', status: 403, code: 'INVALID_ACTION_SIG' }, 403);
314
+ }
315
+ // ── Build ID (client–server contract) ─────────────────────────────────────
316
+ // When `.nexus/build-id.json` exists (after `nexus build`), every action call
317
+ // must carry `x-nexus-build-id` matching that file. Stale tabs from a prior
318
+ // deploy get 412 so the runtime can reload and pick up new action signatures.
319
+ const expectedBuildId = getExpectedNexusBuildId();
320
+ if (expectedBuildId !== null) {
321
+ const sent = request.headers.get('x-nexus-build-id') ?? '';
322
+ if (sent !== expectedBuildId) {
180
323
  emitDevRadar({
181
324
  type: 'security:audit',
182
325
  payload: {
183
- kind: 'csrf_blocked',
184
- message: 'Missing action token',
326
+ kind: 'build_mismatch',
327
+ message: 'x-nexus-build-id does not match deployed build',
185
328
  action: actionName,
186
329
  },
187
330
  });
188
331
  return jsonResponse({
189
- error: 'Missing action token possible CSRF attack',
190
- status: 403,
191
- code: 'MISSING_CSRF_TOKEN',
192
- }, 403);
332
+ error: 'Application was updated. Please reload the page.',
333
+ status: 412,
334
+ code: 'BUILD_MISMATCH',
335
+ }, 412);
336
+ }
337
+ }
338
+ const { fn, opts } = registered;
339
+ const race = opts.race ?? 'cancel';
340
+ const timeout = opts.timeout ?? 30_000;
341
+ // ── CSRF protection (dual-tier) ────────────────────────────────────────────
342
+ //
343
+ // Tier 1 — Custom header (default): Browsers cannot add arbitrary headers to
344
+ // cross-origin requests without a CORS preflight the server will reject.
345
+ // Requiring a non-empty `x-nexus-action` header blocks form-based CSRF from
346
+ // foreign origins without needing token generation on the server.
347
+ //
348
+ // Tier 2 — HMAC token (opt-in): When `x-nexus-action-token` is present the
349
+ // server also validates the full HMAC-SHA256 signed, single-use, session-
350
+ // bound token (generated via `generateActionToken`). This additionally
351
+ // protects against same-origin XSS token theft.
352
+ //
353
+ // Flow:
354
+ // token present → Tier 2 full validation (strongest)
355
+ // header present, no token → Tier 1 header check (standard)
356
+ // neither present → 403 CSRF block
357
+ if (opts.csrf !== false) {
358
+ const token = request.headers.get(ACTION_TOKEN_HEADER);
359
+ const nexusHeader = request.headers.get('x-nexus-action');
360
+ if (token) {
361
+ // Tier 2: full HMAC validation
362
+ const envSecret = process.env['NEXUS_SECRET'];
363
+ if (!envSecret && process.env['NODE_ENV'] === 'production') {
364
+ // listen() already blocks start without NEXUS_SECRET; this catches
365
+ // edge-runtimes / tests that bypass createNexusServer.
366
+ return jsonResponse({ error: 'Server misconfiguration: NEXUS_SECRET not set', status: 500, code: 'INSECURE_SECRET' }, 500);
367
+ }
368
+ const secret = envSecret ?? 'nexus-dev-secret-change-me';
369
+ const sessionId = extractSessionId(request);
370
+ const validation = validateActionToken(token, sessionId, actionName, secret);
371
+ if (!validation.valid) {
372
+ emitDevRadar({
373
+ type: 'security:audit',
374
+ payload: {
375
+ kind: validation.replayed ? 'replay' : 'csrf_blocked',
376
+ message: validation.reason ?? 'Invalid action token',
377
+ action: actionName,
378
+ },
379
+ });
380
+ return jsonResponse({
381
+ error: validation.reason ?? 'Invalid action token',
382
+ status: 403,
383
+ code: validation.replayed ? 'REPLAY_ATTACK' : 'INVALID_CSRF_TOKEN',
384
+ }, 403);
385
+ }
193
386
  }
194
- const validation = validateActionToken(token, sessionId, actionName, secret);
195
- if (!validation.valid) {
387
+ else if (!nexusHeader) {
388
+ // Neither token nor custom header → CSRF attack or missing client header
196
389
  emitDevRadar({
197
390
  type: 'security:audit',
198
391
  payload: {
199
- kind: validation.replayed ? 'replay' : 'csrf_blocked',
200
- message: validation.reason ?? 'Invalid action token',
392
+ kind: 'csrf_blocked',
393
+ message: 'Missing x-nexus-action header — possible CSRF attack',
201
394
  action: actionName,
202
395
  },
203
396
  });
204
397
  return jsonResponse({
205
- error: validation.reason ?? 'Invalid action token',
398
+ error: 'Missing x-nexus-action header — possible CSRF attack',
206
399
  status: 403,
207
- code: validation.replayed ? 'REPLAY_ATTACK' : 'INVALID_CSRF_TOKEN',
400
+ code: 'MISSING_CSRF_HEADER',
208
401
  }, 403);
209
402
  }
403
+ // Tier 1 satisfied: nexusHeader present, no token → header-based CSRF protection
210
404
  }
211
405
  // ── Rate limiting ──────────────────────────────────────────────────────────
212
- if (opts.rateLimit) {
213
- const limiter = createRateLimiter(opts.rateLimit);
406
+ // Use the pre-registered limiter (created once at registerAction time).
407
+ // Previously, a new limiter instance was created per-request, which reset
408
+ // the sliding-window state and made rate limiting completely ineffective.
409
+ const limiter = getLimiter(actionName);
410
+ if (limiter) {
214
411
  const result = limiter.check(request);
215
412
  if (!result.allowed) {
216
413
  emitDevRadar({
@@ -294,7 +491,7 @@ export async function handleActionRequest(request) {
294
491
  const startTime = Date.now();
295
492
  try {
296
493
  // Deserialize input using Nexus transport (preserves Date, Map, Set, etc.)
297
- const input = await deserializeInput(request);
494
+ const input = await deserializeInput(request, opts.maxBodyBytes ?? MAX_ACTION_BODY_BYTES);
298
495
  emitDevRadar({
299
496
  type: 'action:call',
300
497
  payload: {
@@ -348,15 +545,26 @@ export async function handleActionRequest(request) {
348
545
  // Cleanup old entries periodically
349
546
  cleanIdempotencyCache();
350
547
  }
548
+ // Progressive Enhancement: native HTML form → Post/Redirect/Get pattern
549
+ if (nativeForm) {
550
+ const fd = input instanceof FormData ? input : new FormData();
551
+ return formRedirect(request, fd);
552
+ }
351
553
  // Serialize response with Nexus transport
352
554
  const serialized = serialize({ data: result, status: 200, duration, idempotencyKey });
555
+ // Merge response headers from context (includes Set-Cookie from ctx.setCookie)
556
+ const responseHeaders = new Headers({
557
+ 'content-type': 'application/json',
558
+ 'x-nexus-duration': String(duration),
559
+ ...(idempotencyKey ? { 'x-nexus-idempotency': idempotencyKey } : {}),
560
+ });
561
+ // Copy headers from context (cookies, custom headers, etc.)
562
+ ctxWithSignal._responseHeaders.forEach((value, key) => {
563
+ responseHeaders.append(key, value);
564
+ });
353
565
  return new Response(serialized, {
354
566
  status: 200,
355
- headers: {
356
- 'content-type': 'application/json',
357
- 'x-nexus-duration': String(duration),
358
- ...(idempotencyKey ? { 'x-nexus-idempotency': idempotencyKey } : {}),
359
- },
567
+ headers: responseHeaders,
360
568
  });
361
569
  }
362
570
  catch (err) {
@@ -397,19 +605,50 @@ export async function handleActionRequest(request) {
397
605
  ...(err.code !== undefined ? { code: err.code } : {}),
398
606
  },
399
607
  });
400
- return jsonResponse({ error: err.message, status: err.status, code: err.code }, err.status);
608
+ // Progressive Enhancement: redirect back with error encoded in query string
609
+ if (nativeForm) {
610
+ const referer = request.headers.get('referer') ?? '/';
611
+ const target = new URL(referer, request.url);
612
+ target.searchParams.set('__nexus_error', err.message);
613
+ if (err.code)
614
+ target.searchParams.set('__nexus_code', err.code);
615
+ return new Response(null, {
616
+ status: 303,
617
+ headers: { location: target.toString() },
618
+ });
619
+ }
620
+ return jsonResponse({
621
+ error: err.message,
622
+ status: err.status,
623
+ code: err.code,
624
+ ...(err.fieldErrors ? { fieldErrors: err.fieldErrors } : {}),
625
+ }, err.status);
401
626
  }
627
+ const errorId = randomUUID();
628
+ const mask = process.env['NEXUS_EXPOSE_ERRORS'] !== 'true' &&
629
+ process.env['NODE_ENV'] === 'production';
630
+ console.error(`[Nexus Action ${errorId}] "${actionName}" failed:`, err);
402
631
  emitDevRadar({
403
632
  type: 'action:error',
404
633
  payload: {
405
634
  id: traceId,
406
635
  name: actionName,
407
- error: err instanceof Error ? err.message : String(err),
636
+ error: mask ? 'Internal Server Error' : (err instanceof Error ? err.message : String(err)),
408
637
  duration: Date.now() - startTime,
638
+ ...(mask ? { errorId } : {}),
409
639
  },
410
640
  });
411
- console.error(`[Nexus Action] "${actionName}" failed:`, err);
412
- return jsonResponse({ error: 'Internal Server Error', status: 500 }, 500);
641
+ if (mask) {
642
+ return jsonResponse({
643
+ error: 'Internal Server Error',
644
+ status: 500,
645
+ errorId,
646
+ }, 500);
647
+ }
648
+ return jsonResponse({
649
+ error: err instanceof Error ? err.message : String(err),
650
+ status: 500,
651
+ }, 500);
413
652
  }
414
653
  finally {
415
654
  clearTimeout(timeoutId);
@@ -418,18 +657,105 @@ export async function handleActionRequest(request) {
418
657
  }
419
658
  }
420
659
  /**
421
- * Validates that a request comes from a trusted Nexus client.
422
- * Checks x-nexus-action header and CSRF token.
660
+ * Validates that a request comes from a trusted Nexus client (inner CSRF check
661
+ * used by `createAction` wrappers). Verifies:
662
+ * 1. `x-nexus-action` custom header — cross-origin requests cannot add this
663
+ * without a CORS preflight the server will reject.
664
+ * 2. `Origin` / `Referer` header sanity check — additional signal against
665
+ * misconfigured CORS or non-standard clients.
423
666
  */
424
667
  export async function validateRequest(ctx) {
425
668
  const nexusHeader = ctx.request.headers.get('x-nexus-action');
426
669
  if (!nexusHeader) {
427
- throw new ActionError('Missing Nexus action header', 403, 'MISSING_HEADER');
670
+ throw new ActionError('Missing x-nexus-action header', 403, 'MISSING_HEADER');
671
+ }
672
+ // Origin / Referer check: block requests that carry an explicit foreign origin.
673
+ // Same-origin and same-site fetch() calls either omit Origin or match the host.
674
+ const origin = ctx.request.headers.get('origin');
675
+ const referer = ctx.request.headers.get('referer');
676
+ const rawHost = ctx.request.headers.get('host') ?? '';
677
+ // Derive scheme from X-Forwarded-Proto or default to http (covered by HTTPS terminator upstream)
678
+ const proto = ctx.request.headers.get('x-forwarded-proto') ?? 'http';
679
+ const expectedOrigin = `${proto}://${rawHost}`;
680
+ function isSameOrigin(headerValue) {
681
+ try {
682
+ const parsed = new URL(headerValue);
683
+ const expected = new URL(expectedOrigin);
684
+ return parsed.protocol === expected.protocol
685
+ && parsed.hostname === expected.hostname
686
+ && parsed.port === expected.port;
687
+ }
688
+ catch {
689
+ return false;
690
+ }
691
+ }
692
+ const suspectOrigin = origin && !isSameOrigin(origin);
693
+ const suspectReferer = !origin && referer && !isSameOrigin(referer);
694
+ if (suspectOrigin || suspectReferer) {
695
+ throw new ActionError(`Cross-origin action request blocked (host: ${rawHost})`, 403, 'CROSS_ORIGIN_BLOCKED');
428
696
  }
429
697
  }
430
698
  // Re-export security primitives for use in app code
431
699
  export { generateActionToken, validateActionToken, extractSessionId, generateSessionId } from './csrf.js';
432
700
  export { createRateLimiter, RateLimitError, parseWindow } from './rate-limit.js';
701
+ /**
702
+ * Returns `true` when `url` is a safe **public** `http:` / `https:` target for
703
+ * server-side `fetch` (not loopback, RFC1918, link-local, metadata IPs, etc.).
704
+ * Use before `fetch(userUrl)` to reduce blind SSRF risk.
705
+ */
706
+ export function isSafeUrl(url) {
707
+ try {
708
+ const u = new URL(url);
709
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
710
+ return false;
711
+ }
712
+ catch {
713
+ return false;
714
+ }
715
+ return !isInternalUrl(url);
716
+ }
717
+ /**
718
+ * Returns `true` when a URL resolves to a private, loopback, or link-local
719
+ * address. Inverse of {@link isSafeUrl} for `http:` / `https:`.
720
+ */
721
+ export function isInternalUrl(url) {
722
+ let parsed;
723
+ try {
724
+ parsed = new URL(url);
725
+ }
726
+ catch {
727
+ return false; // unparseable URLs are not our SSRF concern — let the caller decide
728
+ }
729
+ // Only http/https are fetched in typical web actions; block everything else
730
+ // (file://, ftp://, etc.) as they access local resources.
731
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
732
+ return true;
733
+ const h = parsed.hostname.toLowerCase();
734
+ // Loopback
735
+ if (h === 'localhost' || h === '127.0.0.1' || h === '::1')
736
+ return true;
737
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h))
738
+ return true;
739
+ // Link-local (AWS/GCP/Azure metadata service lives at 169.254.169.254)
740
+ if (h.startsWith('169.254.'))
741
+ return true;
742
+ if (h === 'fe80::1' || h.startsWith('fe80:'))
743
+ return true;
744
+ // RFC 1918 private ranges
745
+ if (h.startsWith('10.'))
746
+ return true;
747
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h))
748
+ return true;
749
+ if (h.startsWith('192.168.'))
750
+ return true;
751
+ // Unique local IPv6
752
+ if (/^fd[0-9a-f]{2}:/i.test(h) || h.startsWith('fc'))
753
+ return true;
754
+ // Unspecified / broadcast
755
+ if (h === '0.0.0.0' || h === '255.255.255.255')
756
+ return true;
757
+ return false;
758
+ }
433
759
  // ── Client-side race guard ────────────────────────────────────────────────────
434
760
  /**
435
761
  * Client-side AbortController factory.
@@ -470,10 +796,71 @@ export function createActionGuard(name, strategy = 'cancel') {
470
796
  };
471
797
  }
472
798
  // ── Helpers ───────────────────────────────────────────────────────────────────
473
- async function deserializeInput(request) {
799
+ /** Maximum request body for a single server action (10 MB). Override per-action via opts.maxBodyBytes. */
800
+ const MAX_ACTION_BODY_BYTES = 10 * 1_024 * 1_024;
801
+ /** Maximum JSON object nesting depth. Prevents CPU DoS via pathological `{"a":{"b":{…}}}` inputs. */
802
+ const MAX_JSON_DEPTH = 10;
803
+ /** Maximum number of JSON object keys across all nesting levels. Prevents key-explosion attacks. */
804
+ const MAX_JSON_KEYS = 1_000;
805
+ /**
806
+ * Walks the raw JSON text character-by-character (O(n)) tracking nesting depth and
807
+ * key count WITHOUT running the full parser. Throws ActionError before `JSON.parse`
808
+ * touches the data — prevents CPU DoS from deeply nested or key-explosion payloads
809
+ * that fit within MAX_ACTION_BODY_BYTES but would block the event loop for seconds.
810
+ */
811
+ function assertJsonComplexity(text) {
812
+ let depth = 0;
813
+ let keys = 0;
814
+ let inStr = false;
815
+ let esc = false;
816
+ for (let i = 0; i < text.length; i++) {
817
+ const ch = text[i];
818
+ if (esc) {
819
+ esc = false;
820
+ continue;
821
+ }
822
+ if (ch === '\\' && inStr) {
823
+ esc = true;
824
+ continue;
825
+ }
826
+ if (ch === '"') {
827
+ inStr = !inStr;
828
+ continue;
829
+ }
830
+ if (inStr) {
831
+ continue;
832
+ }
833
+ if (ch === '{' || ch === '[') {
834
+ if (++depth > MAX_JSON_DEPTH) {
835
+ throw new ActionError(`JSON nesting too deep (limit ${MAX_JSON_DEPTH} levels)`, 400, 'JSON_TOO_DEEP');
836
+ }
837
+ }
838
+ else if (ch === '}' || ch === ']') {
839
+ depth--;
840
+ }
841
+ else if (ch === ':') {
842
+ if (++keys > MAX_JSON_KEYS) {
843
+ throw new ActionError(`JSON too complex (limit ${MAX_JSON_KEYS} keys)`, 400, 'JSON_TOO_COMPLEX');
844
+ }
845
+ }
846
+ }
847
+ }
848
+ async function deserializeInput(request, maxBytes = MAX_ACTION_BODY_BYTES) {
849
+ // DoS protection: reject oversized bodies before reading any bytes.
850
+ // Prevents memory exhaustion from clients that stream arbitrarily large payloads.
851
+ const cl = parseInt(request.headers.get('content-length') ?? '0', 10);
852
+ if (Number.isFinite(cl) && cl > maxBytes) {
853
+ throw new ActionError(`Request body too large (${cl} bytes, limit ${maxBytes})`, 413, 'PAYLOAD_TOO_LARGE');
854
+ }
474
855
  const contentType = request.headers.get('content-type') ?? '';
475
856
  if (contentType.includes('application/json')) {
476
857
  const text = await request.text();
858
+ if (text.length > maxBytes) {
859
+ throw new ActionError('Request body too large', 413, 'PAYLOAD_TOO_LARGE');
860
+ }
861
+ // Complexity guard: run BEFORE the parser so pathological payloads (deeply
862
+ // nested or key-explosion) are rejected without blocking the event loop.
863
+ assertJsonComplexity(text);
477
864
  try {
478
865
  return deserialize(text);
479
866
  }
@@ -485,7 +872,11 @@ async function deserializeInput(request) {
485
872
  contentType.includes('application/x-www-form-urlencoded')) {
486
873
  return request.formData();
487
874
  }
488
- return request.text();
875
+ const text = await request.text();
876
+ if (text.length > maxBytes) {
877
+ throw new ActionError('Request body too large', 413, 'PAYLOAD_TOO_LARGE');
878
+ }
879
+ return text;
489
880
  }
490
881
  function jsonResponse(body, status) {
491
882
  return new Response(JSON.stringify(body), {