max / makenotwork
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 %} / ∞{% 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 | + | } |