tova 0.4.7 → 0.5.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/package.json +1 -1
- package/src/analyzer/analyzer.js +268 -0
- package/src/codegen/client-codegen.js +12 -1
- package/src/codegen/codegen.js +11 -2
- package/src/codegen/security-codegen.js +408 -0
- package/src/codegen/server-codegen.js +302 -34
- package/src/parser/ast.js +21 -0
- package/src/parser/parser.js +8 -0
- package/src/parser/security-ast.js +95 -0
- package/src/parser/security-parser.js +299 -0
- package/src/version.js +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BaseCodegen } from './base-codegen.js';
|
|
2
|
+
import { SecurityCodegen } from './security-codegen.js';
|
|
2
3
|
|
|
3
4
|
export class ServerCodegen extends BaseCodegen {
|
|
4
5
|
_astUsesIdentifier(blocks, name) {
|
|
@@ -153,9 +154,16 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
generate(serverBlocks, sharedCode, blockName = null, peerBlocks = null, sharedBlocks = []) {
|
|
157
|
+
generate(serverBlocks, sharedCode, blockName = null, peerBlocks = null, sharedBlocks = [], securityConfig = null) {
|
|
157
158
|
const lines = [];
|
|
158
159
|
|
|
160
|
+
// Generate security code fragments if security block is present
|
|
161
|
+
let securityFragments = null;
|
|
162
|
+
if (securityConfig) {
|
|
163
|
+
const secGen = new SecurityCodegen();
|
|
164
|
+
securityFragments = secGen.generateServerSecurity(securityConfig);
|
|
165
|
+
}
|
|
166
|
+
|
|
159
167
|
// Shared code
|
|
160
168
|
if (sharedCode.trim()) {
|
|
161
169
|
lines.push('// ── Shared ──');
|
|
@@ -282,6 +290,19 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
282
290
|
collectFromBody(block.body);
|
|
283
291
|
}
|
|
284
292
|
|
|
293
|
+
// Security block overrides: security block takes precedence over inline declarations
|
|
294
|
+
if (securityFragments) {
|
|
295
|
+
if (securityFragments.authConfig && !authConfig) {
|
|
296
|
+
authConfig = securityFragments.authConfig;
|
|
297
|
+
}
|
|
298
|
+
if (securityFragments.corsConfig && !corsConfig) {
|
|
299
|
+
corsConfig = securityFragments.corsConfig;
|
|
300
|
+
}
|
|
301
|
+
if (securityFragments.rateLimitConfig && !rateLimitConfig) {
|
|
302
|
+
rateLimitConfig = securityFragments.rateLimitConfig;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
285
306
|
// Collect type declarations from shared blocks for model/ORM generation
|
|
286
307
|
const sharedTypes = new Map(); // typeName -> { fields: [{ name, type }] }
|
|
287
308
|
const _collectTypes = (stmts) => {
|
|
@@ -333,13 +354,14 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
333
354
|
const usesDb = this._astUsesIdentifier(serverBlocks, 'db');
|
|
334
355
|
|
|
335
356
|
// Check if rate limiting is needed
|
|
336
|
-
const
|
|
357
|
+
const hasSecurityProtectRateLimit = securityConfig && securityConfig.protects && securityConfig.protects.some(p => p.config.rate_limit);
|
|
358
|
+
const needsRateLimitStore = !!rateLimitConfig || routes.some(r => (r.decorators || []).some(d => d.name === 'rate_limit')) || hasSecurityProtectRateLimit;
|
|
337
359
|
|
|
338
360
|
// Fast mode: emit a minimal request handler when no complex features are used
|
|
339
361
|
const allRoutesStatic = routes.every(r => !r.path.includes(':') && !r.path.includes('*'));
|
|
340
362
|
const hasDynamicRoutes = !allRoutesStatic;
|
|
341
363
|
const isFastMode = !corsConfig && !authConfig && !sessionConfig && !rateLimitConfig &&
|
|
342
|
-
!errorHandler && !wsDecl && !staticDecl && !compressionConfig &&
|
|
364
|
+
!errorHandler && !wsDecl && !staticDecl && !compressionConfig && !securityFragments &&
|
|
343
365
|
middlewares.length === 0 && !dbConfig && subscriptions.length === 0 &&
|
|
344
366
|
backgroundJobs.length === 0 && sseDecls.length === 0 && !cacheConfig &&
|
|
345
367
|
routes.every(r => !(r.decorators || []).length && !r._groupMiddlewares?.length && !r._version);
|
|
@@ -839,6 +861,13 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
839
861
|
lines.push(' const claims = { ...payload, iat: now };');
|
|
840
862
|
lines.push(' if (options.expires_in) claims.exp = now + options.expires_in;');
|
|
841
863
|
lines.push(' if (options.exp) claims.exp = options.exp;');
|
|
864
|
+
// Fix 2: Auto-include iss/aud from auth config when not explicitly set
|
|
865
|
+
if (authConfig && authConfig.issuer) {
|
|
866
|
+
lines.push(` if (!claims.iss) claims.iss = ${this.genExpression(authConfig.issuer)};`);
|
|
867
|
+
}
|
|
868
|
+
if (authConfig && authConfig.audience) {
|
|
869
|
+
lines.push(` if (!claims.aud) claims.aud = ${this.genExpression(authConfig.audience)};`);
|
|
870
|
+
}
|
|
842
871
|
lines.push(' const __b64url = (obj) => btoa(JSON.stringify(obj)).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");');
|
|
843
872
|
lines.push(' const __headerB64 = __b64url(header);');
|
|
844
873
|
lines.push(' const __payloadB64 = __b64url(claims);');
|
|
@@ -920,6 +949,7 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
920
949
|
lines.push(' "Access-Control-Max-Age": __corsMaxAge,');
|
|
921
950
|
lines.push(' };');
|
|
922
951
|
lines.push(' if (__corsCredentials) h["Access-Control-Allow-Credentials"] = "true";');
|
|
952
|
+
lines.push(' if (!__corsOrigins.includes("*")) h["Vary"] = "Origin";');
|
|
923
953
|
lines.push(' return h;');
|
|
924
954
|
lines.push('}');
|
|
925
955
|
} else if (authConfig || sessionConfig) {
|
|
@@ -935,7 +965,7 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
935
965
|
lines.push(' if (!origin) return __corsHeadersConst;');
|
|
936
966
|
lines.push(' const reqUrl = req.url ? new URL(req.url) : null;');
|
|
937
967
|
lines.push(' if (reqUrl && origin === reqUrl.origin) {');
|
|
938
|
-
lines.push(' return { ...__corsHeadersConst, "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true" };');
|
|
968
|
+
lines.push(' return { ...__corsHeadersConst, "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", "Vary": "Origin" };');
|
|
939
969
|
lines.push(' }');
|
|
940
970
|
lines.push(' return __corsHeadersConst;');
|
|
941
971
|
lines.push('}');
|
|
@@ -963,14 +993,32 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
963
993
|
lines.push(' "Referrer-Policy": "strict-origin-when-cross-origin",');
|
|
964
994
|
lines.push(' "Permissions-Policy": "camera=(), microphone=(), geolocation=()",');
|
|
965
995
|
lines.push('});');
|
|
966
|
-
|
|
967
|
-
|
|
996
|
+
// HSTS: explicit security block config, auto-enable with auth, or TLS config
|
|
997
|
+
const hstsConf = securityFragments && securityFragments.hstsConfig;
|
|
998
|
+
const hstsExplicitlyDisabled = hstsConf && hstsConf.enabled && hstsConf.enabled.type === 'BooleanLiteral' && hstsConf.enabled.value === false;
|
|
999
|
+
const needsHsts = !hstsExplicitlyDisabled && (tlsConfig || hstsConf);
|
|
1000
|
+
if (needsHsts) {
|
|
1001
|
+
if (hstsConf && !hstsConf.__autoEnabled && hstsConf.max_age) {
|
|
1002
|
+
// Custom HSTS from security block
|
|
1003
|
+
const maxAge = hstsConf.max_age.value || 31536000;
|
|
1004
|
+
const inclSub = hstsConf.include_subdomains ? (hstsConf.include_subdomains.value !== false) : true;
|
|
1005
|
+
const preload = hstsConf.preload ? hstsConf.preload.value === true : false;
|
|
1006
|
+
let hstsVal = `max-age=${maxAge}`;
|
|
1007
|
+
if (inclSub) hstsVal += '; includeSubDomains';
|
|
1008
|
+
if (preload) hstsVal += '; preload';
|
|
1009
|
+
lines.push(`const __hstsHeader = { "Strict-Transport-Security": ${JSON.stringify(hstsVal)} };`);
|
|
1010
|
+
} else {
|
|
1011
|
+
lines.push('const __hstsHeader = { "Strict-Transport-Security": "max-age=31536000; includeSubDomains" };');
|
|
1012
|
+
}
|
|
968
1013
|
}
|
|
969
1014
|
lines.push('function __applySecurityHeaders(headers) {');
|
|
970
1015
|
lines.push(' for (const [k, v] of Object.entries(__securityHeaders)) headers.set(k, v);');
|
|
971
|
-
if (
|
|
1016
|
+
if (needsHsts) {
|
|
972
1017
|
lines.push(' headers.set("Strict-Transport-Security", __hstsHeader["Strict-Transport-Security"]);');
|
|
973
1018
|
}
|
|
1019
|
+
if (securityFragments && securityFragments.cspCode) {
|
|
1020
|
+
lines.push(' if (typeof __getCspHeader === "function") headers.set("Content-Security-Policy", __getCspHeader());');
|
|
1021
|
+
}
|
|
974
1022
|
lines.push('}');
|
|
975
1023
|
lines.push('');
|
|
976
1024
|
}
|
|
@@ -995,18 +1043,48 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
995
1043
|
} else {
|
|
996
1044
|
// JWT auth (default)
|
|
997
1045
|
const secretExpr = authConfig.secret ? this.genExpression(authConfig.secret) : 'process.env.AUTH_SECRET || process.env.JWT_SECRET';
|
|
1046
|
+
const isCookieAuth = authConfig.storage && authConfig.storage.type === 'StringLiteral' && authConfig.storage.value === 'cookie';
|
|
998
1047
|
lines.push(`const __authSecret = ${secretExpr};`);
|
|
999
1048
|
if (!authConfig.secret) {
|
|
1000
1049
|
lines.push('if (!process.env.AUTH_SECRET && !process.env.JWT_SECRET) console.warn("[tova] WARNING: No auth secret configured. Set AUTH_SECRET or JWT_SECRET env var, or use auth { secret: \\"...\\" }");');
|
|
1001
1050
|
}
|
|
1051
|
+
if (isCookieAuth) {
|
|
1052
|
+
const expiresExpr = authConfig.expires ? this.genExpression(authConfig.expires) : '86400';
|
|
1053
|
+
lines.push(`const __authCookieMaxAge = ${expiresExpr};`);
|
|
1054
|
+
lines.push('function __setAuthCookie(res, token) {');
|
|
1055
|
+
lines.push(' const h = new Headers(res.headers);');
|
|
1056
|
+
lines.push(' h.set("Set-Cookie", `__tova_auth=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${__authCookieMaxAge}`);');
|
|
1057
|
+
lines.push(' return new Response(res.body, { status: res.status, headers: h });');
|
|
1058
|
+
lines.push('}');
|
|
1059
|
+
lines.push('function __clearAuthCookie(res) {');
|
|
1060
|
+
lines.push(' const h = new Headers(res.headers);');
|
|
1061
|
+
lines.push(' h.set("Set-Cookie", "__tova_auth=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0");');
|
|
1062
|
+
lines.push(' return new Response(res.body, { status: res.status, headers: h });');
|
|
1063
|
+
lines.push('}');
|
|
1064
|
+
}
|
|
1002
1065
|
lines.push('let __authKey = null;');
|
|
1003
1066
|
lines.push('async function __authenticate(req) {');
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1067
|
+
if (isCookieAuth) {
|
|
1068
|
+
// Read token from cookie first, fall back to Authorization header
|
|
1069
|
+
lines.push(' let token = null;');
|
|
1070
|
+
lines.push(' const __cookieStr = req.headers.get("Cookie") || "";');
|
|
1071
|
+
lines.push(' const __authCookie = __cookieStr.split(";").map(s => s.trim()).find(s => s.startsWith("__tova_auth="));');
|
|
1072
|
+
lines.push(' if (__authCookie) token = __authCookie.slice("__tova_auth=".length);');
|
|
1073
|
+
lines.push(' if (!token) {');
|
|
1074
|
+
lines.push(' const authHeader = req.headers.get("Authorization");');
|
|
1075
|
+
lines.push(' if (authHeader && authHeader.startsWith("Bearer ")) token = authHeader.slice(7);');
|
|
1076
|
+
lines.push(' }');
|
|
1077
|
+
lines.push(' if (!token) return null;');
|
|
1078
|
+
} else {
|
|
1079
|
+
lines.push(' const authHeader = req.headers.get("Authorization");');
|
|
1080
|
+
lines.push(' if (!authHeader || !authHeader.startsWith("Bearer ")) return null;');
|
|
1081
|
+
lines.push(' const token = authHeader.slice(7);');
|
|
1082
|
+
}
|
|
1007
1083
|
lines.push(' try {');
|
|
1008
1084
|
lines.push(' const parts = token.split(".");');
|
|
1009
1085
|
lines.push(' if (parts.length !== 3) return null;');
|
|
1086
|
+
lines.push(' const __header = JSON.parse(atob(parts[0].replace(/-/g, "+").replace(/_/g, "/")));');
|
|
1087
|
+
lines.push(' if (__header.alg !== "HS256") return null;');
|
|
1010
1088
|
lines.push(' if (!__authKey) {');
|
|
1011
1089
|
lines.push(' __authKey = await crypto.subtle.importKey(');
|
|
1012
1090
|
lines.push(' "raw", new TextEncoder().encode(__authSecret),');
|
|
@@ -1022,6 +1100,14 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
1022
1100
|
lines.push(' if (__sigBuf.length !== __tokBuf.length || !require("crypto").timingSafeEqual(__sigBuf, __tokBuf)) return null;');
|
|
1023
1101
|
lines.push(' const __payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));');
|
|
1024
1102
|
lines.push(' if (__payload.exp && __payload.exp < Math.floor(Date.now() / 1000)) return null;');
|
|
1103
|
+
lines.push(' if (__payload.nbf && __payload.nbf > Math.floor(Date.now() / 1000)) return null;');
|
|
1104
|
+
// Fix 2: JWT iss/aud claim validation
|
|
1105
|
+
if (authConfig.issuer) {
|
|
1106
|
+
lines.push(` if (__payload.iss !== ${this.genExpression(authConfig.issuer)}) return null;`);
|
|
1107
|
+
}
|
|
1108
|
+
if (authConfig.audience) {
|
|
1109
|
+
lines.push(` if (__payload.aud !== ${this.genExpression(authConfig.audience)}) return null;`);
|
|
1110
|
+
}
|
|
1025
1111
|
lines.push(' return __payload;');
|
|
1026
1112
|
lines.push(' } catch { return null; }');
|
|
1027
1113
|
lines.push('}');
|
|
@@ -1029,6 +1115,62 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
1029
1115
|
lines.push('');
|
|
1030
1116
|
}
|
|
1031
1117
|
|
|
1118
|
+
// ════════════════════════════════════════════════════════════
|
|
1119
|
+
// 9b. Security Block — roles, protection, CSP, sensitive, audit
|
|
1120
|
+
// ════════════════════════════════════════════════════════════
|
|
1121
|
+
if (securityFragments) {
|
|
1122
|
+
if (securityFragments.roleDefinitions) {
|
|
1123
|
+
lines.push(securityFragments.roleDefinitions);
|
|
1124
|
+
lines.push('');
|
|
1125
|
+
}
|
|
1126
|
+
if (securityFragments.protectCode) {
|
|
1127
|
+
lines.push(securityFragments.protectCode);
|
|
1128
|
+
lines.push('');
|
|
1129
|
+
}
|
|
1130
|
+
if (securityFragments.cspCode) {
|
|
1131
|
+
lines.push(securityFragments.cspCode);
|
|
1132
|
+
lines.push('');
|
|
1133
|
+
}
|
|
1134
|
+
if (securityFragments.sensitiveCode) {
|
|
1135
|
+
lines.push(securityFragments.sensitiveCode);
|
|
1136
|
+
lines.push('');
|
|
1137
|
+
}
|
|
1138
|
+
if (securityFragments.auditCode) {
|
|
1139
|
+
lines.push(securityFragments.auditCode);
|
|
1140
|
+
lines.push('');
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// ════════════════════════════════════════════════════════════
|
|
1145
|
+
// 9c. Client IP Helper — trust_proxy aware
|
|
1146
|
+
// ════════════════════════════════════════════════════════════
|
|
1147
|
+
if (!isFastMode) {
|
|
1148
|
+
lines.push('// ── Client IP ──');
|
|
1149
|
+
const trustProxy = securityFragments && securityFragments.trustProxyConfig;
|
|
1150
|
+
if (trustProxy === true) {
|
|
1151
|
+
lines.push('function __getClientIp(req) {');
|
|
1152
|
+
lines.push(' const xff = req.headers.get("x-forwarded-for");');
|
|
1153
|
+
lines.push(' if (xff) return xff.split(",")[0].trim();');
|
|
1154
|
+
lines.push(' return req.headers.get("x-real-ip") || __server.requestIP(req)?.address || "unknown";');
|
|
1155
|
+
lines.push('}');
|
|
1156
|
+
} else if (trustProxy === 'loopback') {
|
|
1157
|
+
lines.push('function __getClientIp(req) {');
|
|
1158
|
+
lines.push(' const direct = req.headers.get("x-real-ip") || __server.requestIP(req)?.address || "unknown";');
|
|
1159
|
+
lines.push(' if (direct === "127.0.0.1" || direct === "::1" || direct === "::ffff:127.0.0.1") {');
|
|
1160
|
+
lines.push(' const xff = req.headers.get("x-forwarded-for");');
|
|
1161
|
+
lines.push(' if (xff) return xff.split(",")[0].trim();');
|
|
1162
|
+
lines.push(' }');
|
|
1163
|
+
lines.push(' return direct;');
|
|
1164
|
+
lines.push('}');
|
|
1165
|
+
} else {
|
|
1166
|
+
// Default: do not trust x-forwarded-for
|
|
1167
|
+
lines.push('function __getClientIp(req) {');
|
|
1168
|
+
lines.push(' return req.headers.get("x-real-ip") || __server.requestIP(req)?.address || "unknown";');
|
|
1169
|
+
lines.push('}');
|
|
1170
|
+
}
|
|
1171
|
+
lines.push('');
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1032
1174
|
// ════════════════════════════════════════════════════════════
|
|
1033
1175
|
// 10. Max Body Size
|
|
1034
1176
|
// ════════════════════════════════════════════════════════════
|
|
@@ -1232,13 +1374,29 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
1232
1374
|
lines.push(' }');
|
|
1233
1375
|
lines.push('}, 60000);');
|
|
1234
1376
|
}
|
|
1377
|
+
// Fix 8: Session regeneration helper
|
|
1378
|
+
lines.push('function __regenerateSession(req) {');
|
|
1379
|
+
lines.push(' const oldData = req.__session ? { ...req.__session.data } : {};');
|
|
1380
|
+
lines.push(' const newId = crypto.randomUUID();');
|
|
1381
|
+
lines.push(' if (req.__session && req.__session.destroy) req.__session.destroy();');
|
|
1382
|
+
lines.push(' req.__session = __createSession(newId);');
|
|
1383
|
+
lines.push(' for (const [k, v] of Object.entries(oldData)) req.__session.set(k, v);');
|
|
1384
|
+
lines.push(' req.__sessionRegenerated = true;');
|
|
1385
|
+
lines.push(' req.__newSessionId = newId;');
|
|
1386
|
+
lines.push(' return newId;');
|
|
1387
|
+
lines.push('}');
|
|
1235
1388
|
lines.push('');
|
|
1236
1389
|
}
|
|
1237
1390
|
|
|
1238
1391
|
// ════════════════════════════════════════════════════════════
|
|
1239
1392
|
// 11d. CSRF Protection — server-side token generation + validation
|
|
1240
1393
|
// ════════════════════════════════════════════════════════════
|
|
1241
|
-
|
|
1394
|
+
// Check if CSRF is explicitly disabled via security block: csrf { enabled: false }
|
|
1395
|
+
const csrfExplicitlyDisabled = securityFragments && securityFragments.csrfConfig &&
|
|
1396
|
+
securityFragments.csrfConfig.enabled &&
|
|
1397
|
+
((securityFragments.csrfConfig.enabled.type === 'BooleanLiteral' && securityFragments.csrfConfig.enabled.value === false) ||
|
|
1398
|
+
(securityFragments.csrfConfig.enabled === false));
|
|
1399
|
+
const needsCsrf = !isFastMode && !csrfExplicitlyDisabled && (sessionConfig || authConfig);
|
|
1242
1400
|
if (needsCsrf) {
|
|
1243
1401
|
lines.push('// ── CSRF Protection ──');
|
|
1244
1402
|
lines.push('let __csrfKey = null;');
|
|
@@ -1252,32 +1410,57 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
1252
1410
|
lines.push(' }');
|
|
1253
1411
|
lines.push(' return __csrfKey;');
|
|
1254
1412
|
lines.push('}');
|
|
1255
|
-
lines.push('async function __generateCSRFToken() {');
|
|
1413
|
+
lines.push('async function __generateCSRFToken(bindingId) {');
|
|
1256
1414
|
lines.push(' const key = await __getCSRFKey();');
|
|
1257
1415
|
lines.push(' const timestamp = Date.now().toString(36);');
|
|
1258
1416
|
lines.push(' const nonce = crypto.getRandomValues(new Uint8Array(8));');
|
|
1259
1417
|
lines.push(' const nonceHex = [...nonce].map(b => b.toString(16).padStart(2, "0")).join("");');
|
|
1260
|
-
lines.push(' const data = timestamp + ":" + nonceHex;');
|
|
1418
|
+
lines.push(' const data = timestamp + ":" + nonceHex + ":" + (bindingId || "anon");');
|
|
1261
1419
|
lines.push(' const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));');
|
|
1262
1420
|
lines.push(' const sigHex = [...new Uint8Array(sig)].map(b => b.toString(16).padStart(2, "0")).join("");');
|
|
1263
1421
|
lines.push(' return data + ":" + sigHex;');
|
|
1264
1422
|
lines.push('}');
|
|
1265
|
-
lines.push('async function __validateCSRFToken(token) {');
|
|
1423
|
+
lines.push('async function __validateCSRFToken(token, bindingId) {');
|
|
1266
1424
|
lines.push(' if (!token || typeof token !== "string") return false;');
|
|
1267
1425
|
lines.push(' const parts = token.split(":");');
|
|
1268
|
-
lines.push(' if (parts.length !==
|
|
1269
|
-
lines.push(' const [timestamp, nonce, sig] = parts;');
|
|
1426
|
+
lines.push(' if (parts.length !== 4) return false;');
|
|
1427
|
+
lines.push(' const [timestamp, nonce, binding, sig] = parts;');
|
|
1428
|
+
lines.push(' if (binding !== (bindingId || "anon")) return false;');
|
|
1270
1429
|
lines.push(' const age = Date.now() - parseInt(timestamp, 36);');
|
|
1271
1430
|
lines.push(' if (isNaN(age) || age < 0 || age > 86400000) return false;');
|
|
1272
1431
|
lines.push(' const key = await __getCSRFKey();');
|
|
1273
|
-
lines.push(' const data = timestamp + ":" + nonce;');
|
|
1432
|
+
lines.push(' const data = timestamp + ":" + nonce + ":" + binding;');
|
|
1274
1433
|
lines.push(' const expectedSig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));');
|
|
1275
|
-
lines.push(' const
|
|
1276
|
-
lines.push('
|
|
1277
|
-
lines.push('
|
|
1278
|
-
lines.push('
|
|
1279
|
-
lines.push(' return require("crypto").timingSafeEqual(__a, __b);');
|
|
1434
|
+
lines.push(' const expectedBytes = new Uint8Array(expectedSig);');
|
|
1435
|
+
lines.push(' let sigBytes; try { sigBytes = new Uint8Array(Buffer.from(sig, "hex")); } catch { return false; }');
|
|
1436
|
+
lines.push(' if (sigBytes.length !== expectedBytes.length) return false;');
|
|
1437
|
+
lines.push(' return require("crypto").timingSafeEqual(sigBytes, expectedBytes);');
|
|
1280
1438
|
lines.push('}');
|
|
1439
|
+
// Fix 9: CSRF exempt patterns
|
|
1440
|
+
const csrfExempt = securityFragments && securityFragments.csrfConfig && securityFragments.csrfConfig.exempt;
|
|
1441
|
+
if (csrfExempt) {
|
|
1442
|
+
lines.push('const __csrfExemptPatterns = [');
|
|
1443
|
+
// csrfExempt is an AST node (ArrayLiteral/ArrayExpression)
|
|
1444
|
+
if (csrfExempt.elements) {
|
|
1445
|
+
for (const elem of csrfExempt.elements) {
|
|
1446
|
+
if (elem.type === 'StringLiteral' || elem.value) {
|
|
1447
|
+
const pattern = elem.value;
|
|
1448
|
+
const regexPattern = pattern
|
|
1449
|
+
.replace(/\*\*/g, '\x00GLOBSTAR\x00')
|
|
1450
|
+
.replace(/\*/g, '\x00STAR\x00')
|
|
1451
|
+
.replace(/[.+?^${}()|[\]\\/]/g, '\\$&')
|
|
1452
|
+
.replace(/\x00STAR\x00/g, '[^/]*')
|
|
1453
|
+
.replace(/\x00GLOBSTAR\x00/g, '.*');
|
|
1454
|
+
lines.push(` /^${regexPattern}$/,`);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
lines.push('];');
|
|
1459
|
+
lines.push('function __isCsrfExempt(path) {');
|
|
1460
|
+
lines.push(' for (const p of __csrfExemptPatterns) { if (p.test(path)) return true; }');
|
|
1461
|
+
lines.push(' return false;');
|
|
1462
|
+
lines.push('}');
|
|
1463
|
+
}
|
|
1281
1464
|
lines.push('');
|
|
1282
1465
|
}
|
|
1283
1466
|
|
|
@@ -1957,8 +2140,14 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
1957
2140
|
// CSRF Token Endpoint
|
|
1958
2141
|
if (needsCsrf) {
|
|
1959
2142
|
lines.push('// ── CSRF Token Endpoint ──');
|
|
1960
|
-
|
|
1961
|
-
|
|
2143
|
+
if (sessionConfig) {
|
|
2144
|
+
lines.push('__addRoute("GET", "/csrf-token", async (req) => {');
|
|
2145
|
+
lines.push(' const __bindingId = req.__session ? req.__session.get("__sid") || "anon" : "anon";');
|
|
2146
|
+
lines.push(' const token = await __generateCSRFToken(__bindingId);');
|
|
2147
|
+
} else {
|
|
2148
|
+
lines.push('__addRoute("GET", "/csrf-token", async () => {');
|
|
2149
|
+
lines.push(' const token = await __generateCSRFToken();');
|
|
2150
|
+
}
|
|
1962
2151
|
lines.push(' return Response.json({ token });');
|
|
1963
2152
|
lines.push('});');
|
|
1964
2153
|
lines.push('');
|
|
@@ -1988,12 +2177,29 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
1988
2177
|
} else {
|
|
1989
2178
|
lines.push(` const result = await ${name}();`);
|
|
1990
2179
|
}
|
|
1991
|
-
|
|
2180
|
+
if (securityFragments && securityFragments.hasAutoSanitize) {
|
|
2181
|
+
const userRef = authConfig ? 'await __authenticate(req)' : 'null';
|
|
2182
|
+
lines.push(` return Response.json({ result: __autoSanitize(result, ${userRef}) });`);
|
|
2183
|
+
} else {
|
|
2184
|
+
lines.push(` return Response.json({ result });`);
|
|
2185
|
+
}
|
|
1992
2186
|
lines.push(`});`);
|
|
1993
2187
|
lines.push('');
|
|
1994
2188
|
}
|
|
1995
2189
|
}
|
|
1996
2190
|
|
|
2191
|
+
// Cookie auth logout endpoint
|
|
2192
|
+
if (authConfig) {
|
|
2193
|
+
const isCookieAuth = authConfig.storage && authConfig.storage.type === 'StringLiteral' && authConfig.storage.value === 'cookie';
|
|
2194
|
+
if (isCookieAuth) {
|
|
2195
|
+
lines.push('// ── Cookie Auth Logout ──');
|
|
2196
|
+
lines.push('__addRoute("POST", "/rpc/__logout", async (req) => {');
|
|
2197
|
+
lines.push(' return __clearAuthCookie(Response.json({ ok: true }));');
|
|
2198
|
+
lines.push('});');
|
|
2199
|
+
lines.push('');
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
1997
2203
|
// ════════════════════════════════════════════════════════════
|
|
1998
2204
|
// 16. Event RPC endpoint (F5) — if multi-server + subscriptions
|
|
1999
2205
|
// ════════════════════════════════════════════════════════════
|
|
@@ -2079,7 +2285,7 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
2079
2285
|
if (rateLimitDec && needsRateLimitStore) {
|
|
2080
2286
|
const rlMax = rateLimitDec.args[0] ? this.genExpression(rateLimitDec.args[0]) : '100';
|
|
2081
2287
|
const rlWindow = rateLimitDec.args[1] ? this.genExpression(rateLimitDec.args[1]) : '60';
|
|
2082
|
-
lines.push(` const __rlIp = req
|
|
2288
|
+
lines.push(` const __rlIp = __getClientIp(req);`);
|
|
2083
2289
|
lines.push(` const __rlRoute = __checkRateLimit(\`route:${path}:\${__rlIp}\`, ${rlMax}, ${rlWindow});`);
|
|
2084
2290
|
lines.push(` if (__rlRoute.limited) return __errorResponse(429, "RATE_LIMITED", "Too Many Requests", null, { "Retry-After": String(__rlRoute.retryAfter) });`);
|
|
2085
2291
|
}
|
|
@@ -2322,10 +2528,20 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
2322
2528
|
lines.push(` return new Response(res.body, { status: res.status, headers: h });`);
|
|
2323
2529
|
lines.push(` };`);
|
|
2324
2530
|
lines.push(` if (__result instanceof Response) return __addVersionHeaders(__result);`);
|
|
2325
|
-
|
|
2531
|
+
if (securityFragments && securityFragments.hasAutoSanitize) {
|
|
2532
|
+
const userRef = (hasAuth && authConfig) ? '__user' : (authConfig ? 'await __authenticate(req)' : 'null');
|
|
2533
|
+
lines.push(` return __addVersionHeaders(Response.json(__autoSanitize(__result, ${userRef})));`);
|
|
2534
|
+
} else {
|
|
2535
|
+
lines.push(` return __addVersionHeaders(Response.json(__result));`);
|
|
2536
|
+
}
|
|
2326
2537
|
} else {
|
|
2327
2538
|
lines.push(` if (__result instanceof Response) return __result;`);
|
|
2328
|
-
|
|
2539
|
+
if (securityFragments && securityFragments.hasAutoSanitize) {
|
|
2540
|
+
const userRef = (hasAuth && authConfig) ? '__user' : (authConfig ? 'await __authenticate(req)' : 'null');
|
|
2541
|
+
lines.push(` return Response.json(__autoSanitize(__result, ${userRef}));`);
|
|
2542
|
+
} else {
|
|
2543
|
+
lines.push(` return Response.json(__result);`);
|
|
2544
|
+
}
|
|
2329
2545
|
}
|
|
2330
2546
|
lines.push(`}${versionArg});`);
|
|
2331
2547
|
lines.push('');
|
|
@@ -2796,7 +3012,10 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
2796
3012
|
lines.push(' const __rawUrl = req.url;');
|
|
2797
3013
|
lines.push(' const __pStart = __rawUrl.indexOf("/", 12);');
|
|
2798
3014
|
lines.push(' const __qIdx = __rawUrl.indexOf("?", __pStart);');
|
|
2799
|
-
lines.push('
|
|
3015
|
+
lines.push(' let __pathname = __qIdx === -1 ? __rawUrl.slice(__pStart) : __rawUrl.slice(__pStart, __qIdx);');
|
|
3016
|
+
lines.push(' try { __pathname = decodeURIComponent(__pathname); } catch {}');
|
|
3017
|
+
lines.push(' __pathname = __pathname.replace(/\\/\\/+/g, "/");');
|
|
3018
|
+
lines.push(' if (__pathname.length > 1 && __pathname.endsWith("/")) __pathname = __pathname.slice(0, -1);');
|
|
2800
3019
|
lines.push(' const __method = req.method;');
|
|
2801
3020
|
|
|
2802
3021
|
// OPTIONS fast path
|
|
@@ -2853,7 +3072,20 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
2853
3072
|
lines.push(' const __rawUrl = req.url;');
|
|
2854
3073
|
lines.push(' const __pStart = __rawUrl.indexOf("/", 12);'); // skip "http://x:p" or "https://x:p"
|
|
2855
3074
|
lines.push(' const __qIdx = __rawUrl.indexOf("?", __pStart);');
|
|
2856
|
-
lines.push('
|
|
3075
|
+
lines.push(' let __pathname = __qIdx === -1 ? __rawUrl.slice(__pStart) : __rawUrl.slice(__pStart, __qIdx);');
|
|
3076
|
+
lines.push(' try { __pathname = decodeURIComponent(__pathname); } catch {}');
|
|
3077
|
+
lines.push(' __pathname = __pathname.replace(/\\/\\/+/g, "/");');
|
|
3078
|
+
// Resolve ../ sequences to prevent path traversal
|
|
3079
|
+
lines.push(' if (__pathname.includes("..")) {');
|
|
3080
|
+
lines.push(' const __parts = __pathname.split("/");');
|
|
3081
|
+
lines.push(' const __resolved = [];');
|
|
3082
|
+
lines.push(' for (const __seg of __parts) {');
|
|
3083
|
+
lines.push(' if (__seg === "..") { __resolved.pop(); }');
|
|
3084
|
+
lines.push(' else if (__seg !== ".") { __resolved.push(__seg); }');
|
|
3085
|
+
lines.push(' }');
|
|
3086
|
+
lines.push(' __pathname = __resolved.join("/") || "/";');
|
|
3087
|
+
lines.push(' }');
|
|
3088
|
+
lines.push(' if (__pathname.length > 1 && __pathname.endsWith("/")) __pathname = __pathname.slice(0, -1);');
|
|
2857
3089
|
lines.push(' const __rid = __sanitizeRequestId(req.headers.get("X-Request-Id"));');
|
|
2858
3090
|
lines.push(' const __startTime = Date.now();');
|
|
2859
3091
|
lines.push(' const __cors = __getCorsHeaders(req);');
|
|
@@ -2901,7 +3133,7 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
2901
3133
|
|
|
2902
3134
|
// Global rate limit check (F2)
|
|
2903
3135
|
if (rateLimitConfig) {
|
|
2904
|
-
lines.push(' const __clientIp = req
|
|
3136
|
+
lines.push(' const __clientIp = __getClientIp(req);');
|
|
2905
3137
|
lines.push(' const __rl = __checkRateLimit(__clientIp, __rateLimitMax, __rateLimitWindow);');
|
|
2906
3138
|
lines.push(' if (__rl.limited) {');
|
|
2907
3139
|
lines.push(' return __errorResponse(429, "RATE_LIMITED", "Too Many Requests", null, { ...__cors, "Retry-After": String(__rl.retryAfter) });');
|
|
@@ -2926,13 +3158,48 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
2926
3158
|
|
|
2927
3159
|
// CSRF validation on state-mutating requests
|
|
2928
3160
|
if (needsCsrf) {
|
|
3161
|
+
const hasCsrfExempt = securityFragments && securityFragments.csrfConfig && securityFragments.csrfConfig.exempt;
|
|
2929
3162
|
lines.push(' if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS") {');
|
|
3163
|
+
if (hasCsrfExempt) {
|
|
3164
|
+
lines.push(' if (!__isCsrfExempt(__pathname)) {');
|
|
3165
|
+
}
|
|
2930
3166
|
lines.push(' const __csrfToken = req.headers.get("X-Tova-CSRF") || req.headers.get("x-tova-csrf");');
|
|
2931
|
-
|
|
3167
|
+
if (sessionConfig) {
|
|
3168
|
+
lines.push(' const __csrfBindingId = req.__session ? req.__session.get("__sid") || "anon" : "anon";');
|
|
3169
|
+
lines.push(' const __csrfValid = await __validateCSRFToken(__csrfToken, __csrfBindingId);');
|
|
3170
|
+
} else {
|
|
3171
|
+
lines.push(' const __csrfValid = await __validateCSRFToken(__csrfToken);');
|
|
3172
|
+
}
|
|
2932
3173
|
lines.push(' if (!__csrfValid) {');
|
|
2933
3174
|
lines.push(' __log("warn", "CSRF validation failed", { rid: __rid, method: req.method, path: __pathname });');
|
|
2934
3175
|
lines.push(' return __errorResponse(403, "CSRF_INVALID", "CSRF token invalid or missing", null, __cors);');
|
|
2935
3176
|
lines.push(' }');
|
|
3177
|
+
if (hasCsrfExempt) {
|
|
3178
|
+
lines.push(' }');
|
|
3179
|
+
}
|
|
3180
|
+
lines.push(' }');
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
// Security block: route protection check
|
|
3184
|
+
if (securityFragments && securityFragments.protectCode) {
|
|
3185
|
+
lines.push(' // Security block: route protection');
|
|
3186
|
+
if (authConfig) {
|
|
3187
|
+
lines.push(' const __secUser = await __authenticate(req);');
|
|
3188
|
+
} else {
|
|
3189
|
+
lines.push(' const __secUser = null;');
|
|
3190
|
+
}
|
|
3191
|
+
lines.push(' const __protection = __checkProtection(__pathname, __secUser);');
|
|
3192
|
+
lines.push(' if (!__protection.allowed) {');
|
|
3193
|
+
lines.push(' const __statusCode = __secUser ? 403 : 401;');
|
|
3194
|
+
lines.push(' const __errorCode = __secUser ? "FORBIDDEN" : "AUTH_REQUIRED";');
|
|
3195
|
+
lines.push(' return __errorResponse(__statusCode, __errorCode, __protection.reason, null, __cors);');
|
|
3196
|
+
lines.push(' }');
|
|
3197
|
+
lines.push(' if (__protection.rateLimit && __protection.rateLimit.max) {');
|
|
3198
|
+
lines.push(' const __protectIp = __getClientIp(req);');
|
|
3199
|
+
lines.push(' const __protectRl = __checkRateLimit("protect:" + __pathname + ":" + __protectIp, __protection.rateLimit.max, __protection.rateLimit.window || 60);');
|
|
3200
|
+
lines.push(' if (__protectRl.limited) {');
|
|
3201
|
+
lines.push(' return __errorResponse(429, "RATE_LIMITED", "Too Many Requests", null, { ...__cors, "Retry-After": String(__protectRl.retryAfter) });');
|
|
3202
|
+
lines.push(' }');
|
|
2936
3203
|
lines.push(' }');
|
|
2937
3204
|
}
|
|
2938
3205
|
|
|
@@ -3086,8 +3353,9 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
3086
3353
|
lines.push(' } catch (__e) { throw __e; }');
|
|
3087
3354
|
lines.push(' }).then(async (__res) => {');
|
|
3088
3355
|
lines.push(' if (req.__session && req.__session.__flush) await req.__session.__flush();');
|
|
3089
|
-
lines.push(' if (__res && __sessionIsNew) {');
|
|
3090
|
-
lines.push(' const
|
|
3356
|
+
lines.push(' if (__res && (__sessionIsNew || req.__sessionRegenerated)) {');
|
|
3357
|
+
lines.push(' const __sid = req.__newSessionId || __sessionId;');
|
|
3358
|
+
lines.push(' const __signed = await __signSessionId(__sid);');
|
|
3091
3359
|
lines.push(' const __h = new Headers(__res.headers);');
|
|
3092
3360
|
lines.push(' __h.set("Set-Cookie", `${__sessionCookieName}=${__signed}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${__sessionMaxAge}`);');
|
|
3093
3361
|
lines.push(' return new Response(__res.body, { status: __res.status, headers: __h });');
|
package/src/parser/ast.js
CHANGED
|
@@ -45,6 +45,14 @@ export class SharedBlock {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export class SecurityBlock {
|
|
49
|
+
constructor(body, loc) {
|
|
50
|
+
this.type = 'SecurityBlock';
|
|
51
|
+
this.body = body;
|
|
52
|
+
this.loc = loc;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
// ============================================================
|
|
49
57
|
// Declarations
|
|
50
58
|
// ============================================================
|
|
@@ -683,6 +691,19 @@ export {
|
|
|
683
691
|
ModelDeclaration, AiConfigDeclaration,
|
|
684
692
|
} from './server-ast.js';
|
|
685
693
|
|
|
694
|
+
// ============================================================
|
|
695
|
+
// Security-specific nodes (lazy-loaded from security-ast.js, re-exported for backward compat)
|
|
696
|
+
// ============================================================
|
|
697
|
+
|
|
698
|
+
export {
|
|
699
|
+
SecurityAuthDeclaration, SecurityRoleDeclaration,
|
|
700
|
+
SecurityProtectDeclaration, SecuritySensitiveDeclaration,
|
|
701
|
+
SecurityCorsDeclaration, SecurityCspDeclaration,
|
|
702
|
+
SecurityRateLimitDeclaration, SecurityCsrfDeclaration,
|
|
703
|
+
SecurityAuditDeclaration,
|
|
704
|
+
SecurityTrustProxyDeclaration, SecurityHstsDeclaration,
|
|
705
|
+
} from './security-ast.js';
|
|
706
|
+
|
|
686
707
|
export class TestBlock {
|
|
687
708
|
constructor(name, body, loc, options = {}) {
|
|
688
709
|
this.type = 'TestBlock';
|
package/src/parser/parser.js
CHANGED
|
@@ -2,6 +2,7 @@ import { TokenType } from '../lexer/tokens.js';
|
|
|
2
2
|
import * as AST from './ast.js';
|
|
3
3
|
import { installServerParser } from './server-parser.js';
|
|
4
4
|
import { installClientParser } from './client-parser.js';
|
|
5
|
+
import { installSecurityParser } from './security-parser.js';
|
|
5
6
|
|
|
6
7
|
export class Parser {
|
|
7
8
|
static MAX_EXPRESSION_DEPTH = 200;
|
|
@@ -291,6 +292,13 @@ export class Parser {
|
|
|
291
292
|
if (this.check(TokenType.IDENTIFIER) && this.current().value === 'data' && this.peek(1).type === TokenType.LBRACE) {
|
|
292
293
|
return this.parseDataBlock();
|
|
293
294
|
}
|
|
295
|
+
// security block: security { ... }
|
|
296
|
+
if (this.check(TokenType.IDENTIFIER) && this.current().value === 'security' && this.peek(1).type === TokenType.LBRACE) {
|
|
297
|
+
if (!Parser.prototype._securityParserInstalled) {
|
|
298
|
+
installSecurityParser(Parser);
|
|
299
|
+
}
|
|
300
|
+
return this.parseSecurityBlock();
|
|
301
|
+
}
|
|
294
302
|
// test block: test "name" { ... } or test { ... }
|
|
295
303
|
if (this.check(TokenType.IDENTIFIER) && this.current().value === 'test') {
|
|
296
304
|
const next = this.peek(1);
|