@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.
- package/dist/actions.d.ts +71 -11
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +442 -51
- package/dist/actions.js.map +1 -1
- package/dist/build-id.d.ts +14 -0
- package/dist/build-id.d.ts.map +1 -0
- package/dist/build-id.js +40 -0
- package/dist/build-id.js.map +1 -0
- package/dist/context.d.ts +38 -4
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +13 -3
- package/dist/context.js.map +1 -1
- package/dist/csrf.d.ts +16 -2
- package/dist/csrf.d.ts.map +1 -1
- package/dist/csrf.js +68 -30
- package/dist/csrf.js.map +1 -1
- package/dist/dev-assets.d.ts +31 -0
- package/dist/dev-assets.d.ts.map +1 -1
- package/dist/dev-assets.js +372 -38
- package/dist/dev-assets.js.map +1 -1
- package/dist/dev-assets.test.d.ts +2 -0
- package/dist/dev-assets.test.d.ts.map +1 -0
- package/dist/dev-error-html.d.ts.map +1 -1
- package/dist/dev-error-html.js +24 -0
- 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/head-renderer.test.d.ts +2 -0
- package/dist/head-renderer.test.d.ts.map +1 -0
- package/dist/head-renderer.test.js +78 -0
- package/dist/head-renderer.test.js.map +1 -0
- package/dist/index.d.ts +97 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +442 -47
- package/dist/index.js.map +1 -1
- package/dist/legacy-wrapper.d.ts +88 -0
- package/dist/legacy-wrapper.d.ts.map +1 -0
- package/dist/legacy-wrapper.js +104 -0
- package/dist/legacy-wrapper.js.map +1 -0
- package/dist/lib-assets.d.ts +5 -0
- package/dist/lib-assets.d.ts.map +1 -0
- package/dist/lib-assets.js +95 -0
- package/dist/lib-assets.js.map +1 -0
- package/dist/load-module.d.ts +6 -0
- package/dist/load-module.d.ts.map +1 -1
- package/dist/load-module.js +40 -53
- package/dist/load-module.js.map +1 -1
- package/dist/metadata.d.ts +95 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +132 -0
- package/dist/metadata.js.map +1 -0
- package/dist/navigate.d.ts +0 -5
- package/dist/navigate.d.ts.map +1 -1
- package/dist/navigate.js +0 -1
- package/dist/navigate.js.map +1 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +27 -14
- package/dist/rate-limit.js.map +1 -1
- package/dist/renderer.d.ts +27 -7
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +152 -25
- package/dist/renderer.js.map +1 -1
- package/dist/renderer.test.d.ts +2 -0
- package/dist/renderer.test.d.ts.map +1 -0
- package/dist/renderer.test.js +251 -0
- package/dist/renderer.test.js.map +1 -0
- package/dist/streaming.d.ts +3 -3
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +33 -13
- package/dist/streaming.js.map +1 -1
- package/dist/tenancy.d.ts +17 -0
- package/dist/tenancy.d.ts.map +1 -0
- package/dist/tenancy.js +132 -0
- package/dist/tenancy.js.map +1 -0
- package/dist/tenancy.test.d.ts +2 -0
- package/dist/tenancy.test.d.ts.map +1 -0
- package/dist/tenancy.test.js +38 -0
- package/dist/tenancy.test.js.map +1 -0
- 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
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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: '
|
|
184
|
-
message: '
|
|
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: '
|
|
190
|
-
status:
|
|
191
|
-
code: '
|
|
192
|
-
},
|
|
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
|
-
|
|
195
|
-
|
|
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:
|
|
200
|
-
message:
|
|
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:
|
|
398
|
+
error: 'Missing x-nexus-action header — possible CSRF attack',
|
|
206
399
|
status: 403,
|
|
207
|
-
code:
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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), {
|