@kuratchi/js 0.0.15 → 0.0.17
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/README.md +160 -1
- package/dist/cli.js +78 -45
- package/dist/compiler/api-route-pipeline.d.ts +8 -0
- package/dist/compiler/api-route-pipeline.js +23 -0
- package/dist/compiler/asset-pipeline.d.ts +7 -0
- package/dist/compiler/asset-pipeline.js +33 -0
- package/dist/compiler/client-module-pipeline.d.ts +25 -0
- package/dist/compiler/client-module-pipeline.js +257 -0
- package/dist/compiler/compiler-shared.d.ts +73 -0
- package/dist/compiler/compiler-shared.js +4 -0
- package/dist/compiler/component-pipeline.d.ts +15 -0
- package/dist/compiler/component-pipeline.js +158 -0
- package/dist/compiler/config-reading.d.ts +12 -0
- package/dist/compiler/config-reading.js +380 -0
- package/dist/compiler/convention-discovery.d.ts +9 -0
- package/dist/compiler/convention-discovery.js +83 -0
- package/dist/compiler/durable-object-pipeline.d.ts +9 -0
- package/dist/compiler/durable-object-pipeline.js +255 -0
- package/dist/compiler/error-page-pipeline.d.ts +1 -0
- package/dist/compiler/error-page-pipeline.js +16 -0
- package/dist/compiler/import-linking.d.ts +36 -0
- package/dist/compiler/import-linking.js +140 -0
- package/dist/compiler/index.d.ts +7 -7
- package/dist/compiler/index.js +181 -3321
- package/dist/compiler/layout-pipeline.d.ts +31 -0
- package/dist/compiler/layout-pipeline.js +155 -0
- package/dist/compiler/page-route-pipeline.d.ts +16 -0
- package/dist/compiler/page-route-pipeline.js +62 -0
- package/dist/compiler/parser.d.ts +4 -0
- package/dist/compiler/parser.js +436 -55
- package/dist/compiler/root-layout-pipeline.d.ts +10 -0
- package/dist/compiler/root-layout-pipeline.js +532 -0
- package/dist/compiler/route-discovery.d.ts +7 -0
- package/dist/compiler/route-discovery.js +87 -0
- package/dist/compiler/route-pipeline.d.ts +57 -0
- package/dist/compiler/route-pipeline.js +291 -0
- package/dist/compiler/route-state-pipeline.d.ts +26 -0
- package/dist/compiler/route-state-pipeline.js +139 -0
- package/dist/compiler/routes-module-feature-blocks.d.ts +2 -0
- package/dist/compiler/routes-module-feature-blocks.js +330 -0
- package/dist/compiler/routes-module-pipeline.d.ts +2 -0
- package/dist/compiler/routes-module-pipeline.js +6 -0
- package/dist/compiler/routes-module-runtime-shell.d.ts +2 -0
- package/dist/compiler/routes-module-runtime-shell.js +91 -0
- package/dist/compiler/routes-module-types.d.ts +45 -0
- package/dist/compiler/routes-module-types.js +1 -0
- package/dist/compiler/script-transform.d.ts +16 -0
- package/dist/compiler/script-transform.js +218 -0
- package/dist/compiler/server-module-pipeline.d.ts +13 -0
- package/dist/compiler/server-module-pipeline.js +124 -0
- package/dist/compiler/template.d.ts +13 -1
- package/dist/compiler/template.js +337 -71
- package/dist/compiler/worker-output-pipeline.d.ts +13 -0
- package/dist/compiler/worker-output-pipeline.js +37 -0
- package/dist/compiler/wrangler-sync.d.ts +14 -0
- package/dist/compiler/wrangler-sync.js +185 -0
- package/dist/runtime/app.js +15 -3
- package/dist/runtime/context.d.ts +4 -0
- package/dist/runtime/context.js +40 -2
- package/dist/runtime/do.js +21 -6
- package/dist/runtime/generated-worker.d.ts +55 -0
- package/dist/runtime/generated-worker.js +543 -0
- package/dist/runtime/index.d.ts +4 -1
- package/dist/runtime/index.js +2 -0
- package/dist/runtime/router.d.ts +6 -1
- package/dist/runtime/router.js +125 -31
- package/dist/runtime/security.d.ts +101 -0
- package/dist/runtime/security.js +298 -0
- package/dist/runtime/types.d.ts +29 -2
- package/package.json +5 -1
package/dist/runtime/router.js
CHANGED
|
@@ -5,47 +5,141 @@
|
|
|
5
5
|
* /todos → static
|
|
6
6
|
* /blog/:slug → named param
|
|
7
7
|
* /files/*rest → catch-all
|
|
8
|
+
*
|
|
9
|
+
* Uses a radix tree for O(log n) dynamic route matching instead of O(n) linear scan.
|
|
8
10
|
*/
|
|
11
|
+
function createNode(segment, type, paramName) {
|
|
12
|
+
return {
|
|
13
|
+
segment,
|
|
14
|
+
type,
|
|
15
|
+
paramName,
|
|
16
|
+
children: new Map(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
9
19
|
export class Router {
|
|
10
|
-
|
|
20
|
+
staticRoutes = new Map();
|
|
21
|
+
root = createNode('', 0 /* NodeType.Static */);
|
|
11
22
|
/** Register a pattern (e.g. '/blog/:slug') and associate it with an index. */
|
|
12
23
|
add(pattern, index) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
if (!pattern.includes(':') && !pattern.includes('*')) {
|
|
25
|
+
this.staticRoutes.set(pattern, index);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// Parse pattern into segments
|
|
29
|
+
const segments = pattern.split('/').filter(Boolean);
|
|
30
|
+
let node = this.root;
|
|
31
|
+
for (let i = 0; i < segments.length; i++) {
|
|
32
|
+
const seg = segments[i];
|
|
33
|
+
if (seg.startsWith('*')) {
|
|
34
|
+
// Catch-all: *param
|
|
35
|
+
const paramName = seg.slice(1);
|
|
36
|
+
if (!node.catchAllChild) {
|
|
37
|
+
node.catchAllChild = createNode('', 2 /* NodeType.CatchAll */, paramName);
|
|
38
|
+
}
|
|
39
|
+
node = node.catchAllChild;
|
|
40
|
+
// Catch-all must be last segment
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
else if (seg.startsWith(':')) {
|
|
44
|
+
// Param: :param
|
|
45
|
+
const paramName = seg.slice(1);
|
|
46
|
+
if (!node.paramChild) {
|
|
47
|
+
node.paramChild = createNode('', 1 /* NodeType.Param */, paramName);
|
|
48
|
+
}
|
|
49
|
+
node = node.paramChild;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// Static segment
|
|
53
|
+
const key = seg[0] || '';
|
|
54
|
+
let child = node.children.get(key);
|
|
55
|
+
if (!child) {
|
|
56
|
+
child = createNode(seg, 0 /* NodeType.Static */);
|
|
57
|
+
node.children.set(key, child);
|
|
58
|
+
}
|
|
59
|
+
else if (child.segment !== seg) {
|
|
60
|
+
// Handle prefix splitting for true radix tree (simplified: exact match only)
|
|
61
|
+
// For simplicity, we use segment-level matching which is sufficient for route patterns
|
|
62
|
+
let found = false;
|
|
63
|
+
for (const [, c] of node.children) {
|
|
64
|
+
if (c.segment === seg) {
|
|
65
|
+
child = c;
|
|
66
|
+
found = true;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!found) {
|
|
71
|
+
child = createNode(seg, 0 /* NodeType.Static */);
|
|
72
|
+
// Use full segment as key for collision handling
|
|
73
|
+
node.children.set(seg, child);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
node = child;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
node.routeIndex = index;
|
|
35
80
|
}
|
|
36
81
|
/** Match a pathname against registered routes. Returns null if no match. */
|
|
37
82
|
match(pathname) {
|
|
38
83
|
// Normalize: strip trailing slash (except root)
|
|
39
84
|
const normalized = pathname === '/' ? '/' : pathname.replace(/\/$/, '');
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
85
|
+
// Fast path: static routes (O(1))
|
|
86
|
+
const staticIdx = this.staticRoutes.get(normalized);
|
|
87
|
+
if (staticIdx !== undefined) {
|
|
88
|
+
return { params: {}, index: staticIdx };
|
|
89
|
+
}
|
|
90
|
+
// Radix tree traversal for dynamic routes
|
|
91
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
92
|
+
const params = {};
|
|
93
|
+
const result = this.matchNode(this.root, segments, 0, params);
|
|
94
|
+
if (result !== null) {
|
|
95
|
+
return { params, index: result };
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/** Recursive radix tree matching with backtracking */
|
|
100
|
+
matchNode(node, segments, segIdx, params) {
|
|
101
|
+
// Base case: consumed all segments
|
|
102
|
+
if (segIdx >= segments.length) {
|
|
103
|
+
return node.routeIndex ?? null;
|
|
104
|
+
}
|
|
105
|
+
const seg = segments[segIdx];
|
|
106
|
+
// 1. Try static children first (highest priority)
|
|
107
|
+
// Check by first char, then by full segment
|
|
108
|
+
const staticChild = node.children.get(seg[0]) ?? node.children.get(seg);
|
|
109
|
+
if (staticChild && staticChild.segment === seg) {
|
|
110
|
+
const result = this.matchNode(staticChild, segments, segIdx + 1, params);
|
|
111
|
+
if (result !== null)
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
// Also check full segment key for collision handling
|
|
115
|
+
for (const [key, child] of node.children) {
|
|
116
|
+
if (key !== seg[0] && child.segment === seg) {
|
|
117
|
+
const result = this.matchNode(child, segments, segIdx + 1, params);
|
|
118
|
+
if (result !== null)
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// 2. Try param child (second priority)
|
|
123
|
+
if (node.paramChild) {
|
|
124
|
+
const paramName = node.paramChild.paramName;
|
|
125
|
+
const oldValue = params[paramName];
|
|
126
|
+
params[paramName] = seg;
|
|
127
|
+
const result = this.matchNode(node.paramChild, segments, segIdx + 1, params);
|
|
128
|
+
if (result !== null)
|
|
129
|
+
return result;
|
|
130
|
+
// Backtrack
|
|
131
|
+
if (oldValue !== undefined) {
|
|
132
|
+
params[paramName] = oldValue;
|
|
48
133
|
}
|
|
134
|
+
else {
|
|
135
|
+
delete params[paramName];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// 3. Try catch-all child (lowest priority, consumes rest)
|
|
139
|
+
if (node.catchAllChild) {
|
|
140
|
+
const paramName = node.catchAllChild.paramName;
|
|
141
|
+
params[paramName] = segments.slice(segIdx).join('/');
|
|
142
|
+
return node.catchAllChild.routeIndex ?? null;
|
|
49
143
|
}
|
|
50
144
|
return null;
|
|
51
145
|
}
|
|
@@ -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,298 @@
|
|
|
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
|
+
return token;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Get the current CSRF token for the request
|
|
39
|
+
*/
|
|
40
|
+
export function getCsrfToken() {
|
|
41
|
+
const token = __getLocals().__csrfToken;
|
|
42
|
+
if (!token) {
|
|
43
|
+
throw new Error('[kuratchi] CSRF token not initialized. Ensure security middleware is active.');
|
|
44
|
+
}
|
|
45
|
+
return token;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Validate CSRF token from request against stored token.
|
|
49
|
+
* Checks both header and form field.
|
|
50
|
+
*/
|
|
51
|
+
export function validateCsrf(request, formData, headerName = CSRF_HEADER_NAME, formField = CSRF_FORM_FIELD) {
|
|
52
|
+
const storedToken = __getLocals().__csrfToken;
|
|
53
|
+
if (!storedToken) {
|
|
54
|
+
return { valid: false, reason: 'No CSRF token in session' };
|
|
55
|
+
}
|
|
56
|
+
// Check header first (for fetch requests)
|
|
57
|
+
const headerToken = request.headers.get(headerName);
|
|
58
|
+
if (headerToken) {
|
|
59
|
+
if (timingSafeEqual(headerToken, storedToken)) {
|
|
60
|
+
return { valid: true };
|
|
61
|
+
}
|
|
62
|
+
return { valid: false, reason: 'CSRF header token mismatch' };
|
|
63
|
+
}
|
|
64
|
+
// Check form field (for traditional form submissions)
|
|
65
|
+
if (formData) {
|
|
66
|
+
const formToken = formData.get(formField);
|
|
67
|
+
if (typeof formToken === 'string' && timingSafeEqual(formToken, storedToken)) {
|
|
68
|
+
return { valid: true };
|
|
69
|
+
}
|
|
70
|
+
return { valid: false, reason: 'CSRF form token mismatch or missing' };
|
|
71
|
+
}
|
|
72
|
+
return { valid: false, reason: 'No CSRF token provided in request' };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Timing-safe string comparison to prevent timing attacks
|
|
76
|
+
*/
|
|
77
|
+
function timingSafeEqual(a, b) {
|
|
78
|
+
if (a.length !== b.length) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
let result = 0;
|
|
82
|
+
for (let i = 0; i < a.length; i++) {
|
|
83
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
84
|
+
}
|
|
85
|
+
return result === 0;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Get the Set-Cookie header for CSRF token if needed
|
|
89
|
+
*/
|
|
90
|
+
export function getCsrfCookieHeader() {
|
|
91
|
+
const locals = __getLocals();
|
|
92
|
+
if (!locals.__csrfTokenNew) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const token = locals.__csrfToken;
|
|
96
|
+
const cookieName = locals.__csrfCookieName || CSRF_COOKIE_NAME;
|
|
97
|
+
// SameSite=Lax allows the cookie to be sent on top-level navigations
|
|
98
|
+
// HttpOnly=false so client JS can read it for fetch requests
|
|
99
|
+
return `${cookieName}=${token}; Path=/; SameSite=Lax; Secure`;
|
|
100
|
+
}
|
|
101
|
+
// ── RPC Security ───────────────────────────────────────────────────
|
|
102
|
+
const RPC_NONCE_LENGTH = 16;
|
|
103
|
+
/**
|
|
104
|
+
* Generate a per-request RPC nonce for additional request signing
|
|
105
|
+
*/
|
|
106
|
+
export function generateRpcNonce() {
|
|
107
|
+
return generateToken(RPC_NONCE_LENGTH);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Validate an RPC request meets security requirements
|
|
111
|
+
*/
|
|
112
|
+
export function validateRpcRequest(request, config) {
|
|
113
|
+
const method = request.method;
|
|
114
|
+
// Check allowed methods
|
|
115
|
+
if (!config.allowedMethods.includes(method)) {
|
|
116
|
+
return {
|
|
117
|
+
valid: false,
|
|
118
|
+
status: 405,
|
|
119
|
+
reason: `RPC method ${method} not allowed. Use: ${config.allowedMethods.join(', ')}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// Check CSRF if enabled
|
|
123
|
+
if (config.validateCsrf) {
|
|
124
|
+
const csrfResult = validateCsrf(request);
|
|
125
|
+
if (!csrfResult.valid) {
|
|
126
|
+
return { valid: false, status: 403, reason: csrfResult.reason };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Check authentication if required
|
|
130
|
+
if (config.requireAuth) {
|
|
131
|
+
const locals = __getLocals();
|
|
132
|
+
const user = locals.user || locals.session?.user;
|
|
133
|
+
if (!user) {
|
|
134
|
+
return { valid: false, status: 401, reason: 'Authentication required for RPC' };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { valid: true, status: 200 };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Validate an action request meets security requirements
|
|
141
|
+
*/
|
|
142
|
+
export function validateActionRequest(request, url, formData, config) {
|
|
143
|
+
// Check same-origin
|
|
144
|
+
if (config.requireSameOrigin && !isSameOrigin(request, url)) {
|
|
145
|
+
return { valid: false, status: 403, reason: 'Cross-origin action requests forbidden' };
|
|
146
|
+
}
|
|
147
|
+
// Check CSRF if enabled
|
|
148
|
+
if (config.validateCsrf) {
|
|
149
|
+
const csrfResult = validateCsrf(request, formData);
|
|
150
|
+
if (!csrfResult.valid) {
|
|
151
|
+
return { valid: false, status: 403, reason: csrfResult.reason };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { valid: true, status: 200 };
|
|
155
|
+
}
|
|
156
|
+
const DEFAULT_SEC_HEADERS = {
|
|
157
|
+
'X-Content-Type-Options': 'nosniff',
|
|
158
|
+
'X-Frame-Options': 'DENY',
|
|
159
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Apply security headers to a response
|
|
163
|
+
*/
|
|
164
|
+
export function applySecurityHeaders(response, config) {
|
|
165
|
+
for (const [key, value] of Object.entries(DEFAULT_SEC_HEADERS)) {
|
|
166
|
+
if (!response.headers.has(key)) {
|
|
167
|
+
response.headers.set(key, value);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (config?.contentSecurityPolicy && !response.headers.has('Content-Security-Policy')) {
|
|
171
|
+
response.headers.set('Content-Security-Policy', config.contentSecurityPolicy);
|
|
172
|
+
}
|
|
173
|
+
if (config?.strictTransportSecurity && !response.headers.has('Strict-Transport-Security')) {
|
|
174
|
+
response.headers.set('Strict-Transport-Security', config.strictTransportSecurity);
|
|
175
|
+
}
|
|
176
|
+
if (config?.permissionsPolicy && !response.headers.has('Permissions-Policy')) {
|
|
177
|
+
response.headers.set('Permissions-Policy', config.permissionsPolicy);
|
|
178
|
+
}
|
|
179
|
+
return response;
|
|
180
|
+
}
|
|
181
|
+
// ── Utility Functions ──────────────────────────────────────────────
|
|
182
|
+
function parseCookies(header) {
|
|
183
|
+
const map = {};
|
|
184
|
+
if (!header)
|
|
185
|
+
return map;
|
|
186
|
+
for (const pair of header.split(';')) {
|
|
187
|
+
const eq = pair.indexOf('=');
|
|
188
|
+
if (eq === -1)
|
|
189
|
+
continue;
|
|
190
|
+
map[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
|
|
191
|
+
}
|
|
192
|
+
return map;
|
|
193
|
+
}
|
|
194
|
+
function isSameOrigin(request, url) {
|
|
195
|
+
const fetchSite = request.headers.get('sec-fetch-site');
|
|
196
|
+
if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'same-site' && fetchSite !== 'none') {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
const origin = request.headers.get('origin');
|
|
200
|
+
if (!origin)
|
|
201
|
+
return true;
|
|
202
|
+
try {
|
|
203
|
+
return new URL(origin).origin === url.origin;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// ── Fragment Security ───────────────────────────────────────────────
|
|
210
|
+
/**
|
|
211
|
+
* Sign a fragment ID with the route path and CSRF token to prevent tampering.
|
|
212
|
+
* Format: fragmentId:signature
|
|
213
|
+
*/
|
|
214
|
+
export function signFragmentId(fragmentId, routePath) {
|
|
215
|
+
const token = __getLocals().__csrfToken || '';
|
|
216
|
+
const payload = `${fragmentId}:${routePath}:${token}`;
|
|
217
|
+
const signature = simpleHash(payload);
|
|
218
|
+
return `${fragmentId}:${signature}`;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Validate a signed fragment ID against the current route and session.
|
|
222
|
+
* If no CSRF token is present (e.g., in tests), allows unsigned fragments.
|
|
223
|
+
*/
|
|
224
|
+
export function validateSignedFragment(signedFragment, routePath) {
|
|
225
|
+
const token = __getLocals().__csrfToken || '';
|
|
226
|
+
// If no CSRF token is set, allow unsigned fragments (backward compat / tests)
|
|
227
|
+
if (!token) {
|
|
228
|
+
const colonIdx = signedFragment.lastIndexOf(':');
|
|
229
|
+
// If it looks signed, extract the fragment ID; otherwise use as-is
|
|
230
|
+
const fragmentId = colonIdx !== -1 ? signedFragment.slice(0, colonIdx) : signedFragment;
|
|
231
|
+
return { valid: true, fragmentId };
|
|
232
|
+
}
|
|
233
|
+
const colonIdx = signedFragment.lastIndexOf(':');
|
|
234
|
+
if (colonIdx === -1) {
|
|
235
|
+
// Unsigned fragment with CSRF enabled - reject for security
|
|
236
|
+
return { valid: false, fragmentId: null, reason: 'Fragment ID not signed' };
|
|
237
|
+
}
|
|
238
|
+
const fragmentId = signedFragment.slice(0, colonIdx);
|
|
239
|
+
const providedSignature = signedFragment.slice(colonIdx + 1);
|
|
240
|
+
const payload = `${fragmentId}:${routePath}:${token}`;
|
|
241
|
+
const expectedSignature = simpleHash(payload);
|
|
242
|
+
if (!timingSafeEqual(providedSignature, expectedSignature)) {
|
|
243
|
+
return { valid: false, fragmentId: null, reason: 'Fragment signature invalid' };
|
|
244
|
+
}
|
|
245
|
+
return { valid: true, fragmentId };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Simple hash function for signing (not cryptographic, but sufficient for HMAC-like signing
|
|
249
|
+
* when combined with a secret token). Uses FNV-1a for speed.
|
|
250
|
+
*/
|
|
251
|
+
function simpleHash(str) {
|
|
252
|
+
let hash = 2166136261;
|
|
253
|
+
for (let i = 0; i < str.length; i++) {
|
|
254
|
+
hash ^= str.charCodeAt(i);
|
|
255
|
+
hash = (hash * 16777619) >>> 0;
|
|
256
|
+
}
|
|
257
|
+
return hash.toString(36);
|
|
258
|
+
}
|
|
259
|
+
// ── Query Override Security ────────────────────────────────────────
|
|
260
|
+
/**
|
|
261
|
+
* Validate that a query function is allowed for the current route.
|
|
262
|
+
* The allowedQueries set should contain the function names registered for this route.
|
|
263
|
+
*/
|
|
264
|
+
export function validateQueryOverride(queryFn, allowedQueries) {
|
|
265
|
+
const allowed = allowedQueries instanceof Set ? allowedQueries : new Set(allowedQueries);
|
|
266
|
+
if (!queryFn) {
|
|
267
|
+
return { valid: false, reason: 'No query function specified' };
|
|
268
|
+
}
|
|
269
|
+
if (!allowed.has(queryFn)) {
|
|
270
|
+
return { valid: false, reason: `Query function '${queryFn}' not registered for this route` };
|
|
271
|
+
}
|
|
272
|
+
return { valid: true };
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Validate query arguments are safe JSON.
|
|
276
|
+
* Returns parsed arguments or null if invalid.
|
|
277
|
+
*/
|
|
278
|
+
export function parseQueryArgs(argsRaw) {
|
|
279
|
+
if (!argsRaw || argsRaw === '[]') {
|
|
280
|
+
return { valid: true, args: [] };
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const parsed = JSON.parse(argsRaw);
|
|
284
|
+
if (!Array.isArray(parsed)) {
|
|
285
|
+
return { valid: false, args: [], reason: 'Query args must be an array' };
|
|
286
|
+
}
|
|
287
|
+
return { valid: true, args: parsed };
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return { valid: false, args: [], reason: 'Invalid JSON in query args' };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// ── Exports for Framework Use ──────────────────────────────────────
|
|
294
|
+
export const CSRF_DEFAULTS = {
|
|
295
|
+
cookieName: CSRF_COOKIE_NAME,
|
|
296
|
+
headerName: CSRF_HEADER_NAME,
|
|
297
|
+
formField: CSRF_FORM_FIELD,
|
|
298
|
+
};
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -18,6 +18,12 @@ export interface RouteContext<E extends Env = Env> {
|
|
|
18
18
|
/** Parsed URL */
|
|
19
19
|
url: URL;
|
|
20
20
|
}
|
|
21
|
+
export interface PageRenderResult {
|
|
22
|
+
html: string;
|
|
23
|
+
head?: string;
|
|
24
|
+
fragments?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
export type PageRenderOutput = string | PageRenderResult;
|
|
21
27
|
/** A compiled route module */
|
|
22
28
|
export interface RouteModule {
|
|
23
29
|
/** Pattern string (e.g., '/todos', '/blog/:slug') */
|
|
@@ -28,8 +34,8 @@ export interface RouteModule {
|
|
|
28
34
|
actions?: Record<string, (formData: FormData, env: Env, ctx: RouteContext) => Promise<any>>;
|
|
29
35
|
/** RPC functions â€" callable from client via fetch */
|
|
30
36
|
rpc?: Record<string, (args: any[], env: Env, ctx: RouteContext) => Promise<any>>;
|
|
31
|
-
/** Render function â€" returns HTML
|
|
32
|
-
render: (data: Record<string, any>) =>
|
|
37
|
+
/** Render function â€" returns route HTML and optional head content from data */
|
|
38
|
+
render: (data: Record<string, any>) => PageRenderOutput;
|
|
33
39
|
/** Layout name (default: 'default') */
|
|
34
40
|
layout?: string;
|
|
35
41
|
}
|
|
@@ -224,6 +230,27 @@ export interface AuthConfig {
|
|
|
224
230
|
binding?: string;
|
|
225
231
|
[key: string]: any;
|
|
226
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;
|
|
227
254
|
}
|
|
228
255
|
/** Runtime pipeline context - shared across runtime step handlers */
|
|
229
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.
|
|
3
|
+
"version": "0.0.17",
|
|
4
4
|
"description": "A thin, Cloudflare Workers-native web framework with Svelte-inspired syntax",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
"types": "./dist/runtime/do.d.ts",
|
|
39
39
|
"import": "./dist/runtime/do.js"
|
|
40
40
|
},
|
|
41
|
+
"./runtime/generated-worker.js": {
|
|
42
|
+
"types": "./dist/runtime/generated-worker.d.ts",
|
|
43
|
+
"import": "./dist/runtime/generated-worker.js"
|
|
44
|
+
},
|
|
41
45
|
"./compiler": {
|
|
42
46
|
"types": "./dist/compiler/index.d.ts",
|
|
43
47
|
"import": "./dist/compiler/index.js"
|