CSP Compatibility

What would change to make Menut work without unsafe-eval

This is a reference document, not a roadmap. Menut deliberately chose new Function() for simplicity. This page documents what a CSP-strict build would require, for whoever needs it in the future.

Why 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.

Where new Function is used

#FunctionCompilesLineDifficulty
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

Proposed changes

1. Expression parser (compile) Medium

Replace 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:

What it can drop:

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

2. Event handler compiler (compileEvent) Easy

Restrict 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);
        };
    });
}

3. Assignment compiler (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;
        };
    });
}

4. Component <script> execution Hard

This 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:

Option A: Restrict to declarations only

Parse the script and only allow:

Reject: import, class, for, while, top-level expressions.

This preserves the API but limits what scripts can do.

Option B: Scripts become optional

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.

Option C: Make new Function optional at load time

Menut 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.

Recommended approach

Build a 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:

TaskEstimateImpact
Expression parser (replaces compile)~200 linesCovers 90% of use cases
Restricted event handler (replaces compileEvent)~30 linesFunction calls only
Simple assignment (replaces compileAssign)~15 linesDot-path assignment
Optional script execution~5 linesGraceful degradation
Total~250 linesCSP-strict compatible

The resulting menut-csp.js would be slightly larger (~15-16 kb) but would work with script-src 'self' (no unsafe-eval).