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
14 changes: 13 additions & 1 deletion src/ssr/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,25 @@ const setClass = (el: SSRElement, cls: string): void => {
el.attributes['class'] = merged;
};

// A CSS property name: standard kebab-case identifiers or `--custom-props`.
const SAFE_STYLE_PROP = /^(?:--[\w-]+|-?[a-z][a-z0-9-]*)$/;
// Characters that would let an untrusted value break out of its declaration and
// inject additional declarations or rules.
const UNSAFE_STYLE_VALUE = /[;{}<]/;

const setStyle = (el: SSRElement, declarations: Record<string, unknown>): void => {
let css = el.attributes['style'] ?? '';
for (const [prop, val] of Object.entries(declarations)) {
if (val === undefined || val === null || val === false) continue;
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
const value = String(val);
// Drop declarations whose property or value could inject extra CSS
// (e.g. a value like `x;} body{display:none`). The value is only
// attribute-escaped on serialize, which stops HTML breakout but not the
// injection of sibling declarations/rules within the style attribute.
if (!SAFE_STYLE_PROP.test(cssProp) || UNSAFE_STYLE_VALUE.test(value)) continue;
if (css && !css.endsWith(';')) css += '; ';
css += `${cssProp}: ${String(val)};`;
css += `${cssProp}: ${value};`;
}
if (!('style' in el.attributes)) el.attributeOrder.push('style');
el.attributes['style'] = css;
Expand Down
26 changes: 26 additions & 0 deletions tests/ssr-stable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,32 @@ describe('#129 resumable boundaries — client resume', () => {
});
});

describe('#176 bq-style CSS injection guard', () => {
for (const backend of BACKENDS) {
describe(`backend: ${backend}`, () => {
it('drops style values that try to inject extra declarations', () => {
withBackend(backend, () => {
const html = renderToString('<div bq-style="styles"></div>', {
styles: { width: 'x;} body{display:none' },
}).html;
expect(html).not.toContain('display:none');
expect(html).not.toContain('body{');
});
});

it('keeps safe style declarations', () => {
withBackend(backend, () => {
const html = renderToString('<div bq-style="styles"></div>', {
styles: { color: 'red', marginTop: '4px' },
}).html;
expect(html).toContain('color: red');
expect(html).toContain('margin-top: 4px');
});
});
});
}
});

describe('#163 bq-text escaping on raw-text elements', () => {
for (const backend of BACKENDS) {
describe(`backend: ${backend}`, () => {
Expand Down