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
88 changes: 88 additions & 0 deletions src/security/bind-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Attribute-binding guards shared by the client `bq-bind` directive and the
* SSR renderers.
*
* The attribute *name* comes from the template (author-trusted), but the
* *value* is runtime data — exactly the class of input the framework tells
* authors is safe to bind. URL-bearing attributes must therefore reject
* dangerous protocols, inline event handlers must never be bindable, and
* `srcdoc` must be treated as an HTML sink (the browser entity-decodes the
* attribute and parses it as a full document, so attribute-escaping alone is
* insufficient).
*
* @module bquery/security
* @internal
*/

import { DANGEROUS_PROTOCOLS } from './constants';

/**
* Attributes whose values the browser resolves as URLs.
* @internal
*/
export const URL_BIND_ATTRIBUTES = new Set([
'href',
'src',
'xlink:href',
'formaction',
'action',
'poster',
'background',
'cite',
'data',
]);

/**
* Normalize a URL for protocol checks, stripping control characters,
* zero-width characters, escaped Unicode sequences, and whitespace that
* could hide a dangerous protocol.
* @internal
*/
const normalizeUrlForCheck = (value: string): string =>
value
.replace(/[\u0000-\u001F\u007F]+/g, '')
.replace(/[\u200B-\u200D\uFEFF\u2028\u2029]+/g, '')
.replace(/\\u[\da-fA-F]{4}/g, '')
.replace(/\s+/g, '')
.toLowerCase();

/**
* Check whether a URL value bound to an attribute uses a safe protocol.
* @internal
*/
export const isSafeBindUrl = (value: string): boolean => {
const normalized = normalizeUrlForCheck(value);
return !DANGEROUS_PROTOCOLS.some((protocol) => normalized.startsWith(protocol));
};

/**
* Check every URL in a srcset value (comma-separated "url [descriptor]").
* @internal
*/
export const isSafeBindSrcset = (value: string): boolean =>
value.split(',').every((entry) => {
const url = entry.trim().split(/\s+/)[0];
return !url || isSafeBindUrl(url);
});

/**
* How a runtime-bound attribute value may be applied:
* - `'set'` — safe to write as-is
* - `'drop'` — must not be written (inline handler or unsafe URL)
* - `'sanitize-html'` — value is an HTML sink and must be sanitized first
* @internal
*/
export type BindAttributeVerdict = 'set' | 'drop' | 'sanitize-html';

/**
* Decide how a runtime-bound attribute value may be applied to an element.
* @internal
*/
export const checkBoundAttribute = (name: string, value: string): BindAttributeVerdict => {
const n = name.toLowerCase();
if (n.startsWith('on')) return 'drop';
if (n === 'srcdoc') return 'sanitize-html';
if (n === 'srcset') return isSafeBindSrcset(value) ? 'set' : 'drop';
if (URL_BIND_ATTRIBUTES.has(n) && !isSafeBindUrl(value)) return 'drop';
return 'set';
};
19 changes: 14 additions & 5 deletions src/ssr/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* @module bquery/ssr
*/

import { checkBoundAttribute } from '../security/bind-guard';
import { DANGEROUS_PROTOCOLS } from '../security/constants';
import type { BindingContext } from '../view/types';
import { getDOMParserImpl, resolveBackend } from './config';
Expand Down Expand Up @@ -355,19 +356,27 @@ const processSSRElement = (
}
}

// Handle bq-bind:attr — set arbitrary attributes
// Handle bq-bind:attr — set arbitrary attributes. Bound values are runtime
// data; guard handler/URL/srcdoc sinks before writing.
const attrs = Array.from(el.attributes);
for (const attr of attrs) {
if (attr.name.startsWith(`${prefix}-bind:`)) {
const attrName = attr.name.slice(`${prefix}-bind:`.length);
const value = evaluateSSR(attr.value, context);
if (value === false || value === null || value === undefined) {
el.removeAttribute(attrName);
} else if (value === true) {
el.setAttribute(attrName, '');
} else {
el.setAttribute(attrName, String(value));
continue;
}
const stringValue = value === true ? '' : String(value);
const verdict = checkBoundAttribute(attrName, stringValue);
if (verdict === 'drop') {
el.removeAttribute(attrName);
continue;
}
el.setAttribute(
attrName,
verdict === 'sanitize-html' ? sanitizeHtmlForSSR(stringValue) : stringValue
);
}
}

Expand Down
15 changes: 10 additions & 5 deletions src/ssr/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* @internal
*/

import { checkBoundAttribute } from '../security/bind-guard';
import {
DANGEROUS_ATTR_PREFIXES,
DANGEROUS_PROTOCOLS,
Expand Down Expand Up @@ -479,18 +480,22 @@ const evaluateElement = (
}
}

// bq-bind:*
// bq-bind:* — bound values are runtime data; guard handler/URL/srcdoc sinks.
for (const name of [...el.attributeOrder]) {
if (!name.startsWith(`${prefix}-bind:`)) continue;
const attrName = name.slice(`${prefix}-bind:`.length);
const value = evaluateExpression<unknown>(el.attributes[name], context);
if (value === false || value == null) {
removeAttr(el, attrName);
} else if (value === true) {
setAttr(el, attrName, '');
} else {
setAttr(el, attrName, String(value));
continue;
}
const stringValue = value === true ? '' : String(value);
const verdict = checkBoundAttribute(attrName, stringValue);
if (verdict === 'drop') {
removeAttr(el, attrName);
continue;
}
setAttr(el, attrName, verdict === 'sanitize-html' ? sanitizeHtmlForSSR(stringValue) : stringValue);
}

// bq-model / bq-on — interactive directive parity (#128).
Expand Down
24 changes: 20 additions & 4 deletions src/view/directives/bind.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { effect } from '../../reactive/index';
import { checkBoundAttribute } from '../../security/bind-guard';
import { sanitizeHtml } from '../../security/sanitize';
import { evaluate } from '../evaluate';
import type { DirectiveHandler } from '../types';

/**
* Handles bq-bind:attr directive - attribute binding.
*
* The bound value is runtime data, so it is guarded before it reaches the
* attribute: inline event handlers (`on*`) are never written, URL attributes
* reject dangerous protocols, and `srcdoc` is sanitized as an HTML sink.
* @internal
*/
export const handleBind = (attrName: string): DirectiveHandler => {
Expand All @@ -12,11 +18,21 @@ export const handleBind = (attrName: string): DirectiveHandler => {
const value = evaluate(expression, context);
if (value == null || value === false) {
el.removeAttribute(attrName);
} else if (value === true) {
el.setAttribute(attrName, '');
} else {
el.setAttribute(attrName, String(value));
return;
}
const stringValue = value === true ? '' : String(value);
const verdict = checkBoundAttribute(attrName, stringValue);
if (verdict === 'drop') {
el.removeAttribute(attrName);
console.warn(
`bQuery view: bq-bind:${attrName} dropped an unsafe value (inline handler or dangerous URL)`
);
return;
}
el.setAttribute(
attrName,
verdict === 'sanitize-html' ? String(sanitizeHtml(stringValue)) : stringValue
);
});
cleanups.push(cleanup);
};
Expand Down
46 changes: 46 additions & 0 deletions tests/ssr-stable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,52 @@ describe('#129 resumable boundaries — client resume', () => {
});
});

describe('#164 bq-bind attribute guarding', () => {
for (const backend of BACKENDS) {
describe(`backend: ${backend}`, () => {
it('drops javascript: URLs bound to href', () => {
withBackend(backend, () => {
const html = renderToString('<a bq-bind:href="link">x</a>', {
link: 'javascript:alert(document.cookie)',
}).html;
expect(html).not.toContain('javascript:');
});
});

it('never emits on* attributes via bq-bind', () => {
withBackend(backend, () => {
const html = renderToString('<div bq-bind:onclick="h">x</div>', {
h: 'alert(1)',
}).html;
// The bq-bind:onclick directive attribute may remain for hydration,
// but a live onclick attribute must never be emitted.
expect(html).not.toMatch(/ onclick=/);
expect(html).not.toContain('alert(1)');
});
});

it('sanitizes srcdoc bound to an iframe', () => {
withBackend(backend, () => {
const html = renderToString('<iframe bq-bind:srcdoc="msg"></iframe>', {
msg: '<script>alert(1)</script><p>ok</p>',
}).html;
expect(html).not.toContain('alert(1)');
expect(html).toContain('ok');
});
});

it('keeps safe bound URLs intact', () => {
withBackend(backend, () => {
const html = renderToString('<a bq-bind:href="link">x</a>', {
link: 'https://example.com/page',
}).html;
expect(html).toContain('href="https://example.com/page"');
});
});
});
}
});

describe('#176 bq-style CSS injection guard', () => {
for (const backend of BACKENDS) {
describe(`backend: ${backend}`, () => {
Expand Down
35 changes: 35 additions & 0 deletions tests/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,41 @@ describe('View', () => {
isDisabled.value = false;
expect(button.hasAttribute('disabled')).toBe(false);
});

it('drops javascript: URLs bound to href (#164)', () => {
container.innerHTML = '<a bq-bind:href="link">Link</a>';
const link = signal('javascript:alert(document.cookie)');

view = mount(container, { link });

const a = container.querySelector('a')!;
expect(a.hasAttribute('href')).toBe(false);

link.value = '/safe';
expect(a.getAttribute('href')).toBe('/safe');
});

it('never writes on* attributes via bq-bind (#164)', () => {
container.innerHTML = '<div bq-bind:onclick="handler">x</div>';
const handler = signal('alert(1)');

view = mount(container, { handler });

const div = container.querySelector('div')!;
expect(div.hasAttribute('onclick')).toBe(false);
});

it('sanitizes srcdoc bound to an iframe (#164)', () => {
container.innerHTML = '<iframe bq-bind:srcdoc="doc"></iframe>';
const doc = signal('<script>alert(1)</script><p>ok</p>');

view = mount(container, { doc });

const iframe = container.querySelector('iframe')!;
const srcdoc = iframe.getAttribute('srcdoc') ?? '';
expect(srcdoc).not.toContain('<script');
expect(srcdoc).toContain('ok');
});
});

describe('bq-on', () => {
Expand Down