diff --git a/FAQ.md b/FAQ.md index 07caafa..17f468e 100644 --- a/FAQ.md +++ b/FAQ.md @@ -15,6 +15,9 @@ TypeScript is optional but encouraged! Since we use modern JavaScript features n ## **Q: Which browsers are supported?** Chrome/Edge 90+, Firefox 90+, Safari 15+, and all mobile browsers from 2023+. We target browsers with native support for private class fields, optional chaining, nullish coalescing, and other ES2022+ features. +## **Q: How does DiamondJS prevent XSS from template bindings?** +By default, DiamondJS blocks bindings to unsafe DOM sinks (`innerHTML`, `outerHTML`, `srcdoc`) during compilation and again at runtime. If you need raw HTML assignment, use `.unsafe-bind` (or `DiamondCore.bindUnsafe()`) only with trusted, sanitized content. + ## **Q: When should I use `collection()` vs `reactive()`?** Use `reactive()` for small UI state (< 1,000 items, update-heavy workloads like forms). Use `collection()` for large datasets (> 1,000 items, append-heavy workloads like logs, chat, terminals). The performance difference is dramatic: constant 0.005ms appends with Collection vs. degrading to 0.2ms at 100K items with reactive. diff --git a/README.md b/README.md index b445b49..6a854f6 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,9 @@ Aurelia-inspired binding commands on standard HTML attributes:

Hello, ${name}!

+ +
+
Welcome back
@@ -180,6 +183,8 @@ Aurelia-inspired binding commands on standard HTML attributes: ``` +Security defaults: DiamondJS blocks unsafe DOM sink bindings (`innerHTML`, `outerHTML`, `srcdoc`) for normal `.bind`/`.to-view`/`.two-way` usage. Use `.unsafe-bind` only for trusted, sanitized content. + --- ## Project Structure diff --git a/packages/compiler/src/__tests__/compiler.test.ts b/packages/compiler/src/__tests__/compiler.test.ts index 60e5a4c..d3b101d 100644 --- a/packages/compiler/src/__tests__/compiler.test.ts +++ b/packages/compiler/src/__tests__/compiler.test.ts @@ -36,6 +36,22 @@ describe('DiamondCompiler', () => { expect(result.code).toContain('this.message') }) + it('blocks unsafe DOM sink bindings by default', () => { + expect(() => { + compiler.compile('
') + }).toThrow(CompileError) + }) + + it('allows unsafe DOM sink bindings with explicit unsafe-bind', () => { + const result = compiler.compile( + '
' + ) + + expect(result.code).toContain( + "DiamondCore.bindUnsafe(div0, 'innerHTML', () => this.trustedHtml, (v) => this.trustedHtml = v)" + ) + }) + it('emits [Diamond] hint comments', () => { const result = compiler.compile('') diff --git a/packages/compiler/src/__tests__/generator.test.ts b/packages/compiler/src/__tests__/generator.test.ts index fb7a53d..48b0940 100644 --- a/packages/compiler/src/__tests__/generator.test.ts +++ b/packages/compiler/src/__tests__/generator.test.ts @@ -163,6 +163,21 @@ describe('CodeGenerator', () => { expect(result.code).toContain('// [Diamond] Two-way binding') }) + it('generates explicit unsafe binding', () => { + const nodes: NodeInfo[] = [createElement('div', { + bindings: [{ + type: 'unsafe-bind', + property: 'innerHTML', + expression: 'trustedHtml', + location: null, + }], + })] + const result = generator.generate(nodes) + + expect(result.code).toContain("DiamondCore.bindUnsafe(div0, 'innerHTML', () => this.trustedHtml, (v) => this.trustedHtml = v)") + expect(result.code).toContain('// [Diamond] UNSAFE two-way binding (opt-in)') + }) + it('handles property paths', () => { const nodes: NodeInfo[] = [createElement('span', { bindings: [{ diff --git a/packages/compiler/src/__tests__/parser.test.ts b/packages/compiler/src/__tests__/parser.test.ts index 9abe531..752794b 100644 --- a/packages/compiler/src/__tests__/parser.test.ts +++ b/packages/compiler/src/__tests__/parser.test.ts @@ -119,6 +119,17 @@ describe('TemplateParser', () => { } }) + it('parses unsafe-bind binding', () => { + const nodes = parser.parse('
') + expect(nodes).toHaveLength(1) + if (isElementInfo(nodes[0])) { + expect(nodes[0].bindings).toHaveLength(1) + expect(nodes[0].bindings[0].type).toBe('unsafe-bind') + expect(nodes[0].bindings[0].property).toBe('innerHTML') + expect(nodes[0].bindings[0].expression).toBe('trustedHtml') + } + }) + it('parses property paths', () => { const nodes = parser.parse('') expect(nodes).toHaveLength(1) diff --git a/packages/compiler/src/compiler.ts b/packages/compiler/src/compiler.ts index db49325..e69f98e 100644 --- a/packages/compiler/src/compiler.ts +++ b/packages/compiler/src/compiler.ts @@ -8,7 +8,10 @@ import { TemplateParser } from './parser' import { CodeGenerator } from './generator' -import type { CompilerOptions, CompileResult } from './types' +import type { CompilerOptions, CompileResult, NodeInfo, ElementInfo } from './types' +import { isElementInfo } from './types' + +const UNSAFE_DOM_SINK_PROPERTIES = new Set(['innerhtml', 'outerhtml', 'srcdoc']) /** * DiamondCompiler - The main template compiler @@ -38,11 +41,53 @@ export class DiamondCompiler { // Parse the template const nodes = this.parser.parse(template) + // Security validation + this.validateBindingSecurity(nodes) + // Generate code const generator = new CodeGenerator(options) return generator.generate(nodes) } + /** + * Block unsafe DOM sink bindings unless explicitly opted in with .unsafe-bind + */ + private validateBindingSecurity(nodes: NodeInfo[]): void { + for (const node of nodes) { + if (!isElementInfo(node)) continue + this.validateElementBindingSecurity(node) + } + } + + private validateElementBindingSecurity(element: ElementInfo): void { + for (const binding of element.bindings) { + if (binding.type === 'unsafe-bind') { + continue + } + + if (!this.isUnsafeDomSinkProperty(binding.property)) { + continue + } + + const location = binding.location || element.location || { line: 1, column: 0 } + throw new CompileError( + `Unsafe DOM sink "${binding.property}" is blocked by default. ` + + `Use .unsafe-bind only with trusted, sanitized content.`, + { line: location.line, column: location.column } + ) + } + + for (const child of element.children) { + if (isElementInfo(child)) { + this.validateElementBindingSecurity(child) + } + } + } + + private isUnsafeDomSinkProperty(property: string): boolean { + return UNSAFE_DOM_SINK_PROPERTIES.has(property.toLowerCase()) + } + /** * Compile a template and inject into a component class * diff --git a/packages/compiler/src/generator.ts b/packages/compiler/src/generator.ts index 83ebab9..4344bce 100644 --- a/packages/compiler/src/generator.ts +++ b/packages/compiler/src/generator.ts @@ -214,6 +214,17 @@ export class CodeGenerator { ) break + case 'unsafe-bind': + // [Diamond] hint + this.emitLine( + `// [Diamond] UNSAFE two-way binding (opt-in): ${binding.property} ↔ this.${binding.expression}` + ) + this.emitLine( + `DiamondCore.bindUnsafe(${varName}, '${binding.property}', () => ${expr}, (v) => ${expr} = v);`, + binding.location + ) + break + case 'from-view': // [Diamond] hint this.emitLine( diff --git a/packages/compiler/src/parser.ts b/packages/compiler/src/parser.ts index 8ef08ba..f25bb52 100644 --- a/packages/compiler/src/parser.ts +++ b/packages/compiler/src/parser.ts @@ -179,6 +179,7 @@ export class TemplateParser { private parseBindingCommand(command: string): BindingType { const commandMap: Record = { bind: 'bind', + 'unsafe-bind': 'unsafe-bind', 'one-time': 'one-time', 'to-view': 'to-view', 'from-view': 'from-view', diff --git a/packages/compiler/src/types.ts b/packages/compiler/src/types.ts index 8afa5b8..b0719a2 100644 --- a/packages/compiler/src/types.ts +++ b/packages/compiler/src/types.ts @@ -18,6 +18,7 @@ export interface SourceLocation { */ export type BindingType = | 'bind' // Two-way binding (default) + | 'unsafe-bind' // Two-way binding to unsafe DOM sinks (explicit opt-in) | 'one-time' // One-time binding (no updates) | 'to-view' // One-way to view | 'from-view' // One-way from view diff --git a/packages/parcel-plugin/src/__tests__/transformer.test.ts b/packages/parcel-plugin/src/__tests__/transformer.test.ts index 33bb015..a022603 100644 --- a/packages/parcel-plugin/src/__tests__/transformer.test.ts +++ b/packages/parcel-plugin/src/__tests__/transformer.test.ts @@ -13,6 +13,12 @@ describe('isDiamondTemplate', () => { expect(isDiamondTemplate('')).toBe(true) }) + it('detects .unsafe-bind syntax', () => { + expect( + isDiamondTemplate('
') + ).toBe(true) + }) + it('detects .one-time syntax', () => { expect(isDiamondTemplate('')).toBe( true diff --git a/packages/parcel-plugin/src/utils.ts b/packages/parcel-plugin/src/utils.ts index d3c1f10..5aa9b7c 100644 --- a/packages/parcel-plugin/src/utils.ts +++ b/packages/parcel-plugin/src/utils.ts @@ -11,7 +11,7 @@ import { DiamondCompiler, type CompileResult } from '@diamondjs/compiler' export function isDiamondTemplate(code: string): boolean { // Check for binding syntax: property.command="expression" const bindingPattern = - /\.\s*(bind|one-time|to-view|from-view|two-way|trigger|delegate|capture)\s*=/ + /\.\s*(bind|unsafe-bind|one-time|to-view|from-view|two-way|trigger|delegate|capture)\s*=/ // Check for interpolation syntax: ${...} const interpolationPattern = /\$\{[^}]+\}/ diff --git a/packages/runtime/src/core.ts b/packages/runtime/src/core.ts index 0e3e080..76e6fff 100644 --- a/packages/runtime/src/core.ts +++ b/packages/runtime/src/core.ts @@ -9,6 +9,8 @@ import { reactivityEngine } from './reactivity' type CleanupFn = () => void +const UNSAFE_DOM_SINK_PROPERTIES = new Set(['innerhtml', 'outerhtml', 'srcdoc']) + /** * DiamondCore - The main runtime API class * @@ -96,6 +98,34 @@ export class DiamondCore { getter: () => unknown, setter?: (value: unknown) => void ): CleanupFn { + return this.bindInternal(element, property, getter, setter, false) + } + + /** + * Bind to an unsafe DOM sink (innerHTML/outerHTML/srcdoc). + * Use only with trusted, sanitized content. + */ + static bindUnsafe( + element: HTMLElement, + property: string, + getter: () => unknown, + setter?: (value: unknown) => void + ): CleanupFn { + return this.bindInternal(element, property, getter, setter, true) + } + + /** + * Shared binding implementation for safe and explicit-unsafe paths. + */ + private static bindInternal( + element: HTMLElement, + property: string, + getter: () => unknown, + setter: ((value: unknown) => void) | undefined, + allowUnsafe: boolean + ): CleanupFn { + this.assertSafeBindingProperty(property, allowUnsafe) + // Cast element for dynamic property access const el = element as unknown as Record @@ -124,6 +154,21 @@ export class DiamondCore { } } + private static assertSafeBindingProperty( + property: string, + allowUnsafe: boolean + ): void { + const normalized = property.toLowerCase() + if (allowUnsafe || !UNSAFE_DOM_SINK_PROPERTIES.has(normalized)) { + return + } + + throw new Error( + `[Diamond] Refused unsafe binding to "${property}". ` + + 'Use .unsafe-bind in templates or DiamondCore.bindUnsafe() with trusted, sanitized content.' + ) + } + /** * Attach an event listener to an element * diff --git a/packages/runtime/src/decorators.ts b/packages/runtime/src/decorators.ts index e7f72d1..eab2b29 100644 --- a/packages/runtime/src/decorators.ts +++ b/packages/runtime/src/decorators.ts @@ -60,7 +60,7 @@ export function reactive( return store ? store.value : undefined }, set(this: Record, newValue: unknown) { - let store = this[storageKey] + const store = this[storageKey] if (!store) { // First assignment (field initializer) — create reactive backing store this[storageKey] = reactivityEngine.createProxy({ value: newValue }) diff --git a/packages/runtime/tests/core.test.ts b/packages/runtime/tests/core.test.ts index 2cc319d..3599aca 100644 --- a/packages/runtime/tests/core.test.ts +++ b/packages/runtime/tests/core.test.ts @@ -114,6 +114,24 @@ describe('DiamondCore', () => { expect(div.textContent).toBe('Hello') }) + it('should block unsafe DOM sink bindings by default', () => { + const div = document.createElement('div') + + expect(() => { + DiamondCore.bind(div, 'innerHTML', () => '') + }).toThrow('Refused unsafe binding') + }) + + it('should allow unsafe DOM sink bindings with explicit bindUnsafe', async () => { + const state = DiamondCore.reactive({ html: 'trusted' }) + const div = document.createElement('div') + + DiamondCore.bindUnsafe(div, 'innerHTML', () => state.html) + await vi.runAllTimersAsync() + + expect(div.innerHTML).toBe('trusted') + }) + it('should use change event for checkboxes', async () => { const state = DiamondCore.reactive({ checked: false }) const input = document.createElement('input')