@kuratchi/js 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +168 -11
  2. package/dist/cli.js +13 -13
  3. package/dist/compiler/client-module-pipeline.js +5 -5
  4. package/dist/compiler/compiler-shared.d.ts +18 -0
  5. package/dist/compiler/component-pipeline.js +4 -9
  6. package/dist/compiler/config-reading.d.ts +2 -1
  7. package/dist/compiler/config-reading.js +57 -0
  8. package/dist/compiler/durable-object-pipeline.js +1 -1
  9. package/dist/compiler/import-linking.js +2 -1
  10. package/dist/compiler/index.d.ts +6 -6
  11. package/dist/compiler/index.js +57 -23
  12. package/dist/compiler/layout-pipeline.js +6 -6
  13. package/dist/compiler/parser.js +10 -11
  14. package/dist/compiler/root-layout-pipeline.js +444 -429
  15. package/dist/compiler/route-pipeline.js +36 -41
  16. package/dist/compiler/route-state-pipeline.d.ts +1 -0
  17. package/dist/compiler/route-state-pipeline.js +3 -3
  18. package/dist/compiler/routes-module-feature-blocks.js +63 -63
  19. package/dist/compiler/routes-module-runtime-shell.js +65 -55
  20. package/dist/compiler/routes-module-types.d.ts +2 -1
  21. package/dist/compiler/server-module-pipeline.js +1 -1
  22. package/dist/compiler/template.js +24 -15
  23. package/dist/compiler/worker-output-pipeline.d.ts +1 -0
  24. package/dist/compiler/worker-output-pipeline.js +10 -2
  25. package/dist/create.js +1 -1
  26. package/dist/runtime/context.d.ts +4 -0
  27. package/dist/runtime/context.js +40 -2
  28. package/dist/runtime/do.js +21 -6
  29. package/dist/runtime/generated-worker.d.ts +22 -0
  30. package/dist/runtime/generated-worker.js +154 -23
  31. package/dist/runtime/index.d.ts +3 -1
  32. package/dist/runtime/index.js +1 -0
  33. package/dist/runtime/router.d.ts +5 -1
  34. package/dist/runtime/router.js +116 -31
  35. package/dist/runtime/security.d.ts +101 -0
  36. package/dist/runtime/security.js +312 -0
  37. package/dist/runtime/types.d.ts +21 -0
  38. package/package.json +1 -1
@@ -0,0 +1,101 @@
1
+ /**
2
+ * KuratchiJS Security Module
3
+ *
4
+ * Provides CSRF protection, request signing, and security utilities
5
+ * for the framework runtime.
6
+ */
7
+ /**
8
+ * Initialize CSRF protection for the current request.
9
+ * Generates a new token if one doesn't exist in cookies.
10
+ */
11
+ export declare function initCsrf(request: Request, cookieName?: string): string;
12
+ /**
13
+ * Get the current CSRF token for the request
14
+ */
15
+ export declare function getCsrfToken(): string;
16
+ /**
17
+ * Validate CSRF token from request against stored token.
18
+ * Checks both header and form field.
19
+ */
20
+ export declare function validateCsrf(request: Request, formData?: FormData, headerName?: string, formField?: string): {
21
+ valid: boolean;
22
+ reason?: string;
23
+ };
24
+ /**
25
+ * Get the Set-Cookie header for CSRF token if needed
26
+ */
27
+ export declare function getCsrfCookieHeader(): string | null;
28
+ export interface RpcSecurityConfig {
29
+ requireAuth: boolean;
30
+ validateCsrf: boolean;
31
+ allowedMethods: ('GET' | 'POST')[];
32
+ }
33
+ /**
34
+ * Generate a per-request RPC nonce for additional request signing
35
+ */
36
+ export declare function generateRpcNonce(): string;
37
+ /**
38
+ * Validate an RPC request meets security requirements
39
+ */
40
+ export declare function validateRpcRequest(request: Request, config: RpcSecurityConfig): {
41
+ valid: boolean;
42
+ status: number;
43
+ reason?: string;
44
+ };
45
+ export interface ActionSecurityConfig {
46
+ validateCsrf: boolean;
47
+ requireSameOrigin: boolean;
48
+ }
49
+ /**
50
+ * Validate an action request meets security requirements
51
+ */
52
+ export declare function validateActionRequest(request: Request, url: URL, formData: FormData, config: ActionSecurityConfig): {
53
+ valid: boolean;
54
+ status: number;
55
+ reason?: string;
56
+ };
57
+ export interface SecurityHeadersConfig {
58
+ contentSecurityPolicy?: string | null;
59
+ strictTransportSecurity?: string | null;
60
+ permissionsPolicy?: string | null;
61
+ }
62
+ /**
63
+ * Apply security headers to a response
64
+ */
65
+ export declare function applySecurityHeaders(response: Response, config?: SecurityHeadersConfig): Response;
66
+ /**
67
+ * Sign a fragment ID with the route path and CSRF token to prevent tampering.
68
+ * Format: fragmentId:signature
69
+ */
70
+ export declare function signFragmentId(fragmentId: string, routePath: string): string;
71
+ /**
72
+ * Validate a signed fragment ID against the current route and session.
73
+ * If no CSRF token is present (e.g., in tests), allows unsigned fragments.
74
+ */
75
+ export declare function validateSignedFragment(signedFragment: string, routePath: string): {
76
+ valid: boolean;
77
+ fragmentId: string | null;
78
+ reason?: string;
79
+ };
80
+ /**
81
+ * Validate that a query function is allowed for the current route.
82
+ * The allowedQueries set should contain the function names registered for this route.
83
+ */
84
+ export declare function validateQueryOverride(queryFn: string, allowedQueries: Set<string> | string[]): {
85
+ valid: boolean;
86
+ reason?: string;
87
+ };
88
+ /**
89
+ * Validate query arguments are safe JSON.
90
+ * Returns parsed arguments or null if invalid.
91
+ */
92
+ export declare function parseQueryArgs(argsRaw: string): {
93
+ valid: boolean;
94
+ args: unknown[];
95
+ reason?: string;
96
+ };
97
+ export declare const CSRF_DEFAULTS: {
98
+ readonly cookieName: "__kuratchi_csrf";
99
+ readonly headerName: "x-kuratchi-csrf";
100
+ readonly formField: "_csrf";
101
+ };
@@ -0,0 +1,312 @@
1
+ /**
2
+ * KuratchiJS Security Module
3
+ *
4
+ * Provides CSRF protection, request signing, and security utilities
5
+ * for the framework runtime.
6
+ */
7
+ import { __getLocals, __setLocal } from './context.js';
8
+ // ── CSRF Token Management ──────────────────────────────────────────
9
+ const CSRF_TOKEN_LENGTH = 32;
10
+ const CSRF_COOKIE_NAME = '__kuratchi_csrf';
11
+ const CSRF_HEADER_NAME = 'x-kuratchi-csrf';
12
+ const CSRF_FORM_FIELD = '_csrf';
13
+ /**
14
+ * Generate a cryptographically secure random token
15
+ */
16
+ function generateToken(length = CSRF_TOKEN_LENGTH) {
17
+ const bytes = new Uint8Array(length);
18
+ crypto.getRandomValues(bytes);
19
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
20
+ }
21
+ /**
22
+ * Initialize CSRF protection for the current request.
23
+ * Generates a new token if one doesn't exist in cookies.
24
+ */
25
+ export function initCsrf(request, cookieName = CSRF_COOKIE_NAME) {
26
+ const cookies = parseCookies(request.headers.get('cookie'));
27
+ let token = cookies[cookieName];
28
+ if (!token || token.length < CSRF_TOKEN_LENGTH) {
29
+ token = generateToken();
30
+ // Mark that we need to set the cookie
31
+ __setLocal('__csrfTokenNew', true);
32
+ }
33
+ __setLocal('__csrfToken', token);
34
+ __setLocal('__csrfCookieName', cookieName);
35
+ __setLocal('__csrfCookieSecure', shouldUseSecureCookie(request));
36
+ return token;
37
+ }
38
+ /**
39
+ * Get the current CSRF token for the request
40
+ */
41
+ export function getCsrfToken() {
42
+ const token = __getLocals().__csrfToken;
43
+ if (!token) {
44
+ throw new Error('[kuratchi] CSRF token not initialized. Ensure security middleware is active.');
45
+ }
46
+ return token;
47
+ }
48
+ /**
49
+ * Validate CSRF token from request against stored token.
50
+ * Checks both header and form field.
51
+ */
52
+ export function validateCsrf(request, formData, headerName = CSRF_HEADER_NAME, formField = CSRF_FORM_FIELD) {
53
+ const storedToken = __getLocals().__csrfToken;
54
+ if (!storedToken) {
55
+ return { valid: false, reason: 'No CSRF token in session' };
56
+ }
57
+ // Check header first (for fetch requests)
58
+ const headerToken = request.headers.get(headerName);
59
+ if (headerToken) {
60
+ if (timingSafeEqual(headerToken, storedToken)) {
61
+ return { valid: true };
62
+ }
63
+ return { valid: false, reason: 'CSRF header token mismatch' };
64
+ }
65
+ // Check form field (for traditional form submissions)
66
+ if (formData) {
67
+ const formToken = formData.get(formField);
68
+ if (typeof formToken === 'string' && timingSafeEqual(formToken, storedToken)) {
69
+ return { valid: true };
70
+ }
71
+ return { valid: false, reason: 'CSRF form token mismatch or missing' };
72
+ }
73
+ return { valid: false, reason: 'No CSRF token provided in request' };
74
+ }
75
+ /**
76
+ * Timing-safe string comparison to prevent timing attacks
77
+ */
78
+ function timingSafeEqual(a, b) {
79
+ if (a.length !== b.length) {
80
+ return false;
81
+ }
82
+ let result = 0;
83
+ for (let i = 0; i < a.length; i++) {
84
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
85
+ }
86
+ return result === 0;
87
+ }
88
+ /**
89
+ * Get the Set-Cookie header for CSRF token if needed
90
+ */
91
+ export function getCsrfCookieHeader() {
92
+ const locals = __getLocals();
93
+ if (!locals.__csrfTokenNew) {
94
+ return null;
95
+ }
96
+ const token = locals.__csrfToken;
97
+ const cookieName = locals.__csrfCookieName || CSRF_COOKIE_NAME;
98
+ const secure = locals.__csrfCookieSecure ? '; Secure' : '';
99
+ // SameSite=Lax allows the cookie to be sent on top-level navigations
100
+ // HttpOnly=false so client JS can read it for fetch requests
101
+ return `${cookieName}=${token}; Path=/; SameSite=Lax${secure}`;
102
+ }
103
+ // ── RPC Security ───────────────────────────────────────────────────
104
+ const RPC_NONCE_LENGTH = 16;
105
+ /**
106
+ * Generate a per-request RPC nonce for additional request signing
107
+ */
108
+ export function generateRpcNonce() {
109
+ return generateToken(RPC_NONCE_LENGTH);
110
+ }
111
+ /**
112
+ * Validate an RPC request meets security requirements
113
+ */
114
+ export function validateRpcRequest(request, config) {
115
+ const method = request.method;
116
+ // Check allowed methods
117
+ if (!config.allowedMethods.includes(method)) {
118
+ return {
119
+ valid: false,
120
+ status: 405,
121
+ reason: `RPC method ${method} not allowed. Use: ${config.allowedMethods.join(', ')}`,
122
+ };
123
+ }
124
+ // Check CSRF if enabled
125
+ if (config.validateCsrf) {
126
+ const csrfResult = validateCsrf(request);
127
+ if (!csrfResult.valid) {
128
+ return { valid: false, status: 403, reason: csrfResult.reason };
129
+ }
130
+ }
131
+ // Check authentication if required
132
+ if (config.requireAuth) {
133
+ const locals = __getLocals();
134
+ const user = locals.user || locals.session?.user;
135
+ if (!user) {
136
+ return { valid: false, status: 401, reason: 'Authentication required for RPC' };
137
+ }
138
+ }
139
+ return { valid: true, status: 200 };
140
+ }
141
+ /**
142
+ * Validate an action request meets security requirements
143
+ */
144
+ export function validateActionRequest(request, url, formData, config) {
145
+ // Check same-origin
146
+ if (config.requireSameOrigin && !isSameOrigin(request, url)) {
147
+ return { valid: false, status: 403, reason: 'Cross-origin action requests forbidden' };
148
+ }
149
+ // Check CSRF if enabled
150
+ if (config.validateCsrf) {
151
+ const csrfResult = validateCsrf(request, formData);
152
+ if (!csrfResult.valid) {
153
+ return { valid: false, status: 403, reason: csrfResult.reason };
154
+ }
155
+ }
156
+ return { valid: true, status: 200 };
157
+ }
158
+ const DEFAULT_SEC_HEADERS = {
159
+ 'X-Content-Type-Options': 'nosniff',
160
+ 'X-Frame-Options': 'DENY',
161
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
162
+ };
163
+ /**
164
+ * Apply security headers to a response
165
+ */
166
+ export function applySecurityHeaders(response, config) {
167
+ for (const [key, value] of Object.entries(DEFAULT_SEC_HEADERS)) {
168
+ if (!response.headers.has(key)) {
169
+ response.headers.set(key, value);
170
+ }
171
+ }
172
+ if (config?.contentSecurityPolicy && !response.headers.has('Content-Security-Policy')) {
173
+ response.headers.set('Content-Security-Policy', config.contentSecurityPolicy);
174
+ }
175
+ if (config?.strictTransportSecurity && !response.headers.has('Strict-Transport-Security')) {
176
+ response.headers.set('Strict-Transport-Security', config.strictTransportSecurity);
177
+ }
178
+ if (config?.permissionsPolicy && !response.headers.has('Permissions-Policy')) {
179
+ response.headers.set('Permissions-Policy', config.permissionsPolicy);
180
+ }
181
+ return response;
182
+ }
183
+ // ── Utility Functions ──────────────────────────────────────────────
184
+ function parseCookies(header) {
185
+ const map = {};
186
+ if (!header)
187
+ return map;
188
+ for (const pair of header.split(';')) {
189
+ const eq = pair.indexOf('=');
190
+ if (eq === -1)
191
+ continue;
192
+ map[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
193
+ }
194
+ return map;
195
+ }
196
+ function shouldUseSecureCookie(request) {
197
+ const forwardedProto = request.headers.get('x-forwarded-proto');
198
+ if (forwardedProto) {
199
+ return forwardedProto.split(',')[0].trim().toLowerCase() === 'https';
200
+ }
201
+ try {
202
+ return new URL(request.url).protocol === 'https:';
203
+ }
204
+ catch {
205
+ return false;
206
+ }
207
+ }
208
+ function isSameOrigin(request, url) {
209
+ const fetchSite = request.headers.get('sec-fetch-site');
210
+ if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'same-site' && fetchSite !== 'none') {
211
+ return false;
212
+ }
213
+ const origin = request.headers.get('origin');
214
+ if (!origin)
215
+ return true;
216
+ try {
217
+ return new URL(origin).origin === url.origin;
218
+ }
219
+ catch {
220
+ return false;
221
+ }
222
+ }
223
+ // ── Fragment Security ───────────────────────────────────────────────
224
+ /**
225
+ * Sign a fragment ID with the route path and CSRF token to prevent tampering.
226
+ * Format: fragmentId:signature
227
+ */
228
+ export function signFragmentId(fragmentId, routePath) {
229
+ const token = __getLocals().__csrfToken || '';
230
+ const payload = `${fragmentId}:${routePath}:${token}`;
231
+ const signature = simpleHash(payload);
232
+ return `${fragmentId}:${signature}`;
233
+ }
234
+ /**
235
+ * Validate a signed fragment ID against the current route and session.
236
+ * If no CSRF token is present (e.g., in tests), allows unsigned fragments.
237
+ */
238
+ export function validateSignedFragment(signedFragment, routePath) {
239
+ const token = __getLocals().__csrfToken || '';
240
+ // If no CSRF token is set, allow unsigned fragments (backward compat / tests)
241
+ if (!token) {
242
+ const colonIdx = signedFragment.lastIndexOf(':');
243
+ // If it looks signed, extract the fragment ID; otherwise use as-is
244
+ const fragmentId = colonIdx !== -1 ? signedFragment.slice(0, colonIdx) : signedFragment;
245
+ return { valid: true, fragmentId };
246
+ }
247
+ const colonIdx = signedFragment.lastIndexOf(':');
248
+ if (colonIdx === -1) {
249
+ // Unsigned fragment with CSRF enabled - reject for security
250
+ return { valid: false, fragmentId: null, reason: 'Fragment ID not signed' };
251
+ }
252
+ const fragmentId = signedFragment.slice(0, colonIdx);
253
+ const providedSignature = signedFragment.slice(colonIdx + 1);
254
+ const payload = `${fragmentId}:${routePath}:${token}`;
255
+ const expectedSignature = simpleHash(payload);
256
+ if (!timingSafeEqual(providedSignature, expectedSignature)) {
257
+ return { valid: false, fragmentId: null, reason: 'Fragment signature invalid' };
258
+ }
259
+ return { valid: true, fragmentId };
260
+ }
261
+ /**
262
+ * Simple hash function for signing (not cryptographic, but sufficient for HMAC-like signing
263
+ * when combined with a secret token). Uses FNV-1a for speed.
264
+ */
265
+ function simpleHash(str) {
266
+ let hash = 2166136261;
267
+ for (let i = 0; i < str.length; i++) {
268
+ hash ^= str.charCodeAt(i);
269
+ hash = (hash * 16777619) >>> 0;
270
+ }
271
+ return hash.toString(36);
272
+ }
273
+ // ── Query Override Security ────────────────────────────────────────
274
+ /**
275
+ * Validate that a query function is allowed for the current route.
276
+ * The allowedQueries set should contain the function names registered for this route.
277
+ */
278
+ export function validateQueryOverride(queryFn, allowedQueries) {
279
+ const allowed = allowedQueries instanceof Set ? allowedQueries : new Set(allowedQueries);
280
+ if (!queryFn) {
281
+ return { valid: false, reason: 'No query function specified' };
282
+ }
283
+ if (!allowed.has(queryFn)) {
284
+ return { valid: false, reason: `Query function '${queryFn}' not registered for this route` };
285
+ }
286
+ return { valid: true };
287
+ }
288
+ /**
289
+ * Validate query arguments are safe JSON.
290
+ * Returns parsed arguments or null if invalid.
291
+ */
292
+ export function parseQueryArgs(argsRaw) {
293
+ if (!argsRaw || argsRaw === '[]') {
294
+ return { valid: true, args: [] };
295
+ }
296
+ try {
297
+ const parsed = JSON.parse(argsRaw);
298
+ if (!Array.isArray(parsed)) {
299
+ return { valid: false, args: [], reason: 'Query args must be an array' };
300
+ }
301
+ return { valid: true, args: parsed };
302
+ }
303
+ catch {
304
+ return { valid: false, args: [], reason: 'Invalid JSON in query args' };
305
+ }
306
+ }
307
+ // ── Exports for Framework Use ──────────────────────────────────────
308
+ export const CSRF_DEFAULTS = {
309
+ cookieName: CSRF_COOKIE_NAME,
310
+ headerName: CSRF_HEADER_NAME,
311
+ formField: CSRF_FORM_FIELD,
312
+ };
@@ -230,6 +230,27 @@ export interface AuthConfig {
230
230
  binding?: string;
231
231
  [key: string]: any;
232
232
  } | Record<string, any>;
233
+ /** Security configuration â€" CSRF protection, RPC security */
234
+ security?: SecurityConfig;
235
+ }
236
+ /** Security configuration for kuratchi.config.ts */
237
+ export interface SecurityConfig {
238
+ /** Enable CSRF protection for actions and RPC (default: true) */
239
+ csrfEnabled?: boolean;
240
+ /** CSRF cookie name (default: '__kuratchi_csrf') */
241
+ csrfCookieName?: string;
242
+ /** CSRF header name for fetch requests (default: 'x-kuratchi-csrf') */
243
+ csrfHeaderName?: string;
244
+ /** Require authentication for RPC calls (default: false) */
245
+ rpcRequireAuth?: boolean;
246
+ /** Require authentication for form actions (default: false) */
247
+ actionRequireAuth?: boolean;
248
+ /** Content Security Policy directive string */
249
+ contentSecurityPolicy?: string;
250
+ /** Strict-Transport-Security header value */
251
+ strictTransportSecurity?: string;
252
+ /** Permissions-Policy header value */
253
+ permissionsPolicy?: string;
233
254
  }
234
255
  /** Runtime pipeline context - shared across runtime step handlers */
235
256
  export interface RuntimeContext<E extends Env = Env> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuratchi/js",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "A thin, Cloudflare Workers-native web framework with Svelte-inspired syntax",
5
5
  "license": "MIT",
6
6
  "type": "module",