@nexus_js/server 0.7.5 → 0.9.0
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 +57 -8
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +318 -19
- 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.js +10 -2
- 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 +48 -12
- package/dist/csrf.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 +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +58 -15
- package/dist/index.js.map +1 -1
- package/dist/load-module.d.ts +6 -0
- package/dist/load-module.d.ts.map +1 -1
- package/dist/load-module.js +13 -9
- package/dist/load-module.js.map +1 -1
- package/dist/metadata.d.ts +91 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +128 -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 +23 -3
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +64 -19
- 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 +33 -13
- package/dist/streaming.js.map +1 -1
- package/package.json +7 -7
package/dist/actions.d.ts
CHANGED
|
@@ -29,6 +29,25 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import type { NexusContext } from './context.js';
|
|
31
31
|
import { type RateLimitConfig } from './rate-limit.js';
|
|
32
|
+
/**
|
|
33
|
+
* Zod-compatible schema interface.
|
|
34
|
+
* Supports `.parse()` (throws on failure) and optionally `.safeParse()` (returns structured errors).
|
|
35
|
+
* Works with Zod, Valibot, ArkType, Superstruct, and any schema library following this contract.
|
|
36
|
+
*/
|
|
37
|
+
export interface NexusSchema<T> {
|
|
38
|
+
parse(data: unknown): T;
|
|
39
|
+
/** Optional — when present, used to extract structured field errors (Zod format). */
|
|
40
|
+
safeParse?: (data: unknown) => {
|
|
41
|
+
success: boolean;
|
|
42
|
+
error?: {
|
|
43
|
+
issues?: Array<{
|
|
44
|
+
path: Array<string | number>;
|
|
45
|
+
message: string;
|
|
46
|
+
}>;
|
|
47
|
+
};
|
|
48
|
+
data?: T;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
32
51
|
export type ActionFn<TInput = FormData, TOutput = void> = (input: TInput, ctx: NexusContext & {
|
|
33
52
|
signal: AbortSignal;
|
|
34
53
|
}) => Promise<TOutput>;
|
|
@@ -70,13 +89,23 @@ export interface ActionOptions {
|
|
|
70
89
|
*/
|
|
71
90
|
csrf?: boolean;
|
|
72
91
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
92
|
+
* Zod-compatible schema for input validation.
|
|
93
|
+
* The action rejects invalid input **before** calling the handler —
|
|
94
|
+
* preventing SQL injection, type coercion attacks, and untrusted data reaching business logic.
|
|
95
|
+
*
|
|
96
|
+
* Accepts any object with a `.parse()` method (Zod, Valibot, ArkType, etc.)
|
|
97
|
+
* or `.safeParse()` for structured error extraction.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* import { z } from 'zod';
|
|
102
|
+
* export const updateUser = createAction({
|
|
103
|
+
* schema: z.object({ name: z.string().min(1).max(100), age: z.number().int().min(0) }),
|
|
104
|
+
* handler: async ({ name, age }, ctx) => { ... },
|
|
105
|
+
* });
|
|
106
|
+
* ```
|
|
76
107
|
*/
|
|
77
|
-
schema?:
|
|
78
|
-
parse: (data: unknown) => unknown;
|
|
79
|
-
};
|
|
108
|
+
schema?: NexusSchema<unknown>;
|
|
80
109
|
/**
|
|
81
110
|
* Maximum request body size in bytes. Default: 10 MB.
|
|
82
111
|
* Lower this for actions that only receive small form payloads (e.g. login forms).
|
|
@@ -93,13 +122,18 @@ export interface ActionResult<T = unknown> {
|
|
|
93
122
|
/** Server-side execution time in ms */
|
|
94
123
|
duration?: number;
|
|
95
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Verifies an action name signature. Returns true if the signature is valid or
|
|
127
|
+
* if we are in dev mode (no NEXUS_SECRET set — signature is optional in dev).
|
|
128
|
+
*/
|
|
129
|
+
export declare function verifyActionSig(name: string, sig: string | null): boolean;
|
|
96
130
|
/**
|
|
97
131
|
* Defines a Server Action with integrated security, rate limiting, and
|
|
98
132
|
* race-condition management. The returned object is registered automatically
|
|
99
133
|
* and ready to be called by the client.
|
|
100
134
|
*
|
|
101
135
|
* Security layers applied (in order):
|
|
102
|
-
* 1. CSRF
|
|
136
|
+
* 1. CSRF: custom header `x-nexus-action: 1` (Tier 1) + optional HMAC token (Tier 2)
|
|
103
137
|
* 2. Rate limiting (sliding window, per-IP or per-user)
|
|
104
138
|
* 3. Input schema validation (Zod or any .parse() compatible schema)
|
|
105
139
|
* 4. AbortController (client disconnect + timeout)
|
|
@@ -126,7 +160,11 @@ export declare function getRegisteredActionNames(): ReadonlySet<string>;
|
|
|
126
160
|
export declare class ActionError extends Error {
|
|
127
161
|
readonly status: number;
|
|
128
162
|
readonly code?: string | undefined;
|
|
129
|
-
|
|
163
|
+
/** Structured field-level validation errors (Zod-style). Key: field path, Value: message. */
|
|
164
|
+
readonly fieldErrors?: Record<string, string> | undefined;
|
|
165
|
+
constructor(message: string, status?: number, code?: string | undefined,
|
|
166
|
+
/** Structured field-level validation errors (Zod-style). Key: field path, Value: message. */
|
|
167
|
+
fieldErrors?: Record<string, string> | undefined);
|
|
130
168
|
}
|
|
131
169
|
export declare class ActionAbortedError extends ActionError {
|
|
132
170
|
constructor();
|
|
@@ -148,6 +186,17 @@ export declare function validateRequest(ctx: NexusContext): Promise<void>;
|
|
|
148
186
|
export { generateActionToken, validateActionToken, extractSessionId, generateSessionId } from './csrf.js';
|
|
149
187
|
export { createRateLimiter, RateLimitError, parseWindow } from './rate-limit.js';
|
|
150
188
|
export type { RateLimitConfig, RateLimitResult, RateLimiter } from './rate-limit.js';
|
|
189
|
+
/**
|
|
190
|
+
* Returns `true` when `url` is a safe **public** `http:` / `https:` target for
|
|
191
|
+
* server-side `fetch` (not loopback, RFC1918, link-local, metadata IPs, etc.).
|
|
192
|
+
* Use before `fetch(userUrl)` to reduce blind SSRF risk.
|
|
193
|
+
*/
|
|
194
|
+
export declare function isSafeUrl(url: string): boolean;
|
|
195
|
+
/**
|
|
196
|
+
* Returns `true` when a URL resolves to a private, loopback, or link-local
|
|
197
|
+
* address. Inverse of {@link isSafeUrl} for `http:` / `https:`.
|
|
198
|
+
*/
|
|
199
|
+
export declare function isInternalUrl(url: string): boolean;
|
|
151
200
|
/**
|
|
152
201
|
* Client-side AbortController factory.
|
|
153
202
|
* Use this in island code to cancel in-flight action fetches
|
package/dist/actions.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../src/actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAOjD,OAAO,EAKL,KAAK,eAAe,EACrB,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../src/actions.ts"],"names":[],"mappings":"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"}
|
package/dist/actions.js
CHANGED
|
@@ -31,7 +31,9 @@ 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
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,47 @@ 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 (no NEXUS_SECRET required in dev mode).
|
|
50
|
+
*/
|
|
51
|
+
function signActionName(name) {
|
|
52
|
+
const secret = process.env['NEXUS_SECRET'];
|
|
53
|
+
if (!secret || secret === 'nexus-dev-secret-change-me')
|
|
54
|
+
return null;
|
|
55
|
+
return createHmac('sha256', secret).update(`action:${name}`).digest('base64url').slice(0, 16);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Verifies an action name signature. Returns true if the signature is valid or
|
|
59
|
+
* if we are in dev mode (no NEXUS_SECRET set — signature is optional in dev).
|
|
60
|
+
*/
|
|
61
|
+
export function verifyActionSig(name, sig) {
|
|
62
|
+
const expected = signActionName(name);
|
|
63
|
+
if (expected === null)
|
|
64
|
+
return true; // dev mode — skip verification
|
|
65
|
+
if (!sig)
|
|
66
|
+
return false;
|
|
67
|
+
// Constant-time comparison (sig is 16 base64url chars = 96 bits)
|
|
68
|
+
try {
|
|
69
|
+
return timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
43
75
|
/**
|
|
44
76
|
* SSR and islands coerce `action={myAction}` with `String(myAction)`. The value is the
|
|
45
77
|
* wrapper from `createAction`, whose default `Function#toString()` is the entire wrapper
|
|
46
78
|
* source (CSRF, rate limit, …) — that string was being used as the form URL. After
|
|
47
79
|
* registration we pin `toString` / `@@toPrimitive` to the real POST path.
|
|
80
|
+
* In production (NEXUS_SECRET is set), the URL carries an HMAC query param
|
|
81
|
+
* (`?__sig=…`) so the server can reject calls with unsigned / forged action URLs.
|
|
48
82
|
*/
|
|
49
83
|
function patchRegisteredActionStringCoercion(fn, name) {
|
|
50
|
-
const
|
|
84
|
+
const sig = signActionName(name);
|
|
85
|
+
const url = sig ? `${ACTION_PREFIX}${name}?__sig=${sig}` : `${ACTION_PREFIX}${name}`;
|
|
51
86
|
Object.defineProperty(fn, 'toString', {
|
|
52
87
|
value: function nexusActionUrlString() {
|
|
53
88
|
return url;
|
|
@@ -73,7 +108,7 @@ function patchRegisteredActionStringCoercion(fn, name) {
|
|
|
73
108
|
* and ready to be called by the client.
|
|
74
109
|
*
|
|
75
110
|
* Security layers applied (in order):
|
|
76
|
-
* 1. CSRF
|
|
111
|
+
* 1. CSRF: custom header `x-nexus-action: 1` (Tier 1) + optional HMAC token (Tier 2)
|
|
77
112
|
* 2. Rate limiting (sliding window, per-IP or per-user)
|
|
78
113
|
* 3. Input schema validation (Zod or any .parse() compatible schema)
|
|
79
114
|
* 4. AbortController (client disconnect + timeout)
|
|
@@ -109,14 +144,33 @@ export function createAction(optsOrFn, legacyOpts = {}) {
|
|
|
109
144
|
throw new RateLimitError(result);
|
|
110
145
|
}
|
|
111
146
|
}
|
|
112
|
-
// 3. Schema validation
|
|
147
|
+
// 3. Schema validation — fail-fast before any business logic or DB access
|
|
113
148
|
if (opts.schema) {
|
|
114
|
-
|
|
115
|
-
|
|
149
|
+
if (typeof opts.schema.safeParse === 'function') {
|
|
150
|
+
// Structured path: extract field-level errors for the client
|
|
151
|
+
const result = opts.schema.safeParse(input);
|
|
152
|
+
if (!result.success) {
|
|
153
|
+
const issues = result.error?.issues ?? [];
|
|
154
|
+
const fieldErrors = {};
|
|
155
|
+
for (const issue of issues) {
|
|
156
|
+
const path = issue.path.join('.') || '_root';
|
|
157
|
+
fieldErrors[path] = issue.message;
|
|
158
|
+
}
|
|
159
|
+
throw new ActionError(issues.length > 0
|
|
160
|
+
? `Validation failed: ${issues.map((i) => `${i.path.join('.') || 'input'} — ${i.message}`).join('; ')}`
|
|
161
|
+
: 'Input validation failed', 400, 'VALIDATION_ERROR', fieldErrors);
|
|
162
|
+
}
|
|
163
|
+
if (result.data !== undefined)
|
|
164
|
+
input = result.data;
|
|
116
165
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
166
|
+
else {
|
|
167
|
+
try {
|
|
168
|
+
input = opts.schema.parse(input);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
const msg = err instanceof Error ? err.message : 'Input validation failed';
|
|
172
|
+
throw new ActionError(`Invalid input: ${msg}`, 400, 'VALIDATION_ERROR');
|
|
173
|
+
}
|
|
120
174
|
}
|
|
121
175
|
}
|
|
122
176
|
return fn(input, ctx);
|
|
@@ -136,10 +190,14 @@ export function getRegisteredActionNames() {
|
|
|
136
190
|
export class ActionError extends Error {
|
|
137
191
|
status;
|
|
138
192
|
code;
|
|
139
|
-
|
|
193
|
+
fieldErrors;
|
|
194
|
+
constructor(message, status = 400, code,
|
|
195
|
+
/** Structured field-level validation errors (Zod-style). Key: field path, Value: message. */
|
|
196
|
+
fieldErrors) {
|
|
140
197
|
super(message);
|
|
141
198
|
this.status = status;
|
|
142
199
|
this.code = code;
|
|
200
|
+
this.fieldErrors = fieldErrors;
|
|
143
201
|
this.name = 'ActionError';
|
|
144
202
|
}
|
|
145
203
|
}
|
|
@@ -148,6 +206,41 @@ export class ActionAbortedError extends ActionError {
|
|
|
148
206
|
super('Action was superseded by a newer request', 409, 'ABORTED');
|
|
149
207
|
}
|
|
150
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Detects whether a request originates from a plain HTML form (no JS).
|
|
211
|
+
*
|
|
212
|
+
* A "native form" request:
|
|
213
|
+
* - Has `Content-Type: application/x-www-form-urlencoded`
|
|
214
|
+
* - Does NOT have the `x-nexus-action` custom header (added by the JS runtime)
|
|
215
|
+
* - Does NOT explicitly request `application/json` in Accept
|
|
216
|
+
*
|
|
217
|
+
* When true, the action handler responds with 303 redirects instead of JSON
|
|
218
|
+
* (Progressive Enhancement — the action works even when JS is disabled).
|
|
219
|
+
*/
|
|
220
|
+
function isNativeFormRequest(request) {
|
|
221
|
+
const ct = request.headers.get('content-type') ?? '';
|
|
222
|
+
const hasNexusHeader = request.headers.has('x-nexus-action');
|
|
223
|
+
const acceptsJson = (request.headers.get('accept') ?? '').includes('application/json');
|
|
224
|
+
return (ct.includes('application/x-www-form-urlencoded') &&
|
|
225
|
+
!hasNexusHeader &&
|
|
226
|
+
!acceptsJson);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Builds a 303 See Other redirect for progressive-enhancement form submissions.
|
|
230
|
+
* Follows the Post/Redirect/Get pattern to prevent duplicate submissions on refresh.
|
|
231
|
+
*
|
|
232
|
+
* Redirect target priority:
|
|
233
|
+
* 1. `__redirectTo` hidden field in the form
|
|
234
|
+
* 2. `Referer` header (the page that submitted the form)
|
|
235
|
+
* 3. `/` as final fallback
|
|
236
|
+
*/
|
|
237
|
+
function formRedirect(request, formData, status = 303) {
|
|
238
|
+
const redirectTo = formData.get('__redirectTo') ??
|
|
239
|
+
request.headers.get('referer') ??
|
|
240
|
+
'/';
|
|
241
|
+
const headers = new Headers({ location: redirectTo });
|
|
242
|
+
return new Response(null, { status, headers });
|
|
243
|
+
}
|
|
151
244
|
/**
|
|
152
245
|
* Main HTTP handler for /_nexus/action/:name
|
|
153
246
|
* This is where all the race-condition logic runs.
|
|
@@ -163,6 +256,7 @@ export async function handleActionRequest(request) {
|
|
|
163
256
|
headers: { allow: 'POST' },
|
|
164
257
|
});
|
|
165
258
|
}
|
|
259
|
+
const nativeForm = isNativeFormRequest(request);
|
|
166
260
|
// ── Guard: opaque (null) Origin ────────────────────────────────────────────
|
|
167
261
|
// Sandboxed iframes (<iframe sandbox> without allow-same-origin) and data:
|
|
168
262
|
// URIs send the string "null" as the Origin header. This is never a
|
|
@@ -189,6 +283,46 @@ export async function handleActionRequest(request) {
|
|
|
189
283
|
if (!registered) {
|
|
190
284
|
return jsonResponse({ error: `Action "${actionName}" not found`, status: 404 }, 404);
|
|
191
285
|
}
|
|
286
|
+
// ── Action URL signature verification (production only) ───────────────────
|
|
287
|
+
// In production (NEXUS_SECRET is set), action URLs are HMAC-signed during SSR.
|
|
288
|
+
// A request without a valid signature means the URL was manually crafted or
|
|
289
|
+
// the action name was enumerated — not server-rendered.
|
|
290
|
+
// In dev mode (no secret), signature check is skipped so hot-reload works.
|
|
291
|
+
const actionSig = url.searchParams.get('__sig');
|
|
292
|
+
if (!verifyActionSig(actionName, actionSig)) {
|
|
293
|
+
emitDevRadar({
|
|
294
|
+
type: 'security:audit',
|
|
295
|
+
payload: {
|
|
296
|
+
kind: 'csrf_blocked',
|
|
297
|
+
message: 'Action URL signature missing or invalid — possible action enumeration',
|
|
298
|
+
action: actionName,
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
return jsonResponse({ error: 'Invalid action URL', status: 403, code: 'INVALID_ACTION_SIG' }, 403);
|
|
302
|
+
}
|
|
303
|
+
// ── Build ID (client–server contract) ─────────────────────────────────────
|
|
304
|
+
// When `.nexus/build-id.json` exists (after `nexus build`), every action call
|
|
305
|
+
// must carry `x-nexus-build-id` matching that file. Stale tabs from a prior
|
|
306
|
+
// deploy get 412 so the runtime can reload and pick up new action signatures.
|
|
307
|
+
const expectedBuildId = getExpectedNexusBuildId();
|
|
308
|
+
if (expectedBuildId !== null) {
|
|
309
|
+
const sent = request.headers.get('x-nexus-build-id') ?? '';
|
|
310
|
+
if (sent !== expectedBuildId) {
|
|
311
|
+
emitDevRadar({
|
|
312
|
+
type: 'security:audit',
|
|
313
|
+
payload: {
|
|
314
|
+
kind: 'build_mismatch',
|
|
315
|
+
message: 'x-nexus-build-id does not match deployed build',
|
|
316
|
+
action: actionName,
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
return jsonResponse({
|
|
320
|
+
error: 'Application was updated. Please reload the page.',
|
|
321
|
+
status: 412,
|
|
322
|
+
code: 'BUILD_MISMATCH',
|
|
323
|
+
}, 412);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
192
326
|
const { fn, opts } = registered;
|
|
193
327
|
const race = opts.race ?? 'cancel';
|
|
194
328
|
const timeout = opts.timeout ?? 30_000;
|
|
@@ -196,7 +330,7 @@ export async function handleActionRequest(request) {
|
|
|
196
330
|
//
|
|
197
331
|
// Tier 1 — Custom header (default): Browsers cannot add arbitrary headers to
|
|
198
332
|
// cross-origin requests without a CORS preflight the server will reject.
|
|
199
|
-
// Requiring `x-nexus-action
|
|
333
|
+
// Requiring a non-empty `x-nexus-action` header blocks form-based CSRF from
|
|
200
334
|
// foreign origins without needing token generation on the server.
|
|
201
335
|
//
|
|
202
336
|
// Tier 2 — HMAC token (opt-in): When `x-nexus-action-token` is present the
|
|
@@ -213,7 +347,13 @@ export async function handleActionRequest(request) {
|
|
|
213
347
|
const nexusHeader = request.headers.get('x-nexus-action');
|
|
214
348
|
if (token) {
|
|
215
349
|
// Tier 2: full HMAC validation
|
|
216
|
-
const
|
|
350
|
+
const envSecret = process.env['NEXUS_SECRET'];
|
|
351
|
+
if (!envSecret && process.env['NODE_ENV'] === 'production') {
|
|
352
|
+
// listen() already blocks start without NEXUS_SECRET; this catches
|
|
353
|
+
// edge-runtimes / tests that bypass createNexusServer.
|
|
354
|
+
return jsonResponse({ error: 'Server misconfiguration: NEXUS_SECRET not set', status: 500, code: 'INSECURE_SECRET' }, 500);
|
|
355
|
+
}
|
|
356
|
+
const secret = envSecret ?? 'nexus-dev-secret-change-me';
|
|
217
357
|
const sessionId = extractSessionId(request);
|
|
218
358
|
const validation = validateActionToken(token, sessionId, actionName, secret);
|
|
219
359
|
if (!validation.valid) {
|
|
@@ -393,6 +533,11 @@ export async function handleActionRequest(request) {
|
|
|
393
533
|
// Cleanup old entries periodically
|
|
394
534
|
cleanIdempotencyCache();
|
|
395
535
|
}
|
|
536
|
+
// Progressive Enhancement: native HTML form → Post/Redirect/Get pattern
|
|
537
|
+
if (nativeForm) {
|
|
538
|
+
const fd = input instanceof FormData ? input : new FormData();
|
|
539
|
+
return formRedirect(request, fd);
|
|
540
|
+
}
|
|
396
541
|
// Serialize response with Nexus transport
|
|
397
542
|
const serialized = serialize({ data: result, status: 200, duration, idempotencyKey });
|
|
398
543
|
return new Response(serialized, {
|
|
@@ -442,19 +587,50 @@ export async function handleActionRequest(request) {
|
|
|
442
587
|
...(err.code !== undefined ? { code: err.code } : {}),
|
|
443
588
|
},
|
|
444
589
|
});
|
|
445
|
-
|
|
590
|
+
// Progressive Enhancement: redirect back with error encoded in query string
|
|
591
|
+
if (nativeForm) {
|
|
592
|
+
const referer = request.headers.get('referer') ?? '/';
|
|
593
|
+
const target = new URL(referer, request.url);
|
|
594
|
+
target.searchParams.set('__nexus_error', err.message);
|
|
595
|
+
if (err.code)
|
|
596
|
+
target.searchParams.set('__nexus_code', err.code);
|
|
597
|
+
return new Response(null, {
|
|
598
|
+
status: 303,
|
|
599
|
+
headers: { location: target.toString() },
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
return jsonResponse({
|
|
603
|
+
error: err.message,
|
|
604
|
+
status: err.status,
|
|
605
|
+
code: err.code,
|
|
606
|
+
...(err.fieldErrors ? { fieldErrors: err.fieldErrors } : {}),
|
|
607
|
+
}, err.status);
|
|
446
608
|
}
|
|
609
|
+
const errorId = randomUUID();
|
|
610
|
+
const mask = process.env['NEXUS_EXPOSE_ERRORS'] !== 'true' &&
|
|
611
|
+
process.env['NODE_ENV'] === 'production';
|
|
612
|
+
console.error(`[Nexus Action ${errorId}] "${actionName}" failed:`, err);
|
|
447
613
|
emitDevRadar({
|
|
448
614
|
type: 'action:error',
|
|
449
615
|
payload: {
|
|
450
616
|
id: traceId,
|
|
451
617
|
name: actionName,
|
|
452
|
-
error: err instanceof Error ? err.message : String(err),
|
|
618
|
+
error: mask ? 'Internal Server Error' : (err instanceof Error ? err.message : String(err)),
|
|
453
619
|
duration: Date.now() - startTime,
|
|
620
|
+
...(mask ? { errorId } : {}),
|
|
454
621
|
},
|
|
455
622
|
});
|
|
456
|
-
|
|
457
|
-
|
|
623
|
+
if (mask) {
|
|
624
|
+
return jsonResponse({
|
|
625
|
+
error: 'Internal Server Error',
|
|
626
|
+
status: 500,
|
|
627
|
+
errorId,
|
|
628
|
+
}, 500);
|
|
629
|
+
}
|
|
630
|
+
return jsonResponse({
|
|
631
|
+
error: err instanceof Error ? err.message : String(err),
|
|
632
|
+
status: 500,
|
|
633
|
+
}, 500);
|
|
458
634
|
}
|
|
459
635
|
finally {
|
|
460
636
|
clearTimeout(timeoutId);
|
|
@@ -479,16 +655,89 @@ export async function validateRequest(ctx) {
|
|
|
479
655
|
// Same-origin and same-site fetch() calls either omit Origin or match the host.
|
|
480
656
|
const origin = ctx.request.headers.get('origin');
|
|
481
657
|
const referer = ctx.request.headers.get('referer');
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
const
|
|
658
|
+
const rawHost = ctx.request.headers.get('host') ?? '';
|
|
659
|
+
// Derive scheme from X-Forwarded-Proto or default to http (covered by HTTPS terminator upstream)
|
|
660
|
+
const proto = ctx.request.headers.get('x-forwarded-proto') ?? 'http';
|
|
661
|
+
const expectedOrigin = `${proto}://${rawHost}`;
|
|
662
|
+
function isSameOrigin(headerValue) {
|
|
663
|
+
try {
|
|
664
|
+
const parsed = new URL(headerValue);
|
|
665
|
+
const expected = new URL(expectedOrigin);
|
|
666
|
+
return parsed.protocol === expected.protocol
|
|
667
|
+
&& parsed.hostname === expected.hostname
|
|
668
|
+
&& parsed.port === expected.port;
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const suspectOrigin = origin && !isSameOrigin(origin);
|
|
675
|
+
const suspectReferer = !origin && referer && !isSameOrigin(referer);
|
|
485
676
|
if (suspectOrigin || suspectReferer) {
|
|
486
|
-
throw new ActionError(`Cross-origin action request blocked (host: ${
|
|
677
|
+
throw new ActionError(`Cross-origin action request blocked (host: ${rawHost})`, 403, 'CROSS_ORIGIN_BLOCKED');
|
|
487
678
|
}
|
|
488
679
|
}
|
|
489
680
|
// Re-export security primitives for use in app code
|
|
490
681
|
export { generateActionToken, validateActionToken, extractSessionId, generateSessionId } from './csrf.js';
|
|
491
682
|
export { createRateLimiter, RateLimitError, parseWindow } from './rate-limit.js';
|
|
683
|
+
/**
|
|
684
|
+
* Returns `true` when `url` is a safe **public** `http:` / `https:` target for
|
|
685
|
+
* server-side `fetch` (not loopback, RFC1918, link-local, metadata IPs, etc.).
|
|
686
|
+
* Use before `fetch(userUrl)` to reduce blind SSRF risk.
|
|
687
|
+
*/
|
|
688
|
+
export function isSafeUrl(url) {
|
|
689
|
+
try {
|
|
690
|
+
const u = new URL(url);
|
|
691
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:')
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
return !isInternalUrl(url);
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Returns `true` when a URL resolves to a private, loopback, or link-local
|
|
701
|
+
* address. Inverse of {@link isSafeUrl} for `http:` / `https:`.
|
|
702
|
+
*/
|
|
703
|
+
export function isInternalUrl(url) {
|
|
704
|
+
let parsed;
|
|
705
|
+
try {
|
|
706
|
+
parsed = new URL(url);
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
return false; // unparseable URLs are not our SSRF concern — let the caller decide
|
|
710
|
+
}
|
|
711
|
+
// Only http/https are fetched in typical web actions; block everything else
|
|
712
|
+
// (file://, ftp://, etc.) as they access local resources.
|
|
713
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
714
|
+
return true;
|
|
715
|
+
const h = parsed.hostname.toLowerCase();
|
|
716
|
+
// Loopback
|
|
717
|
+
if (h === 'localhost' || h === '127.0.0.1' || h === '::1')
|
|
718
|
+
return true;
|
|
719
|
+
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h))
|
|
720
|
+
return true;
|
|
721
|
+
// Link-local (AWS/GCP/Azure metadata service lives at 169.254.169.254)
|
|
722
|
+
if (h.startsWith('169.254.'))
|
|
723
|
+
return true;
|
|
724
|
+
if (h === 'fe80::1' || h.startsWith('fe80:'))
|
|
725
|
+
return true;
|
|
726
|
+
// RFC 1918 private ranges
|
|
727
|
+
if (h.startsWith('10.'))
|
|
728
|
+
return true;
|
|
729
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h))
|
|
730
|
+
return true;
|
|
731
|
+
if (h.startsWith('192.168.'))
|
|
732
|
+
return true;
|
|
733
|
+
// Unique local IPv6
|
|
734
|
+
if (/^fd[0-9a-f]{2}:/i.test(h) || h.startsWith('fc'))
|
|
735
|
+
return true;
|
|
736
|
+
// Unspecified / broadcast
|
|
737
|
+
if (h === '0.0.0.0' || h === '255.255.255.255')
|
|
738
|
+
return true;
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
492
741
|
// ── Client-side race guard ────────────────────────────────────────────────────
|
|
493
742
|
/**
|
|
494
743
|
* Client-side AbortController factory.
|
|
@@ -531,6 +780,53 @@ export function createActionGuard(name, strategy = 'cancel') {
|
|
|
531
780
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
532
781
|
/** Maximum request body for a single server action (10 MB). Override per-action via opts.maxBodyBytes. */
|
|
533
782
|
const MAX_ACTION_BODY_BYTES = 10 * 1_024 * 1_024;
|
|
783
|
+
/** Maximum JSON object nesting depth. Prevents CPU DoS via pathological `{"a":{"b":{…}}}` inputs. */
|
|
784
|
+
const MAX_JSON_DEPTH = 10;
|
|
785
|
+
/** Maximum number of JSON object keys across all nesting levels. Prevents key-explosion attacks. */
|
|
786
|
+
const MAX_JSON_KEYS = 1_000;
|
|
787
|
+
/**
|
|
788
|
+
* Walks the raw JSON text character-by-character (O(n)) tracking nesting depth and
|
|
789
|
+
* key count WITHOUT running the full parser. Throws ActionError before `JSON.parse`
|
|
790
|
+
* touches the data — prevents CPU DoS from deeply nested or key-explosion payloads
|
|
791
|
+
* that fit within MAX_ACTION_BODY_BYTES but would block the event loop for seconds.
|
|
792
|
+
*/
|
|
793
|
+
function assertJsonComplexity(text) {
|
|
794
|
+
let depth = 0;
|
|
795
|
+
let keys = 0;
|
|
796
|
+
let inStr = false;
|
|
797
|
+
let esc = false;
|
|
798
|
+
for (let i = 0; i < text.length; i++) {
|
|
799
|
+
const ch = text[i];
|
|
800
|
+
if (esc) {
|
|
801
|
+
esc = false;
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
if (ch === '\\' && inStr) {
|
|
805
|
+
esc = true;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
if (ch === '"') {
|
|
809
|
+
inStr = !inStr;
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
if (inStr) {
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
if (ch === '{' || ch === '[') {
|
|
816
|
+
if (++depth > MAX_JSON_DEPTH) {
|
|
817
|
+
throw new ActionError(`JSON nesting too deep (limit ${MAX_JSON_DEPTH} levels)`, 400, 'JSON_TOO_DEEP');
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
else if (ch === '}' || ch === ']') {
|
|
821
|
+
depth--;
|
|
822
|
+
}
|
|
823
|
+
else if (ch === ':') {
|
|
824
|
+
if (++keys > MAX_JSON_KEYS) {
|
|
825
|
+
throw new ActionError(`JSON too complex (limit ${MAX_JSON_KEYS} keys)`, 400, 'JSON_TOO_COMPLEX');
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
534
830
|
async function deserializeInput(request, maxBytes = MAX_ACTION_BODY_BYTES) {
|
|
535
831
|
// DoS protection: reject oversized bodies before reading any bytes.
|
|
536
832
|
// Prevents memory exhaustion from clients that stream arbitrarily large payloads.
|
|
@@ -544,6 +840,9 @@ async function deserializeInput(request, maxBytes = MAX_ACTION_BODY_BYTES) {
|
|
|
544
840
|
if (text.length > maxBytes) {
|
|
545
841
|
throw new ActionError('Request body too large', 413, 'PAYLOAD_TOO_LARGE');
|
|
546
842
|
}
|
|
843
|
+
// Complexity guard: run BEFORE the parser so pathological payloads (deeply
|
|
844
|
+
// nested or key-explosion) are rejected without blocking the event loop.
|
|
845
|
+
assertJsonComplexity(text);
|
|
547
846
|
try {
|
|
548
847
|
return deserialize(text);
|
|
549
848
|
}
|