Skip to main content

max / makenotwork

Fix UX-1: move free-text out of inline onclick JS-string context Five inline onclick handlers interpolated free-text fields (promo code, insertion title, synckit app name/slug) directly into a JS-string literal. HTML escaping does not cover JS-string context, so a value containing a single quote broke out (self-XSS — the fields are only length-validated). Move each value into a data-* attribute (Askama HTML-escapes attribute values) and have the handler read it via this.dataset.*, which returns the decoded string as a JS value that is never parsed as source. The existing JS functions and the safe UUID args are unchanged. Regression test: a promo code containing a quote renders into an escaped data-code attribute with the copy handler reading dataset — never into a writeText('...') literal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-18 02:07 UTC
Commit: 0a50783e258a2af2900805e8a723b2bb003c819d
Parent: bb78004
5 files changed, +41 insertions, -6 deletions
@@ -28,7 +28,7 @@
28 28 <td>{{ ins.media_type }}</td>
29 29 <td>{{ ins.created_at }}</td>
30 30 <td class="insertion-list-actions">
31 - <button type="button" class="btn btn-xs insertion-list-action-btn" onclick="MNW.insertions.rename('{{ ins.id }}', '{{ ins.title }}')">Rename</button>
31 + <button type="button" class="btn btn-xs insertion-list-action-btn" data-title="{{ ins.title }}" onclick="MNW.insertions.rename('{{ ins.id }}', this.dataset.title)">Rename</button>
32 32 <button type="button" class="btn btn-xs btn-danger insertion-list-action-btn"
33 33 hx-delete="/api/insertions/{{ ins.id }}"
34 34 hx-target="#insertion-library"
@@ -18,7 +18,7 @@
18 18 {% for code in promo_codes %}
19 19 <tr{% if code.is_expired %} class="promo-codes-row--expired"{% else if code.is_not_yet_active %} class="promo-codes-row--pending"{% endif %}>
20 20 <td>
21 - <code class="key-code" title="Click to copy" onclick="navigator.clipboard.writeText('{{ code.code }}')">
21 + <code class="key-code" title="Click to copy" data-code="{{ code.code }}" onclick="navigator.clipboard.writeText(this.dataset.code)">
22 22 {{ code.code }}
23 23 </code>
24 24 </td>
@@ -26,7 +26,8 @@
26 26 <td>
27 27 {% if code.use_count > 0 %}
28 28 <button class="btn-link btn-sm" type="button"
29 - onclick="showPromoRedemptions('{{ code.id }}', '{{ code.code }}')"
29 + data-code="{{ code.code }}"
30 + onclick="showPromoRedemptions('{{ code.id }}', this.dataset.code)"
30 31 title="Show who redeemed this code">
31 32 {{ code.use_count }}{% if let Some(max) = code.max_uses %} / {{ max }}{% else %} / &infin;{% endif %}
32 33 </button>
@@ -76,7 +76,8 @@
76 76 <button class="btn-small btn-secondary proj-synckit-btn-row"
77 77 onclick="syncKitRegenKey('{{ app.id }}')">Regenerate Key</button>
78 78 <button class="btn-small btn-danger proj-synckit-btn-row"
79 - onclick="syncKitDeleteApp('{{ app.id }}', '{{ app.name }}')">Delete</button>
79 + data-name="{{ app.name }}"
80 + onclick="syncKitDeleteApp('{{ app.id }}', this.dataset.name)">Delete</button>
80 81 </td>
81 82 </tr>
82 83 {% endfor %}
@@ -62,7 +62,8 @@
62 62 <span class="dimmed">-</span>
63 63 {% endif %}
64 64 <button class="btn-small btn-secondary user-synckit-inline-btn"
65 - onclick="syncKitShowSlugForm('{{ app.id }}', '{{ app.slug.as_deref().unwrap_or_default() }}')">Set</button>
65 + data-slug="{{ app.slug.as_deref().unwrap_or_default() }}"
66 + onclick="syncKitShowSlugForm('{{ app.id }}', this.dataset.slug)">Set</button>
66 67 </td>
67 68 <td id="synckit-link-cell-{{ app.id }}">
68 69 {% if let Some(pname) = app.project_name %}
@@ -97,7 +98,8 @@
97 98 <button class="btn-small btn-secondary user-synckit-row-btn"
98 99 onclick="syncKitRegenKey('{{ app.id }}')">Regenerate Key</button>
99 100 <button class="btn-small btn-danger user-synckit-row-btn"
100 - onclick="syncKitDeleteApp('{{ app.id }}', '{{ app.name }}')">Delete</button>
101 + data-name="{{ app.name }}"
102 + onclick="syncKitDeleteApp('{{ app.id }}', this.dataset.name)">Delete</button>
101 103 </td>
102 104 </tr>
103 105 {% endfor %}
@@ -271,3 +271,34 @@ async fn discount_code_project_scoped() {
271 271 assert!(data.iter().any(|c| c["code"].as_str() == Some("PROJ10")),
272 272 "Project-scoped code should appear in listing");
273 273 }
274 +
275 + // UX-1: free-text rendered into a JS-string context (inline onclick) must be
276 + // escaped. A promo code is only length-validated, so it can contain a quote; it
277 + // must land in an escaped data-* attribute with the copy handler reading dataset,
278 + // never interpolated raw into a writeText('...') literal (which a quote breaks).
279 + #[tokio::test]
280 + async fn promo_code_with_quote_is_not_in_js_string_context() {
281 + let mut h = TestHarness::new().await;
282 + let setup = h.create_creator_with_item("xssseller", "digital", 1000).await;
283 + h.publish_project_and_item(&setup.project_id, &setup.item_id).await;
284 +
285 + // An HTMX create returns the rendered promo_codes_list.html partial (otherwise
286 + // the route returns JSON); htmx_post_form also injects the CSRF token.
287 + let resp = h
288 + .client
289 + .htmx_post_form(
290 + "/api/promo-codes",
291 + "code=AB'CD&code_purpose=discount&discount_type=percentage&discount_value=25",
292 + )
293 + .await;
294 + assert!(resp.status.is_success(), "create failed: {} {}", resp.status, resp.text);
295 +
296 + let html = &resp.text;
297 + // Code is uppercased to AB'CD. The raw quote must never appear unescaped
298 + // (Askama escapes it in both the cell text and the data attribute).
299 + assert!(!html.contains("AB'CD"), "raw quoted code must not appear unescaped: {html}");
300 + // The copy handler reads the data attribute instead of interpolating the code.
301 + assert!(html.contains("writeText(this.dataset.code)"), "copy must read dataset: {html}");
302 + assert!(!html.contains("writeText('"), "code must not be interpolated into a JS string literal");
303 + assert!(html.contains("data-code="), "escaped code must live in a data-code attr: {html}");
304 + }