From 7da9b263630d42aad2cff0d55290cee7259291fa Mon Sep 17 00:00:00 2001 From: Shubham Srivastava Date: Thu, 16 Jul 2026 23:02:38 +0530 Subject: [PATCH] fix(dashboard): escape model id in hx-vals to prevent injection/XSS (#73) The model id is interpolated into the single-quoted hx-vals attribute. A crafted id (reachable as an `active` model from any /v1/messages request on the unauthenticated localhost dashboard) could break out of the JSON or the attribute itself. Build valid JSON via JSON.stringify, then escapeHtml the whole attribute value; the browser decodes the entities before htmx reads it, so well-formed ids are unaffected. Closes #58. Co-authored-by: Claude Opus 4.8 --- src/dashboard/fragments.ts | 2 +- tests/models-fragment-escape.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/models-fragment-escape.test.ts diff --git a/src/dashboard/fragments.ts b/src/dashboard/fragments.ts index ad3f964..3e5971e 100644 --- a/src/dashboard/fragments.ts +++ b/src/dashboard/fragments.ts @@ -124,7 +124,7 @@ export function renderModelsFragment( return ( `` + `hx-vals='${escapeHtml(`{"model":${JSON.stringify(id)},"on":${!lit}}`)}'>${escapeHtml(label)}${lit ? ' ✓' : ''}` ); }; const claudeChips = ids.filter((id) => id.startsWith('claude')).map(chipFor).join(''); diff --git a/tests/models-fragment-escape.test.ts b/tests/models-fragment-escape.test.ts new file mode 100644 index 0000000..a602a3f --- /dev/null +++ b/tests/models-fragment-escape.test.ts @@ -0,0 +1,26 @@ +/** + * A crafted model id (attacker-controlled: it rides in as an `active` model + * from a /v1/messages request on unauthenticated localhost) must not be able + * to break out of the single-quoted hx-vals attribute or inject extra JSON + * keys. See issue #58. + */ + +import { describe, it, expect } from 'vitest'; +import { renderModelsFragment } from '../src/dashboard/fragments.js'; + +describe('renderModelsFragment hx-vals escaping', () => { + it('escapes crafted model ids so they cannot break out of the attribute', () => { + // Double-quote → JSON injection; single-quote → attribute breakout. + const evil = `foo","evil":"bar' onmouseover='alert(1)`; + const html = renderModelsFragment([evil], [], true); + + // Raw quotes from the id never reach the attribute unescaped. + expect(html).not.toContain(`"model":"${evil}"`); + expect(html).not.toContain(`bar' onmouseover=`); + + // A well-formed id still renders its JSON value (as escaped entities that + // the browser decodes back to valid JSON before htmx reads them). + const ok = renderModelsFragment(['claude-fable-5'], [], true); + expect(ok).toContain('"model":"claude-fable-5"'); + }); +});