Skip to main content

max / makenotwork

audit Run 15 Phase 5: CSP script-src drops 'unsafe-inline' Removed every inline script from templates so the CSP can forbid inline script execution — the main-site XSS backstop that was defanged by ~200 inline handlers. - Added a data-action / data-change / data-input / data-submit delegation dispatcher and a data-hx-* htmx-callback dispatcher to mnw.js (built-in DOM verbs, prevent/stop modifiers, global-function passthrough, and named behaviors for the multi-step cases). - Migrated all 204 inline on* handlers, 2 javascript: hrefs, and 30 hx-on:: attributes across 70 templates to those dispatchers; per-area wrapper functions live in static/actions-{pages,partials,tabs,dashboards}.js. - Standalone embed player (no dispatcher) wires its play/seek controls directly in embed-item-player.js. - lib.rs CSP: script-src 'self' https://js.stripe.com (was + 'unsafe-inline'). style-src keeps 'unsafe-inline' (inline style="" attributes remain in scope). Needs manual UI verification: automated tests cover template rendering and server behavior, not client-side click/change/submit wiring.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 01:51 UTC
Signed with PGP, not checked
Commit: ee9853ebb5d21fb3b5deea869bac2dd4bb4614a3
Parent: 64dbc1d
75 files changed, +806 insertions, -238 deletions
@@ -376,9 +376,14 @@
376 376 // user-pages host, so that origin must be a permitted frame source.
377 377 let scheme = if state.config.host_url.starts_with("https") { "https" } else { "http" };
378 378 let user_pages_origin = format!("{scheme}://{}", state.config.user_pages_host);
379 + // script-src is 'self' (+ Stripe) with NO 'unsafe-inline': all inline
380 + // on*/hx-on handlers were moved to delegated listeners in static/*.js
381 + // (the data-action / data-hx-* dispatchers in mnw.js), so any injected
382 + // markup can no longer execute script. style-src keeps 'unsafe-inline'
383 + // because inline style="" attributes are still used throughout.
379 384 let csp = format!(
380 385 "default-src 'self'; \
381 - script-src 'self' 'unsafe-inline' https://js.stripe.com; \
386 + script-src 'self' https://js.stripe.com; \
382 387 style-src 'self' 'unsafe-inline'; \
383 388 img-src 'self' data: https:; \
384 389 font-src 'self'; \
@@ -24,3 +24,13 @@
24 24 const rect = e.currentTarget.getBoundingClientRect();
25 25 audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
26 26 }
27 +
28 + // Wire the play/seek controls without inline on* handlers (the embed page runs
29 + // under the same CSP that forbids script-src 'unsafe-inline'). This page loads
30 + // only this file, not mnw.js's data-action dispatcher, so bind directly.
31 + document.addEventListener('DOMContentLoaded', function () {
32 + var play = document.getElementById('play');
33 + if (play) play.addEventListener('click', togglePlay);
34 + var bar = document.getElementById('progress-bar');
35 + if (bar) bar.addEventListener('click', seek);
36 + });
@@ -674,3 +674,181 @@
674 674 window.prompt('Copy this link:', url);
675 675 }
676 676 });
677 +
678 + /* ===========================================
679 + ACTION DISPATCHER — delegated on* replacement
680 + =========================================== *
681 + * Lets templates drop inline on* handlers so the CSP can drop
682 + * script-src 'unsafe-inline'. An element declares behavior via a data-*
683 + * attribute whose value is either a built-in verb or a global function name:
684 + *
685 + * data-action -> click
686 + * data-change -> change
687 + * data-input -> input
688 + * data-submit -> submit (preventDefault is automatic for submit)
689 + *
690 + * Modifiers (presence of the attribute): data-prevent -> preventDefault,
691 + * data-stop -> stopPropagation. Applied before the verb runs.
692 + *
693 + * Built-in verbs operate on data-target (one or more element ids, space
694 + * separated); data-href supplies a URL:
695 + * show / hide / toggle -> classList remove/add/toggle 'hidden' on target(s)
696 + * click -> .click() the target(s)
697 + * remove -> .remove() the target(s)
698 + * remove-self -> .remove() the dispatching element
699 + * nav -> window.location.href = data-href
700 + *
701 + * Any other value is treated as a global function name and invoked as
702 + * fn.apply(element, args) with `this` === the element and args drawn from
703 + * data-arg / data-arg2 (both optional). So a former `fn()` becomes
704 + * data-action="fn"; `fn('x')` becomes data-action="fn" data-arg="x"; and a
705 + * former `fn(this)` becomes data-action="fn" with fn reading `this`.
706 + */
707 + (function () {
708 + function targets(el) {
709 + var raw = el.getAttribute('data-target');
710 + if (!raw) return [];
711 + return raw.split(/\s+/).filter(Boolean).map(function (id) {
712 + return document.getElementById(id);
713 + }).filter(Boolean);
714 + }
715 +
716 + var BUILTINS = {
717 + show: function (el) { targets(el).forEach(function (t) { t.classList.remove('hidden'); }); },
718 + hide: function (el) { targets(el).forEach(function (t) { t.classList.add('hidden'); }); },
719 + toggle: function (el) { targets(el).forEach(function (t) { t.classList.toggle('hidden'); }); },
720 + click: function (el) { targets(el).forEach(function (t) { t.click(); }); },
721 + remove: function (el) { targets(el).forEach(function (t) { t.remove(); }); },
722 + 'remove-self': function (el) { el.remove(); }
723 + };
724 +
725 + function run(el, verb, evt) {
726 + if (el.hasAttribute('data-prevent')) evt.preventDefault();
727 + if (el.hasAttribute('data-stop')) evt.stopPropagation();
728 + if (verb === 'nav') {
729 + var href = el.getAttribute('data-href');
730 + if (href) window.location.href = href;
731 + return;
732 + }
733 + if (BUILTINS[verb]) { BUILTINS[verb](el, evt); return; }
734 + var fn = window[verb];
735 + if (typeof fn !== 'function') {
736 + if (window.console) console.warn('data-action: no global function', verb);
737 + return;
738 + }
739 + var args = [];
740 + if (el.hasAttribute('data-arg')) args.push(el.getAttribute('data-arg'));
741 + if (el.hasAttribute('data-arg2')) args.push(el.getAttribute('data-arg2'));
742 + fn.apply(el, args);
743 + }
744 +
745 + function listen(eventName, attr, autoPrevent) {
746 + document.addEventListener(eventName, function (evt) {
747 + var el = evt.target.closest('[' + attr + ']');
748 + if (!el) return;
749 + if (autoPrevent) evt.preventDefault();
750 + run(el, el.getAttribute(attr), evt);
751 + });
752 + }
753 +
754 + listen('click', 'data-action', false);
755 + listen('change', 'data-change', false);
756 + listen('input', 'data-input', false);
757 + listen('submit', 'data-submit', true);
758 + })();
759 +
760 + /* ===========================================
761 + HTMX CALLBACK DISPATCHER — hx-on:: replacement
762 + =========================================== *
763 + * Replaces inline `hx-on::after-request` / `hx-on:htmx:config-request`
764 + * attributes (which are inline scripts and would keep script-src pinned to
765 + * 'unsafe-inline') with delegated listeners driven by data attributes.
766 + *
767 + * On the element that fired the request (evt.detail.elt):
768 + * data-hx-always behavior name that runs regardless of outcome
769 + * data-hx-always-click=ID .click() that element id regardless of outcome
770 + * ...and, only when the request succeeded (evt.detail.successful):
771 + * data-hx-reset this.reset() (form)
772 + * data-hx-click=ID .click() the element with that id
773 + * data-hx-get=URL + data-hx-target=SEL htmx.ajax('GET', URL, SEL)
774 + * data-hx-nav=URL window.location.href = URL
775 + * data-hx-reload window.location.reload()
776 + * data-hx-toast=MSG (+ data-hx-toast-type) showToast(MSG, type)
777 + * data-hx-behavior=NAME run a named behavior (custom multi-step cases)
778 + *
779 + * configRequest (before send):
780 + * data-hx-config="publish-at-iso" normalize publish_at to ISO 8601
781 + */
782 + (function () {
783 + function byId(id) { return id && document.getElementById(id); }
784 +
785 + function refreshProfileTab() {
786 + var t = document.getElementById('settings-body');
787 + if (t) {
788 + htmx.ajax('GET', '/dashboard/tabs/profile', { target: t, swap: 'innerHTML' });
789 + } else {
790 + var b = document.getElementById('tab-profile');
791 + if (b) b.click();
792 + }
793 + }
794 +
795 + var BEHAVIORS = {
796 + 'mark-added': function (el) { el.textContent = 'Added'; el.disabled = true; },
797 + // Faithful to the original inline handler: the row fades only on success,
798 + // but the label flips to 'Added' unconditionally. Registered under
799 + // data-hx-always so it sees both outcomes.
800 + 'fade-row-added': function (el, evt) {
801 + if (evt.detail.successful) {
802 + var tr = el.closest('tr');
803 + if (tr) tr.classList.add('is-faded');
804 + }
805 + el.textContent = 'Added';
806 + },
807 + 'refresh-profile': function () { refreshProfileTab(); },
808 + 'refresh-profile-if-verified': function (el, evt) {
809 + if (evt.detail.xhr && evt.detail.xhr.responseText.indexOf('verified successfully') !== -1) {
810 + refreshProfileTab();
811 + }
812 + }
813 + };
814 +
815 + document.body.addEventListener('htmx:afterRequest', function (evt) {
816 + var el = evt.detail.elt;
817 + if (!el || !el.getAttribute) return;
818 +
819 + var always = el.getAttribute('data-hx-always');
820 + if (always && BEHAVIORS[always]) BEHAVIORS[always](el, evt);
821 + if (el.hasAttribute('data-hx-always-click')) {
822 + var ac = byId(el.getAttribute('data-hx-always-click'));
823 + if (ac) ac.click();
824 + }
825 +
826 + if (!evt.detail.successful) return;
827 +
828 + if (el.hasAttribute('data-hx-reset') && typeof el.reset === 'function') el.reset();
829 + if (el.hasAttribute('data-hx-click')) {
830 + var t = byId(el.getAttribute('data-hx-click'));
831 + if (t) t.click();
832 + }
833 + if (el.hasAttribute('data-hx-get')) {
834 + var url = el.getAttribute('data-hx-get');
835 + var target = el.getAttribute('data-hx-target');
836 + if (url && target) htmx.ajax('GET', url, target);
837 + }
838 + if (el.hasAttribute('data-hx-nav')) window.location.href = el.getAttribute('data-hx-nav');
839 + if (el.hasAttribute('data-hx-reload')) window.location.reload();
840 + if (el.hasAttribute('data-hx-toast')) {
841 + showToast(el.getAttribute('data-hx-toast'), el.getAttribute('data-hx-toast-type') || 'info');
842 + }
843 + var beh = el.getAttribute('data-hx-behavior');
844 + if (beh && BEHAVIORS[beh]) BEHAVIORS[beh](el, evt);
845 + });
846 +
847 + document.body.addEventListener('htmx:configRequest', function (evt) {
848 + var el = evt.detail.elt;
849 + if (el && el.getAttribute && el.getAttribute('data-hx-config') === 'publish-at-iso') {
850 + var v = evt.detail.parameters.publish_at;
851 + if (v) evt.detail.parameters.publish_at = new Date(v).toISOString();
852 + }
853 + });
854 + })();
@@ -35,8 +35,8 @@
35 35 <a href="/changelog">Changelog</a>
36 36 <a href="mailto:info@makenot.work">Contact</a>
37 37 <a href="/health" title="Service status and uptime">Status</a>
38 - <a href="#" onclick="event.preventDefault(); showWhatsNewModal();">What's new</a>
39 - <a href="#" onclick="event.preventDefault(); toggleShortcutsHelp();" title="Keyboard shortcuts (?)">Shortcuts</a>
38 + <a href="#" data-prevent data-action="showWhatsNewModal">What's new</a>
39 + <a href="#" data-prevent data-action="toggleShortcutsHelp" title="Keyboard shortcuts (?)">Shortcuts</a>
40 40 </div>
41 41 <p>&copy; 2026 Make Creative, LLC</p>
42 42 </footer>
@@ -44,7 +44,14 @@
44 44 <!-- Toast notification container -->
45 45 <div id="notifications" class="toast-container" role="alert" aria-live="polite"></div>
46 46
47 - <script src="/static/mnw.js?v=0531"></script>
47 + <script src="/static/mnw.js?v=0701"></script>
48 + <!-- Delegated on* handlers, extracted from inline attributes so the CSP can
49 + drop script-src 'unsafe-inline'. Loaded globally after mnw.js (which
50 + defines the data-action dispatcher). -->
51 + <script src="/static/actions-pages.js?v=0701"></script>
52 + <script src="/static/actions-partials.js?v=0701"></script>
53 + <script src="/static/actions-tabs.js?v=0701"></script>
54 + <script src="/static/actions-dashboards.js?v=0701"></script>
48 55 <script src="/static/carousel.js?v=0605"></script>
49 56 <script src="/static/collections.js?v=0514"></script>
50 57 <script src="/static/synckit-billing.js?v=0620"></script>
@@ -86,7 +86,7 @@
86 86 </section>
87 87
88 88 <form class="cp-reset" method="post" action="{{ base_path }}/reset"
89 - onsubmit="return confirm('Reset this page to the platform default? This clears your custom HTML and CSS.');">
89 + data-submit="confirmResetPage">
90 90 {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
91 91 <button type="submit" class="btn-danger">Reset to default</button>
92 92 </form>
@@ -10,7 +10,7 @@
10 10 <div class="breadcrumb">
11 11 <a href="/dashboard">Dashboard</a> /
12 12 <a href="/dashboard/project/{{ project_slug }}">{{ project_slug }}</a> /
13 - <a href="/dashboard/project/{{ project_slug }}" onclick="setTimeout(function(){document.getElementById('tab-blog').click()},100)">Blog</a> /
13 + <a href="/dashboard/project/{{ project_slug }}" data-action="blogTabNav">Blog</a> /
14 14 {% if editing %}Edit{% else %}New Post{% endif %}
15 15 </div>
16 16
@@ -37,7 +37,7 @@
37 37 <div class="form-group">
38 38 <label for="post-body">Content (Markdown)</label>
39 39 <textarea id="post-body" rows="20" class="blog-editor-input input--sm w-full" placeholder="Write your post in Markdown...">{{ post_body }}</textarea>
40 - <button type="button" class="btn-secondary blog-editor-media-btn" onclick="mediaPickerOpen('post-body')">Insert Image</button>
40 + <button type="button" class="btn-secondary blog-editor-media-btn" data-action="mediaPickerOpen" data-arg="post-body">Insert Image</button>
41 41 </div>
42 42 {% if is_changelog_project %}
43 43 <div class="form-group blog-editor-landing-toggle">
@@ -51,7 +51,7 @@
51 51 <div class="blog-editor-actions">
52 52 <button class="btn-primary" id="save-draft-btn">{% if editing %}Save as Draft{% else %}Save Draft{% endif %}</button>
53 53 <button class="btn-secondary" id="publish-btn">{% if editing && post_is_published %}Update{% else %}Publish{% endif %}</button>
54 - <a href="/dashboard/project/{{ project_slug }}" onclick="setTimeout(function(){document.getElementById('tab-blog').click()},100)">
54 + <a href="/dashboard/project/{{ project_slug }}" data-action="blogTabNav">
55 55 <button class="btn-secondary" type="button">Cancel</button>
56 56 </a>
57 57 </div>
@@ -17,7 +17,7 @@
17 17 <h1 class="page-title">Export Your Data</h1>
18 18 <p class="subtitle">Download your content, projects, and transaction history.</p>
19 19 </div>
20 - <button class="btn-primary export-all-btn" id="export-all-btn" onclick="exportAll()">
20 + <button class="btn-primary export-all-btn" id="export-all-btn" data-action="exportAll">
21 21 Export All
22 22 </button>
23 23 </header>
@@ -33,7 +33,7 @@
33 33 <span>{{ item.sales_count }} sales</span>
34 34 <button class="btn-secondary small dashboard-item-share-btn"
35 35 title="Copy item ID for use in embeds or integrations"
36 - onclick="navigator.clipboard.writeText('{{ item.id }}'); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy Item ID', 1500);">Copy Item ID</button>
36 + data-action="onCopyItemId" data-arg="{{ item.id }}">Copy Item ID</button>
37 37 </div>
38 38 </header>
39 39
@@ -48,7 +48,7 @@
48 48 hx-target="#tab-content"
49 49 hx-swap="innerHTML"
50 50 hx-indicator="#tab-spinner"
51 - onclick="setActiveTab(this)">Overview</button>
51 + data-action="onSetActiveTab">Overview</button>
52 52 <button class="tab"
53 53 role="tab"
54 54 aria-selected="false"
@@ -59,7 +59,7 @@
59 59 hx-target="#tab-content"
60 60 hx-swap="innerHTML"
61 61 hx-indicator="#tab-spinner"
62 - onclick="setActiveTab(this)">Details</button>
62 + data-action="onSetActiveTab">Details</button>
63 63 <button class="tab"
64 64 role="tab"
65 65 aria-selected="false"
@@ -70,7 +70,7 @@
70 70 hx-target="#tab-content"
71 71 hx-swap="innerHTML"
72 72 hx-indicator="#tab-spinner"
73 - onclick="setActiveTab(this)">Pricing</button>
73 + data-action="onSetActiveTab">Pricing</button>
74 74 {% if item.item_type != "bundle" %}
75 75 <button class="tab"
76 76 role="tab"
@@ -82,7 +82,7 @@
82 82 hx-target="#tab-content"
83 83 hx-swap="innerHTML"
84 84 hx-indicator="#tab-spinner"
85 - onclick="setActiveTab(this)">Files</button>
85 + data-action="onSetActiveTab">Files</button>
86 86 {% endif %}
87 87 <button class="tab"
88 88 role="tab"
@@ -94,7 +94,7 @@
94 94 hx-target="#tab-content"
95 95 hx-swap="innerHTML"
96 96 hx-indicator="#tab-spinner"
97 - onclick="setActiveTab(this)">Sales</button>
97 + data-action="onSetActiveTab">Sales</button>
98 98 <span id="tab-spinner" class="htmx-indicator tab-spinner-indicator" aria-live="polite"> Loading...</span>
99 99 </div>
100 100
@@ -40,7 +40,7 @@
40 40 hx-target="#tab-content"
41 41 hx-swap="innerHTML"
42 42 hx-indicator="#tab-spinner"
43 - onclick="setActiveTab(this)">Overview</button>
43 + data-action="onSetActiveTab">Overview</button>
44 44 <button class="tab"
45 45 role="tab"
46 46 aria-selected="false"
@@ -51,7 +51,7 @@
51 51 hx-target="#tab-content"
52 52 hx-swap="innerHTML"
53 53 hx-indicator="#tab-spinner"
54 - onclick="setActiveTab(this)">Content</button>
54 + data-action="onSetActiveTab">Content</button>
55 55 <button class="tab"
56 56 role="tab"
57 57 aria-selected="false"
@@ -62,7 +62,7 @@
62 62 hx-target="#tab-content"
63 63 hx-swap="innerHTML"
64 64 hx-indicator="#tab-spinner"
65 - onclick="setActiveTab(this)">Analytics</button>
65 + data-action="onSetActiveTab">Analytics</button>
66 66 <button class="tab"
67 67 role="tab"
68 68 aria-selected="false"
@@ -73,7 +73,7 @@
73 73 hx-target="#tab-content"
74 74 hx-swap="innerHTML"
75 75 hx-indicator="#tab-spinner"
76 - onclick="setActiveTab(this)">Monetization</button>
76 + data-action="onSetActiveTab">Monetization</button>
77 77 {% if git_enabled %}
78 78 <button class="tab"
79 79 role="tab"
@@ -85,7 +85,7 @@
85 85 hx-target="#tab-content"
86 86 hx-swap="innerHTML"
87 87 hx-indicator="#tab-spinner"
88 - onclick="setActiveTab(this)">Code</button>
88 + data-action="onSetActiveTab">Code</button>
89 89 {% endif %}
90 90 {% if synckit_enabled %}
91 91 <button class="tab"
@@ -98,7 +98,7 @@
98 98 hx-target="#tab-content"
99 99 hx-swap="innerHTML"
100 100 hx-indicator="#tab-spinner"
101 - onclick="setActiveTab(this)">Cloud Sync</button>
101 + data-action="onSetActiveTab">Cloud Sync</button>
102 102 {% endif %}
103 103 <button class="tab"
104 104 role="tab"
@@ -110,7 +110,7 @@
110 110 hx-target="#tab-content"
111 111 hx-swap="innerHTML"
112 112 hx-indicator="#tab-spinner"
113 - onclick="setActiveTab(this)">Settings</button>
113 + data-action="onSetActiveTab">Settings</button>
114 114 <span id="tab-spinner" class="htmx-indicator tab-spinner-indicator" aria-live="polite"> Loading...</span>
115 115 </div>
116 116
@@ -21,7 +21,7 @@
21 21 <p>You can:</p>
22 22 <div class="account-status-actions">
23 23 <form hx-post="/api/users/me/reactivate" hx-swap="none"
24 - onsubmit="setTimeout(function(){ window.location.reload(); }, 500)">
24 + data-submit="reactivateReload">
25 25 <button type="submit" class="btn-primary">Reactivate Account</button>
26 26 </form>
27 27 <a href="/dashboard/export" class="button">Export Data</a>
@@ -83,7 +83,7 @@
83 83 hx-target="#tab-content"
84 84 hx-swap="innerHTML"
85 85 hx-indicator="#tab-spinner"
86 - onclick="setActiveTab(this)">Support</button>
86 + data-action="onSetActiveTab">Support</button>
87 87 <span id="tab-spinner" class="htmx-indicator tab-spinner" aria-live="polite"> Loading...</span>
88 88 </div>
89 89
@@ -106,7 +106,7 @@
106 106 hx-target="#tab-content"
107 107 hx-swap="innerHTML"
108 108 hx-indicator="#tab-spinner"
109 - onclick="setActiveTab(this)">Projects</button>
109 + data-action="onSetActiveTab">Projects</button>
110 110 {% endif %}{% endif %}
111 111 <button class="tab{% if let Some(su) = session_user %}{% if !su.can_create_projects %} is-selected{% endif %}{% endif %}"
112 112 role="tab"
@@ -118,7 +118,7 @@
118 118 hx-target="#tab-content"
119 119 hx-swap="innerHTML"
120 120 hx-indicator="#tab-spinner"
121 - onclick="setActiveTab(this)">Payments</button>
121 + data-action="onSetActiveTab">Payments</button>
122 122 {% if let Some(su) = session_user %}{% if su.can_create_projects %}
123 123 <button class="tab"
124 124 role="tab"
@@ -130,7 +130,7 @@
130 130 hx-target="#tab-content"
131 131 hx-swap="innerHTML"
132 132 hx-indicator="#tab-spinner"
133 - onclick="setActiveTab(this)">Analytics</button>
133 + data-action="onSetActiveTab">Analytics</button>
134 134 {% endif %}{% endif %}
135 135 <button class="tab"
136 136 role="tab"
@@ -142,7 +142,7 @@
142 142 hx-target="#tab-content"
143 143 hx-swap="innerHTML"
144 144 hx-indicator="#tab-spinner"
145 - onclick="setActiveTab(this)">Settings</button>
145 + data-action="onSetActiveTab">Settings</button>
146 146 <button class="tab"
147 147 role="tab"
148 148 aria-selected="false"
@@ -153,7 +153,7 @@
153 153 hx-target="#tab-content"
154 154 hx-swap="innerHTML"
155 155 hx-indicator="#tab-spinner"
156 - onclick="setActiveTab(this)">Support</button>
156 + data-action="onSetActiveTab">Support</button>
157 157 <span id="tab-spinner" class="htmx-indicator tab-spinner" aria-live="polite"> Loading...</span>
158 158 </div>
159 159