Skip to content

[Critical][ssr] bq-text on raw-text elements (textarea/title) is not escaped → stored XSS in default renderer #163

Description

@JosunLP

Severity: 🔴 Critical (verified)

Location

src/ssr/renderer.ts:195-197 (setText) and :418 (bq-text handler), interacting with src/ssr/html-parser.ts:358-362 (serializeTree raw-element branch). This is the default backend on all server runtimes without a global DOMParser (Node/Bun/Deno — see src/ssr/config.ts).

Description

setText writes the evaluated value directly as a text child with no escaping:

const setText = (el: SSRElement, value: string): void => {
  el.children = [{ type: 'text', value }];
};

For raw-text elements (script, style, textarea, title — see html-parser.ts:34), serializeTree emits text children verbatim, without escaping:

if (el.raw) {
  for (const child of el.children) {
    if (child.type === 'text') inner += child.value;   // no escaping
  }
}

So an untrusted bq-text value on a <textarea> or <title> can close the element and inject live markup. The author was aware of exactly this hazard for bq-modelrenderer.ts:234-238 escapes via el.raw ? escapeText(...) with a comment naming the XSS — but the identical protection is missing on the bq-text path (renderer.ts:418 calls setText(el, String(value ?? ''))).

Reproduction (executed)

Input:  <textarea bq-text="msg"></textarea>   msg = '</textarea><img src=x onerror=alert(1)>'
Output: <textarea bq-text="msg"></textarea><img src=x onerror=alert(1)></textarea>

Input:  <title bq-text="msg"></title>          msg = '</title><script>alert(1)</script>'
Output: <title bq-text="msg"></title><script>alert(1)</script></title>

The browser closes the textarea/title at the injected close tag, then executes the following <img onerror> / <script>. bq-text on a <textarea>/<title> is a completely ordinary pattern requiring no developer misuse, and the value is exactly where untrusted data normally flows. (The legacy DOM-backed path in render.ts, which uses textContent + escapeHtmlText, is not affected — only the default pure renderer.)

Suggested fix

Escape when the target is a raw-text element, mirroring the existing bq-model handling:

const setText = (el: SSRElement, value: string): void => {
  el.children = [{ type: 'text', value: el.raw ? escapeText(value) : value }];
};

Filed as part of a full-codebase security & correctness audit.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcriticalCritical severitysecurityChanges to the security modulessrChanges to the ssr modulevulnerabilitySecurity vulnerability

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions