Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 8 additions & 70 deletions src/ssr/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
* @module bquery/ssr
*/

import { isComputed, isSignal, type Signal } from '../reactive/index';
import { DANGEROUS_PROTOCOLS } from '../security/constants';
import type { BindingContext } from '../view/types';
import { getDOMParserImpl, resolveBackend } from './config';
import { evaluateExpression } from './expression';
import {
classifyUnsupportedDirective,
collectOnEvents,
Expand Down Expand Up @@ -123,79 +123,17 @@ const serializeSSRNode = (node: Node): string => {
};

/**
* Unwraps a value — if it's a signal/computed, returns `.value`, otherwise returns as-is.
* @internal
*/
const unwrap = (value: unknown): unknown => {
if (isSignal(value) || isComputed(value)) {
return (value as Signal<unknown>).value;
}
return value;
};

/**
* Evaluates a simple expression against a context.
* Supports dot-notation property access, negation, ternary, and basic comparisons.
* Unlike the view module's `evaluate()`, this does NOT use `new Function()` —
* it uses a safe subset for SSR to avoid `unsafe-eval` in server environments.
* Evaluates a template expression against a context.
*
* Falls back to `new Function()` for complex expressions.
* Delegates to the CSP-safe Pratt-parser evaluator shared with the pure
* renderer (`expression.ts`): no `eval`/`new Function()` fallback, and member
* access is guarded against prototype-chain lookups (`constructor`,
* `__proto__`, …). Unsupported expressions evaluate to `undefined`.
*
* @internal
*/
const evaluateSSR = <T = unknown>(expression: string, context: BindingContext): T => {
const trimmed = expression.trim();

// Handle negation: !expr
if (trimmed.startsWith('!')) {
return !evaluateSSR(trimmed.slice(1).trim(), context) as T;
}

// Handle string literals
if (
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
) {
return trimmed.slice(1, -1) as T;
}

// Handle numeric literals
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return Number(trimmed) as T;
}

// Handle boolean literals
if (trimmed === 'true') return true as T;
if (trimmed === 'false') return false as T;
if (trimmed === 'null') return null as T;
if (trimmed === 'undefined') return undefined as T;

// Handle dot-notation property access: a.b.c
if (/^[\w$]+(?:\.[\w$]+)*$/.test(trimmed)) {
const parts = trimmed.split('.');
let current: unknown = context;
for (const part of parts) {
if (current == null) return undefined as T;
// First level: unwrap signals
if (current === context) {
current = unwrap((current as Record<string, unknown>)[part]);
} else {
current = (current as Record<string, unknown>)[part];
}
}
return current as T;
}

// For complex expressions, fall back to Function-based evaluation
try {
const keys = Object.keys(context);
const values = keys.map((k) => unwrap(context[k]));
const fn = new Function(...keys, `return (${trimmed});`);
return fn(...values) as T;
} catch {
return undefined as T;
}
};
const evaluateSSR = <T = unknown>(expression: string, context: BindingContext): T =>
evaluateExpression<T>(expression, context);

/**
* Parses a `bq-for` expression like `item in items` or `(item, index) in items`.
Expand Down
28 changes: 28 additions & 0 deletions tests/ssr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,34 @@ describe('renderToString', () => {
}
});

it('does not reach the Function constructor via the DOM backend evaluator (#167)', () => {
const previousConfig = getSSRConfig();
try {
configureSSR({ backend: 'dom' });
const result = renderToString('<div bq-text="constructor.constructor"></div>', {});
expect(result.html).not.toContain('function');
expect(result.html).not.toContain('Function');
// The guarded evaluator yields undefined → no text content injected.
expect(result.html).toMatch(/<div[^>]*><\/div>/);
} finally {
configureSSR(previousConfig);
}
});

it('does not eval arbitrary JS on the DOM backend (#167)', () => {
const previousConfig = getSSRConfig();
try {
configureSSR({ backend: 'dom' });
// A complex expression that only worked via the removed new Function()
// fallback now evaluates through the CSP-safe parser (arithmetic is
// supported; side-effecting JS is not reachable).
const result = renderToString('<div bq-text="a + b"></div>', { a: 2, b: 3 });
expect(result.html).toContain('>5</div>');
} finally {
configureSSR(previousConfig);
}
});

it('renders nested elements correctly', () => {
const result = renderToString(
'<div><section bq-if="show"><h2 bq-text="title"></h2><p bq-text="body"></p></section></div>',
Expand Down