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')