Skip to content
Open
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
3 changes: 3 additions & 0 deletions FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ Aurelia-inspired binding commands on standard HTML attributes:
<!-- Interpolation -->
<p>Hello, ${name}!</p>

<!-- Explicit unsafe HTML binding (trusted/sanitized content only) -->
<div innerhtml.unsafe-bind="trustedHtml"></div>

<!-- Conditional rendering -->
<div if.bind="isLoggedIn">Welcome back</div>

Expand All @@ -180,6 +183,8 @@ Aurelia-inspired binding commands on standard HTML attributes:
</ul>
```

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
Expand Down
16 changes: 16 additions & 0 deletions packages/compiler/src/__tests__/compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ describe('DiamondCompiler', () => {
expect(result.code).toContain('this.message')
})

it('blocks unsafe DOM sink bindings by default', () => {
expect(() => {
compiler.compile('<div innerhtml.bind="userContent"></div>')
}).toThrow(CompileError)
})

it('allows unsafe DOM sink bindings with explicit unsafe-bind', () => {
const result = compiler.compile(
'<div innerhtml.unsafe-bind="trustedHtml"></div>'
)

expect(result.code).toContain(
"DiamondCore.bindUnsafe(div0, 'innerHTML', () => this.trustedHtml, (v) => this.trustedHtml = v)"
)
})

it('emits [Diamond] hint comments', () => {
const result = compiler.compile('<input value.bind="name">')

Expand Down
15 changes: 15 additions & 0 deletions packages/compiler/src/__tests__/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [{
Expand Down
11 changes: 11 additions & 0 deletions packages/compiler/src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,17 @@ describe('TemplateParser', () => {
}
})

it('parses unsafe-bind binding', () => {
const nodes = parser.parse('<div innerhtml.unsafe-bind="trustedHtml"></div>')
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('<span textContent.bind="user.profile.name"></span>')
expect(nodes).toHaveLength(1)
Expand Down
47 changes: 46 additions & 1 deletion packages/compiler/src/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*
Expand Down
11 changes: 11 additions & 0 deletions packages/compiler/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions packages/compiler/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export class TemplateParser {
private parseBindingCommand(command: string): BindingType {
const commandMap: Record<string, BindingType> = {
bind: 'bind',
'unsafe-bind': 'unsafe-bind',
'one-time': 'one-time',
'to-view': 'to-view',
'from-view': 'from-view',
Expand Down
1 change: 1 addition & 0 deletions packages/compiler/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/parcel-plugin/src/__tests__/transformer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ describe('isDiamondTemplate', () => {
expect(isDiamondTemplate('<input value.bind="name">')).toBe(true)
})

it('detects .unsafe-bind syntax', () => {
expect(
isDiamondTemplate('<div innerhtml.unsafe-bind="trustedHtml"></div>')
).toBe(true)
})

it('detects .one-time syntax', () => {
expect(isDiamondTemplate('<span textContent.one-time="title"></span>')).toBe(
true
Expand Down
2 changes: 1 addition & 1 deletion packages/parcel-plugin/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /\$\{[^}]+\}/

Expand Down
45 changes: 45 additions & 0 deletions packages/runtime/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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<string, unknown>

Expand Down Expand Up @@ -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
*
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function reactive(
return store ? store.value : undefined
},
set(this: Record<symbol, { value: unknown }>, 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 })
Expand Down
18 changes: 18 additions & 0 deletions packages/runtime/tests/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => '<img src=x onerror=alert(1)>')
}).toThrow('Refused unsafe binding')
})

it('should allow unsafe DOM sink bindings with explicit bindUnsafe', async () => {
const state = DiamondCore.reactive({ html: '<strong>trusted</strong>' })
const div = document.createElement('div')

DiamondCore.bindUnsafe(div, 'innerHTML', () => state.html)
await vi.runAllTimersAsync()

expect(div.innerHTML).toBe('<strong>trusted</strong>')
})

it('should use change event for checkboxes', async () => {
const state = DiamondCore.reactive({ checked: false })
const input = document.createElement('input')
Expand Down