What would change to make Menut work without unsafe-eval
new Function() for simplicity. This page documents what a CSP-strict build would require, for whoever needs it in the future.
unsafe-eval?Menut compiles HTML expressions into JavaScript functions at runtime using the new Function() constructor. This requires script-src 'unsafe-eval' in Content Security Policy.
The decision was intentional: it keeps the framework tiny (~13 kb), the API simple (write expressions directly in HTML), and avoids a build step. Alternatives exist but add complexity.
new Function is used| # | Function | Compiles | Line | Difficulty |
|---|---|---|---|---|
| 1 | compile() |
Reactive expressions: :if, :text, :class, :attr, interpolations |
314 | Medium |
| 2 | compileEvent() |
Event handlers: :on.click="count++" |
343 | Easy |
| 3 | compileAssign() |
Two-way binding: :model="name" |
360 | Easy |
| 4 | boot() |
SFC <script> blocks |
1317 | Hard |
compile) MediumReplace new Function("context", `with (context) { return (${expr}); }`) with a hand-written parser that walks the expression AST and resolves identifiers against the context.
What it needs to support:
count, item.name, item._index"string", 42, true, null, [], {}+, -, *, /, %, >, <, ===, !==, &&, ||, !, ??, ?:incrementar(), toggle(item), items.filter(i => i.done).lengthWhat it can drop:
with statement (the parser resolves names explicitly)for, import, class inside expressions)Estimated size: ~150-200 lines (vs current 5 lines for compile).
Current code:
function compile(expression) {
return cached(expression, () =>
new Function("context", `with (context) { return (${expression}); }`));
}
CSP version:
function compile(expression) {
return cached(expression, () => parseExpression(expression));
}
// parseExpression returns a function(ctx) that walks the AST
// and resolves identifiers via ctx[property] lookups.
// Example: parseExpression("count > 0") returns:
// (ctx) => ctx.count > 0
// Example: parseExpression("item.name") returns:
// (ctx) => ctx.item.name
compileEvent) EasyRestrict event handler syntax to single function calls only. No arbitrary JS expressions.
Current (unrestricted):
:on.click="event.preventDefault(); count++"
:on.click="await save(item)"
:on.click="if (ok) confirm()"
CSP version (function calls only):
:on.click="handleClick(event)"
:on.click="save(item)"
:on.click="confirm()"
Implementation: Parse the expression as a function call (identifier(args)), resolve the function name from context, and invoke it. Reject anything that isn't a simple call.
// Current
function compileEvent(expression) {
return cached("await " + expression, () =>
new Function("context",
`return (async () => { with (context) { ${expression} } })();`));
}
// CSP version
function compileEvent(expression) {
return cached("await " + expression, () => {
const match = expression.match(/^(\w+(?:\.\w+)*)\((.*)\)$/s);
if (!match) throw new Error(`CSP: event handler must be a function call: ${expression}`);
const [, name, args] = match;
return (ctx) => {
const fn = resolvePath(ctx, name);
const argVals = args.trim() ? evalArgs(args, ctx) : [];
return fn.apply(ctx.el, argVals);
};
});
}
compileAssign) Easy:model only needs to assign a value to a single property path. No with needed.
Current code:
function compileAssign(expression) {
return cached("=" + expression, () =>
new Function("context", "value",
`with (context) { ${expression} = value; }`));
}
CSP version:
function compileAssign(expression) {
return cached("=" + expression, () => {
// expression is a simple path like "name" or "item.done"
const parts = expression.split(".");
return (ctx, value) => {
let obj = ctx;
for (let i = 0; i < parts.length - 1; i++)
obj = obj[parts[i]];
obj[parts[parts.length - 1]] = value;
};
});
}
<script> execution HardThis is the hardest part. Menut currently runs SFC scripts with new Function(def.script).call(state), giving the script full access to this (the reactive state) and allowing arbitrary JS.
Options:
Parse the script and only allow:
function incrementar() { ... }host.onconnected = () => { ... }const x = ...Reject: import, class, for, while, top-level expressions.
This preserves the API but limits what scripts can do.
If CSP blocks new Function, the component renders its template but the script doesn't run. Users move logic to a separate .js file:
<script src="my-component.js"></script>
<script>
document.querySelector("x-my-component").addEventListener("connected", () => {
// logic here
});
</script>
This changes the developer experience significantly.
new Function optional at load timeMenut detects whether new Function works and degrades gracefully:
let EVAL_OK = true;
try { new Function(""); } catch { EVAL_OK = false; }
// In boot():
if (def.script) {
if (EVAL_OK) {
new Function(def.script).call(state);
} else {
console.warn(`Menut CSP: script in <${tag}> skipped (unsafe-eval blocked)`);
}
}
This is the least disruptive option. The framework works in both modes; scripts just don't run in CSP-strict environments.
menut-csp.js variant that ships the expression parser and restricted event handlers, with component scripts made optional (Option C). Keep the main menut.js as-is for maximum flexibility.
The effort breaks down as:
| Task | Estimate | Impact |
|---|---|---|
Expression parser (replaces compile) | ~200 lines | Covers 90% of use cases |
Restricted event handler (replaces compileEvent) | ~30 lines | Function calls only |
Simple assignment (replaces compileAssign) | ~15 lines | Dot-path assignment |
| Optional script execution | ~5 lines | Graceful degradation |
| Total | ~250 lines | CSP-strict compatible |
The resulting menut-csp.js would be slightly larger (~15-16 kb) but would work with script-src 'self' (no unsafe-eval).