@nexus_js/server 0.9.28 → 0.9.29
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/dist/actions.d.ts +11 -71
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +51 -442
- package/dist/actions.js.map +1 -1
- package/dist/context.d.ts +4 -38
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +3 -13
- package/dist/context.js.map +1 -1
- package/dist/csrf.d.ts +2 -16
- package/dist/csrf.d.ts.map +1 -1
- package/dist/csrf.js +30 -68
- package/dist/csrf.js.map +1 -1
- package/dist/dev-assets.d.ts +0 -31
- package/dist/dev-assets.d.ts.map +1 -1
- package/dist/dev-assets.js +38 -372
- package/dist/dev-assets.js.map +1 -1
- package/dist/dev-error-html.d.ts.map +1 -1
- package/dist/dev-error-html.js +0 -24
- package/dist/dev-error-html.js.map +1 -1
- package/dist/devradar.d.ts +1 -1
- package/dist/devradar.d.ts.map +1 -1
- package/dist/devradar.js.map +1 -1
- package/dist/index.d.ts +2 -97
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -442
- package/dist/index.js.map +1 -1
- package/dist/load-module.d.ts +0 -6
- package/dist/load-module.d.ts.map +1 -1
- package/dist/load-module.js +53 -40
- package/dist/load-module.js.map +1 -1
- package/dist/navigate.d.ts +5 -0
- package/dist/navigate.d.ts.map +1 -1
- package/dist/navigate.js +1 -0
- package/dist/navigate.js.map +1 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +14 -27
- package/dist/rate-limit.js.map +1 -1
- package/dist/renderer.d.ts +7 -27
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +25 -152
- package/dist/renderer.js.map +1 -1
- package/dist/streaming.d.ts +3 -3
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +13 -33
- package/dist/streaming.js.map +1 -1
- package/package.json +8 -26
- package/dist/build-id.d.ts +0 -14
- package/dist/build-id.d.ts.map +0 -1
- package/dist/build-id.js +0 -40
- package/dist/build-id.js.map +0 -1
- package/dist/dev-assets.test.d.ts +0 -2
- package/dist/dev-assets.test.d.ts.map +0 -1
- package/dist/head-renderer.test.d.ts +0 -2
- package/dist/head-renderer.test.d.ts.map +0 -1
- package/dist/head-renderer.test.js +0 -78
- package/dist/head-renderer.test.js.map +0 -1
- package/dist/legacy-wrapper.d.ts +0 -88
- package/dist/legacy-wrapper.d.ts.map +0 -1
- package/dist/legacy-wrapper.js +0 -104
- package/dist/legacy-wrapper.js.map +0 -1
- package/dist/lib-assets.d.ts +0 -5
- package/dist/lib-assets.d.ts.map +0 -1
- package/dist/lib-assets.js +0 -95
- package/dist/lib-assets.js.map +0 -1
- package/dist/metadata.d.ts +0 -95
- package/dist/metadata.d.ts.map +0 -1
- package/dist/metadata.js +0 -132
- package/dist/metadata.js.map +0 -1
- package/dist/renderer.test.d.ts +0 -2
- package/dist/renderer.test.d.ts.map +0 -1
- package/dist/renderer.test.js +0 -251
- package/dist/renderer.test.js.map +0 -1
- package/dist/tenancy.d.ts +0 -17
- package/dist/tenancy.d.ts.map +0 -1
- package/dist/tenancy.js +0 -132
- package/dist/tenancy.js.map +0 -1
- package/dist/tenancy.test.d.ts +0 -2
- package/dist/tenancy.test.d.ts.map +0 -1
- package/dist/tenancy.test.js +0 -38
- package/dist/tenancy.test.js.map +0 -1
package/dist/actions.js
CHANGED
|
@@ -30,10 +30,8 @@
|
|
|
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,
|
|
34
|
-
import { randomUUID, createHmac, timingSafeEqual } from 'node:crypto';
|
|
33
|
+
import { createRateLimiter, registerLimiter, RateLimitError, } from './rate-limit.js';
|
|
35
34
|
import { emitDevRadar, newTraceId, sanitizeTelemetryValue } from './devradar.js';
|
|
36
|
-
import { getExpectedNexusBuildId } from './build-id.js';
|
|
37
35
|
const ACTION_PREFIX = '/_nexus/action/';
|
|
38
36
|
const idempotencyCache = new Map();
|
|
39
37
|
const IDEMPOTENCY_TTL = 30_000; // 30 seconds
|
|
@@ -42,49 +40,14 @@ const IDEMPOTENCY_TTL = 30_000; // 30 seconds
|
|
|
42
40
|
const inFlightActions = new Map();
|
|
43
41
|
const actionQueues = new Map();
|
|
44
42
|
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
|
-
}
|
|
77
43
|
/**
|
|
78
44
|
* SSR and islands coerce `action={myAction}` with `String(myAction)`. The value is the
|
|
79
45
|
* wrapper from `createAction`, whose default `Function#toString()` is the entire wrapper
|
|
80
46
|
* source (CSRF, rate limit, …) — that string was being used as the form URL. After
|
|
81
47
|
* 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.
|
|
84
48
|
*/
|
|
85
49
|
function patchRegisteredActionStringCoercion(fn, name) {
|
|
86
|
-
const
|
|
87
|
-
const url = sig ? `${ACTION_PREFIX}${name}?__sig=${sig}` : `${ACTION_PREFIX}${name}`;
|
|
50
|
+
const url = ACTION_PREFIX + name;
|
|
88
51
|
Object.defineProperty(fn, 'toString', {
|
|
89
52
|
value: function nexusActionUrlString() {
|
|
90
53
|
return url;
|
|
@@ -110,7 +73,7 @@ function patchRegisteredActionStringCoercion(fn, name) {
|
|
|
110
73
|
* and ready to be called by the client.
|
|
111
74
|
*
|
|
112
75
|
* Security layers applied (in order):
|
|
113
|
-
* 1. CSRF
|
|
76
|
+
* 1. CSRF token validation (x-nexus-action-token header)
|
|
114
77
|
* 2. Rate limiting (sliding window, per-IP or per-user)
|
|
115
78
|
* 3. Input schema validation (Zod or any .parse() compatible schema)
|
|
116
79
|
* 4. AbortController (client disconnect + timeout)
|
|
@@ -146,33 +109,14 @@ export function createAction(optsOrFn, legacyOpts = {}) {
|
|
|
146
109
|
throw new RateLimitError(result);
|
|
147
110
|
}
|
|
148
111
|
}
|
|
149
|
-
// 3. Schema validation
|
|
112
|
+
// 3. Schema validation
|
|
150
113
|
if (opts.schema) {
|
|
151
|
-
|
|
152
|
-
|
|
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;
|
|
114
|
+
try {
|
|
115
|
+
input = opts.schema.parse(input);
|
|
167
116
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
const msg = err instanceof Error ? err.message : 'Input validation failed';
|
|
119
|
+
throw new ActionError(`Invalid input: ${msg}`, 400, 'VALIDATION_ERROR');
|
|
176
120
|
}
|
|
177
121
|
}
|
|
178
122
|
return fn(input, ctx);
|
|
@@ -192,25 +136,11 @@ export function getRegisteredActionNames() {
|
|
|
192
136
|
export class ActionError extends Error {
|
|
193
137
|
status;
|
|
194
138
|
code;
|
|
195
|
-
|
|
196
|
-
constructor(message, optionsOrStatus, code, fieldErrors) {
|
|
139
|
+
constructor(message, status = 400, code) {
|
|
197
140
|
super(message);
|
|
141
|
+
this.status = status;
|
|
142
|
+
this.code = code;
|
|
198
143
|
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
|
-
}
|
|
214
144
|
}
|
|
215
145
|
}
|
|
216
146
|
export class ActionAbortedError extends ActionError {
|
|
@@ -218,41 +148,6 @@ export class ActionAbortedError extends ActionError {
|
|
|
218
148
|
super('Action was superseded by a newer request', 409, 'ABORTED');
|
|
219
149
|
}
|
|
220
150
|
}
|
|
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
|
-
}
|
|
256
151
|
/**
|
|
257
152
|
* Main HTTP handler for /_nexus/action/:name
|
|
258
153
|
* This is where all the race-condition logic runs.
|
|
@@ -268,146 +163,54 @@ export async function handleActionRequest(request) {
|
|
|
268
163
|
headers: { allow: 'POST' },
|
|
269
164
|
});
|
|
270
165
|
}
|
|
271
|
-
const
|
|
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;
|
|
166
|
+
const actionName = url.pathname.slice(ACTION_PREFIX.length);
|
|
294
167
|
const registered = actionRegistry.get(actionName);
|
|
295
168
|
if (!registered) {
|
|
296
169
|
return jsonResponse({ error: `Action "${actionName}" not found`, status: 404 }, 404);
|
|
297
170
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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) {
|
|
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) {
|
|
323
180
|
emitDevRadar({
|
|
324
181
|
type: 'security:audit',
|
|
325
182
|
payload: {
|
|
326
|
-
kind: '
|
|
327
|
-
message: '
|
|
183
|
+
kind: 'csrf_blocked',
|
|
184
|
+
message: 'Missing action token',
|
|
328
185
|
action: actionName,
|
|
329
186
|
},
|
|
330
187
|
});
|
|
331
188
|
return jsonResponse({
|
|
332
|
-
error: '
|
|
333
|
-
status:
|
|
334
|
-
code: '
|
|
335
|
-
},
|
|
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
|
-
}
|
|
189
|
+
error: 'Missing action token — possible CSRF attack',
|
|
190
|
+
status: 403,
|
|
191
|
+
code: 'MISSING_CSRF_TOKEN',
|
|
192
|
+
}, 403);
|
|
386
193
|
}
|
|
387
|
-
|
|
388
|
-
|
|
194
|
+
const validation = validateActionToken(token, sessionId, actionName, secret);
|
|
195
|
+
if (!validation.valid) {
|
|
389
196
|
emitDevRadar({
|
|
390
197
|
type: 'security:audit',
|
|
391
198
|
payload: {
|
|
392
|
-
kind: 'csrf_blocked',
|
|
393
|
-
message: '
|
|
199
|
+
kind: validation.replayed ? 'replay' : 'csrf_blocked',
|
|
200
|
+
message: validation.reason ?? 'Invalid action token',
|
|
394
201
|
action: actionName,
|
|
395
202
|
},
|
|
396
203
|
});
|
|
397
204
|
return jsonResponse({
|
|
398
|
-
error: '
|
|
205
|
+
error: validation.reason ?? 'Invalid action token',
|
|
399
206
|
status: 403,
|
|
400
|
-
code: '
|
|
207
|
+
code: validation.replayed ? 'REPLAY_ATTACK' : 'INVALID_CSRF_TOKEN',
|
|
401
208
|
}, 403);
|
|
402
209
|
}
|
|
403
|
-
// Tier 1 satisfied: nexusHeader present, no token → header-based CSRF protection
|
|
404
210
|
}
|
|
405
211
|
// ── Rate limiting ──────────────────────────────────────────────────────────
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
// the sliding-window state and made rate limiting completely ineffective.
|
|
409
|
-
const limiter = getLimiter(actionName);
|
|
410
|
-
if (limiter) {
|
|
212
|
+
if (opts.rateLimit) {
|
|
213
|
+
const limiter = createRateLimiter(opts.rateLimit);
|
|
411
214
|
const result = limiter.check(request);
|
|
412
215
|
if (!result.allowed) {
|
|
413
216
|
emitDevRadar({
|
|
@@ -491,7 +294,7 @@ export async function handleActionRequest(request) {
|
|
|
491
294
|
const startTime = Date.now();
|
|
492
295
|
try {
|
|
493
296
|
// Deserialize input using Nexus transport (preserves Date, Map, Set, etc.)
|
|
494
|
-
const input = await deserializeInput(request
|
|
297
|
+
const input = await deserializeInput(request);
|
|
495
298
|
emitDevRadar({
|
|
496
299
|
type: 'action:call',
|
|
497
300
|
payload: {
|
|
@@ -545,26 +348,15 @@ export async function handleActionRequest(request) {
|
|
|
545
348
|
// Cleanup old entries periodically
|
|
546
349
|
cleanIdempotencyCache();
|
|
547
350
|
}
|
|
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
|
-
}
|
|
553
351
|
// Serialize response with Nexus transport
|
|
554
352
|
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
|
-
});
|
|
565
353
|
return new Response(serialized, {
|
|
566
354
|
status: 200,
|
|
567
|
-
headers:
|
|
355
|
+
headers: {
|
|
356
|
+
'content-type': 'application/json',
|
|
357
|
+
'x-nexus-duration': String(duration),
|
|
358
|
+
...(idempotencyKey ? { 'x-nexus-idempotency': idempotencyKey } : {}),
|
|
359
|
+
},
|
|
568
360
|
});
|
|
569
361
|
}
|
|
570
362
|
catch (err) {
|
|
@@ -605,50 +397,19 @@ export async function handleActionRequest(request) {
|
|
|
605
397
|
...(err.code !== undefined ? { code: err.code } : {}),
|
|
606
398
|
},
|
|
607
399
|
});
|
|
608
|
-
|
|
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);
|
|
400
|
+
return jsonResponse({ error: err.message, status: err.status, code: err.code }, err.status);
|
|
626
401
|
}
|
|
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);
|
|
631
402
|
emitDevRadar({
|
|
632
403
|
type: 'action:error',
|
|
633
404
|
payload: {
|
|
634
405
|
id: traceId,
|
|
635
406
|
name: actionName,
|
|
636
|
-
error:
|
|
407
|
+
error: err instanceof Error ? err.message : String(err),
|
|
637
408
|
duration: Date.now() - startTime,
|
|
638
|
-
...(mask ? { errorId } : {}),
|
|
639
409
|
},
|
|
640
410
|
});
|
|
641
|
-
|
|
642
|
-
|
|
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);
|
|
411
|
+
console.error(`[Nexus Action] "${actionName}" failed:`, err);
|
|
412
|
+
return jsonResponse({ error: 'Internal Server Error', status: 500 }, 500);
|
|
652
413
|
}
|
|
653
414
|
finally {
|
|
654
415
|
clearTimeout(timeoutId);
|
|
@@ -657,105 +418,18 @@ export async function handleActionRequest(request) {
|
|
|
657
418
|
}
|
|
658
419
|
}
|
|
659
420
|
/**
|
|
660
|
-
* Validates that a request comes from a trusted Nexus client
|
|
661
|
-
*
|
|
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.
|
|
421
|
+
* Validates that a request comes from a trusted Nexus client.
|
|
422
|
+
* Checks x-nexus-action header and CSRF token.
|
|
666
423
|
*/
|
|
667
424
|
export async function validateRequest(ctx) {
|
|
668
425
|
const nexusHeader = ctx.request.headers.get('x-nexus-action');
|
|
669
426
|
if (!nexusHeader) {
|
|
670
|
-
throw new ActionError('Missing
|
|
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');
|
|
427
|
+
throw new ActionError('Missing Nexus action header', 403, 'MISSING_HEADER');
|
|
696
428
|
}
|
|
697
429
|
}
|
|
698
430
|
// Re-export security primitives for use in app code
|
|
699
431
|
export { generateActionToken, validateActionToken, extractSessionId, generateSessionId } from './csrf.js';
|
|
700
432
|
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
|
-
}
|
|
759
433
|
// ── Client-side race guard ────────────────────────────────────────────────────
|
|
760
434
|
/**
|
|
761
435
|
* Client-side AbortController factory.
|
|
@@ -796,71 +470,10 @@ export function createActionGuard(name, strategy = 'cancel') {
|
|
|
796
470
|
};
|
|
797
471
|
}
|
|
798
472
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
799
|
-
|
|
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
|
-
}
|
|
473
|
+
async function deserializeInput(request) {
|
|
855
474
|
const contentType = request.headers.get('content-type') ?? '';
|
|
856
475
|
if (contentType.includes('application/json')) {
|
|
857
476
|
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);
|
|
864
477
|
try {
|
|
865
478
|
return deserialize(text);
|
|
866
479
|
}
|
|
@@ -872,11 +485,7 @@ async function deserializeInput(request, maxBytes = MAX_ACTION_BODY_BYTES) {
|
|
|
872
485
|
contentType.includes('application/x-www-form-urlencoded')) {
|
|
873
486
|
return request.formData();
|
|
874
487
|
}
|
|
875
|
-
|
|
876
|
-
if (text.length > maxBytes) {
|
|
877
|
-
throw new ActionError('Request body too large', 413, 'PAYLOAD_TOO_LARGE');
|
|
878
|
-
}
|
|
879
|
-
return text;
|
|
488
|
+
return request.text();
|
|
880
489
|
}
|
|
881
490
|
function jsonResponse(body, status) {
|
|
882
491
|
return new Response(JSON.stringify(body), {
|