Changeset f7d142f in Klonkt


Ignore:
Timestamp:
06/28/2026 03:36:22 PM (2 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
81bb9c5
Parents:
127f87e
Message:

chore(csp): drop the last script unsafe-inline + add Permissions-Policy

Move every inline on* handler to a shared delegated data-* handler, so script-src-attr
can be 'none' instead of 'unsafe-inline'. Add a Permissions-Policy header disabling
camera/microphone/geolocation/Topics (embed features left at default).

  • views/shell.ejs — shared delegated submit/change/click/error handler (data-confirm, data-autosubmit, data-lang-switch, data-selectall, data-back, data-fallback)
  • views/{pages,partials}/*.ejs — 18 inline on* handlers -> data-* attributes (14 files)
  • server.js — scriptSrcAttr 'unsafe-inline' -> 'none'; Permissions-Policy header
Location:
src
Files:
16 edited

Legend:

Unmodified
Added
Removed
  • src/server.js

    r127f87e rf7d142f  
    109109        (req, res) => `'nonce-${res.locals.cspNonce}'`,
    110110      ],
    111       // Helmet's default sets script-src-attr to 'none', which blocks ALL inline
    112       // event handlers (onchange/onclick/onsubmit) — causing e.g. the avatar
    113       // upload (<input onchange="this.form.submit()">) and the role dropdown to
    114       // silently do nothing. We explicitly allow inline handlers, consistent with
    115       // the already-allowed inline <script> above.
    116       scriptSrcAttr: ["'unsafe-inline'"],
     111      // No inline event handlers anywhere: every on* attribute was moved to a
     112      // delegated data-* handler (the shared script in shell.ejs), so inline
     113      // handlers are blocked entirely — this closes the last 'unsafe-inline' in
     114      // the script directives.
     115      scriptSrcAttr: ["'none'"],
    117116      styleSrc: ["'self'", "'unsafe-inline'"],
    118117      // blob: required for the image editor (Cropper) — it displays the chosen
     
    144143  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
    145144}));
     145
     146// Permissions-Policy: disable powerful features Klonkt never uses (camera, microphone,
     147// geolocation) and opt out of the Topics API. Features that embeds legitimately need
     148// (autoplay, fullscreen, encrypted-media, picture-in-picture) are left at their default
     149// allowlist, so YouTube/Spotify/SoundCloud players keep working.
     150app.use((req, res, next) => {
     151  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), browsing-topics=()');
     152  next();
     153});
    146154
    147155app.set('view engine', 'ejs');
  • src/views/pages/admin-audio.ejs

    r127f87e rf7d142f  
    121121                        style="<%= t.downloadable ? 'color:var(--accent,#6b8f71)' : 'opacity:.5' %>">⬇</button>
    122122              <% } %>
    123               <form action="/admin/audio/<%= t.id %>/delete" method="post" onsubmit="return confirm('<%= _aaudDelConfirm %>')" class="ax-track-delete">
     123              <form action="/admin/audio/<%= t.id %>/delete" method="post" data-confirm="<%= _aaudDelConfirm %>" class="ax-track-delete">
    124124                <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="<%= _aaudDelete %>" title="<%= _aaudDelete %>">🗑</button>
    125125              </form>
     
    135135      <h2 style="margin:0 0 .4rem">🔊 <%= t('aaud.embed_player') %></h2>
    136136      <p style="opacity:.75;font-size:.9rem;margin:0 0 .6rem"><%= t('aaud.embed_hint') %></p>
    137       <textarea readonly rows="3" onclick="this.select()" style="width:100%;font-family:monospace;font-size:12px;padding:10px;border-radius:8px;border:1px solid var(--rule);background:var(--paper-2);color:inherit;">&lt;iframe src="<%= embedUrl %>" width="100%" height="420" frameborder="0" loading="lazy" style="border-radius:12px"&gt;&lt;/iframe&gt;</textarea>
     137      <textarea readonly rows="3" data-selectall style="width:100%;font-family:monospace;font-size:12px;padding:10px;border-radius:8px;border:1px solid var(--rule);background:var(--paper-2);color:inherit;">&lt;iframe src="<%= embedUrl %>" width="100%" height="420" frameborder="0" loading="lazy" style="border-radius:12px"&gt;&lt;/iframe&gt;</textarea>
    138138      <p style="margin:.6rem 0 0"><a class="btn" href="<%= embedUrl %>" target="_blank">▶ <%= t('aaud.preview_player') %></a></p>
    139139    </section>
  • src/views/pages/admin-newsletter.ejs

    r127f87e rf7d142f  
    2424
    2525  <% var _sendConfirm = t('anews.send_confirm', { n: c.confirmed }); %>
    26   <form method="POST" action="<%= siteUrlBase %>/admin/newsletter/send" class="anl-form">
     26  <form method="POST" action="<%= siteUrlBase %>/admin/newsletter/send" class="anl-form" data-confirm="<%= _sendConfirm %>">
    2727    <label><%= t('anews.subject') %>
    2828      <input type="text" name="subject" required maxlength="200" value="<%= (typeof nlSubject!=='undefined')?nlSubject:'' %>" placeholder="<%= t('anews.subject_ph') %>">
     
    3131      <textarea name="body" rows="10" required placeholder="<%= t('anews.body_ph') %>"><%= (typeof nlBody!=='undefined')?nlBody:'' %></textarea>
    3232    </label>
    33     <button type="submit" class="anl-send" <%= smtp ? '' : 'disabled' %> onclick="return confirm('<%= _sendConfirm %>')"><%= t('anews.send_btn', { n: c.confirmed }) %></button>
     33    <button type="submit" class="anl-send" <%= smtp ? '' : 'disabled' %>><%= t('anews.send_btn', { n: c.confirmed }) %></button>
    3434  </form>
    3535
  • src/views/pages/admin-shows.ejs

    r127f87e rf7d142f  
    1414  <form method="POST" action="<%= siteUrlBase %>/admin/shows/toggle" style="margin:0 0 14px">
    1515    <label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-weight:600">
    16       <input type="checkbox" name="enabled" value="1" <%= agOn ? 'checked' : '' %> onchange="this.form.submit()" style="width:18px;height:18px;accent-color:var(--accent)">
     16      <input type="checkbox" name="enabled" value="1" <%= agOn ? 'checked' : '' %> data-autosubmit style="width:18px;height:18px;accent-color:var(--accent)">
    1717      <span><%= t('ashow.show_toggle') %> <small style="font-weight:400;opacity:.7"><%= t('ashow.show_toggle_hint') %></small></span>
    1818    </label>
     
    4545          <span class="ash-when"><%= s.date %><% if (s.time) { %> <%= s.time %><% } %></span>
    4646          <span class="ash-where"><%= s.city %><% if (s.venue) { %> — <%= s.venue %><% } %></span>
    47           <form method="POST" action="<%= siteUrlBase %>/admin/shows/<%= s.id %>/delete" onsubmit="return confirm('<%= _delConfirm %>')" style="margin:0">
     47          <form method="POST" action="<%= siteUrlBase %>/admin/shows/<%= s.id %>/delete" data-confirm="<%= _delConfirm %>" style="margin:0">
    4848            <button type="submit" class="ash-del">🗑</button>
    4949          </form>
  • src/views/pages/admin-sites.ejs

    r127f87e rf7d142f  
    9393            <% if (!s.is_primary && (typeof canMutate === 'undefined' || canMutate)) { %>
    9494              <form method="post" action="/admin/sites/<%= s.slug %>/make-primary" style="margin:0;display:inline"
    95                     onsubmit="return confirm('<%= _s_makeprim_c %>')">
     95                    data-confirm="<%= _s_makeprim_c %>">
    9696                <button type="submit" class="ax-icon-btn" aria-label="<%= _s_makeprim %>" title="<%= _s_makeprim_t %>">★</button>
    9797              </form>
     
    9999            <a href="/admin/sites/<%= s.slug %>/edit" class="ax-icon-btn" aria-label="<%= _s_edit %>" title="<%= _s_edit %>">✎</a>
    100100            <form method="post" action="/admin/sites/<%= s.slug %>/delete" class="ax-delete-form"
    101                   onsubmit="return confirm('<%= _s_del_c %>')">
     101                  data-confirm="<%= _s_del_c %>">
    102102              <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="<%= _s_del %>" title="<%= _s_del %>">🗑</button>
    103103            </form>
  • src/views/pages/admin-updates.ejs

    r127f87e rf7d142f  
    4040    <% if (canCheck && canSelfUpdate) { %>
    4141    <form method="post" action="/admin/updates/run" class="set-form"
    42           onsubmit="return confirm('<%= t('aupd.run_confirm') %>');">
     42          data-confirm="<%= t('aupd.run_confirm') %>">
    4343      <button type="submit" class="btn btn-primary">
    4444        <%= upToDate ? '↻ ' + t('aupd.redeploy') : '⬆ ' + t('aupd.update_now') %>
  • src/views/pages/admin-users.ejs

    r127f87e rf7d142f  
    8080              <label class="ax-role">
    8181                <span><%= _u_rol %></span>
    82                 <select name="role" onchange="this.form.submit()" <% if (u.id === user.id || !canMutate) { %>disabled<% } %>>
     82                <select name="role" data-autosubmit <% if (u.id === user.id || !canMutate) { %>disabled<% } %>>
    8383                  <option value="kijker" <%= u.role === 'kijker' ? 'selected' : '' %>><%= _u_r_kijker %></option>
    8484                  <option value="member" <%= u.role === 'member' ? 'selected' : '' %>><%= _u_r_member %></option>
     
    9494                   : ''; %>
    9595              <form method="post" action="/admin/users/<%= u.id %>/delete" class="ax-delete-form"
    96                     onsubmit="return confirm('<%= t('ausr.del_confirm', { name: u.username }) %> <%= _warn %><%= t('ausr.del_undo') %>')">
     96                    data-confirm="<%= t('ausr.del_confirm', { name: u.username }) %> <%= _warn %><%= t('ausr.del_undo') %>">
    9797                <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="<%= _u_del %>" title="<%= _u_del %>">🗑</button>
    9898              </form>
  • src/views/pages/authorize-interaction.ejs

    r127f87e rf7d142f  
    3737              <a class="fedi-goto-btn" href="<%= m.in_reply_to %>" target="_blank" rel="nofollow noopener"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M7 7h10v10"/></svg> <%= t('fedi.goto_post') %></a>
    3838              <% } %>
    39               <form method="post" action="/fediverse/<%= m.id %>/delete" class="fedi-del-form" onsubmit="return confirm('<%= t('fedi.delete_confirm') %>')">
     39              <form method="post" action="/fediverse/<%= m.id %>/delete" class="fedi-del-form" data-confirm="<%= t('fedi.delete_confirm') %>">
    4040                <button type="submit" class="fedi-del-btn"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <%= t('comments.delete') %></button>
    4141              </form>
  • src/views/pages/following.ejs

    r127f87e rf7d142f  
    2828          <form method="post" action="/news/autoboost" class="tl-foll-ab" title="<%= t('tl.autoboost_hint') %>">
    2929            <input type="hidden" name="actor_uri" value="<%= f.actor_uri %>">
    30             <label><input type="checkbox" name="auto_boost" value="1" <%= f.auto_boost ? 'checked' : '' %> onchange="this.form.submit()"> ✨ <%= t('tl.autoboost') %></label>
     30            <label><input type="checkbox" name="auto_boost" value="1" <%= f.auto_boost ? 'checked' : '' %> data-autosubmit> ✨ <%= t('tl.autoboost') %></label>
    3131          </form>
    3232          <form method="post" action="/news/unfollow"><input type="hidden" name="actor_uri" value="<%= f.actor_uri %>"><button type="submit" class="tl-unfollow"><%= t('tl.unfollow') %></button></form>
  • src/views/pages/news.ejs

    r127f87e rf7d142f  
    135135            <form method="post" action="/news/boost" class="tl-act-form tl-react-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-boost<%= p.boosted ? ' is-on' : '' %>" title="<%= p.boosted ? t('tl.unboost') : t('fedi.boosts') %>" aria-label="<%= p.boosted ? t('tl.unboost') : t('fedi.boosts') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></button></form>
    136136            <button type="button" class="tl-act tl-act-reply fedi-remote-reply-btn" data-fedi-uri="<%= p.id %>" data-fedi-ph="<%= t('fedi.remote_ph') %>" title="<%= t('fedi.remote_reply') %>" aria-label="<%= t('fedi.remote_reply') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg></button>
    137             <form method="post" action="/blocking/add" class="tl-act-form" onsubmit="return confirm('<%= t('tl.block') %>?');"><input type="hidden" name="target" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-block" title="<%= t('tl.block') %>" aria-label="<%= t('tl.block') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/></svg></button></form>
     137            <form method="post" action="/blocking/add" class="tl-act-form" data-confirm="<%= t('tl.block') %>?"><input type="hidden" name="target" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-block" title="<%= t('tl.block') %>" aria-label="<%= t('tl.block') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/></svg></button></form>
    138138          </div>
    139139        </li>
  • src/views/pages/post.ejs

    r127f87e rf7d142f  
    6868      <% } %>
    6969      <% if (permissions.canDeletePost(user, post, site)) { %>
    70         <form method="post" action="<%= _base %>/posts/<%= post.slug %>/delete" style="display:inline" onsubmit="return confirm('Delete this post?')">
     70        <form method="post" action="<%= _base %>/posts/<%= post.slug %>/delete" style="display:inline" data-confirm="Delete this post?">
    7171          <button type="submit" class="btn btn-danger">🗑 Delete</button>
    7272        </form>
  • src/views/pages/viewer-blocked.ejs

    r127f87e rf7d142f  
    88    </p>
    99    <div class="vb-actions">
    10       <button type="button" class="vb-btn vb-btn-primary" onclick="history.back()">← <%= t('vblk.back') %></button>
     10      <button type="button" class="vb-btn vb-btn-primary" data-back>← <%= t('vblk.back') %></button>
    1111      <a class="vb-btn" href="/"><%= t('vblk.to_home') %></a>
    1212    </div>
  • src/views/partials/fedi-node.ejs

    r127f87e rf7d142f  
    1414  <div class="comment-actions">
    1515    <% if (n.mine && typeof canManageSite !== 'undefined' && canManageSite && n.outboxId) { %>
    16       <form method="post" action="<%= _base %>/fediverse/<%= n.outboxId %>/delete" onsubmit="return confirm('<%= t('fedi.delete_confirm') %>')">
     16      <form method="post" action="<%= _base %>/fediverse/<%= n.outboxId %>/delete" data-confirm="<%= t('fedi.delete_confirm') %>">
    1717        <button type="submit" class="comment-delete-btn"><%= t('comments.delete') %></button>
    1818      </form>
  • src/views/partials/footer.ejs

    r127f87e rf7d142f  
    2222      <% if (typeof langs !== 'undefined' && langs && langs.length > 1) { %>
    2323        <select class="footer-lang" aria-label="<%= t('nav.language') %>"
    24                 onchange="location.href='/lang/'+this.value+'?r='+encodeURIComponent(location.pathname+location.search)">
     24                data-lang-switch>
    2525          <% langs.forEach(function(l){ %><option value="<%= l.code %>"<%= l.active ? ' selected' : '' %>><%= l.name %></option><% }); %>
    2626        </select>
  • src/views/partials/playlist-editor.ejs

    r127f87e rf7d142f  
    442442      const u = coverEl.value.trim();
    443443      coverThumb.innerHTML = u
    444         ? `<img src="${esc(u)}" alt="" onerror="this.parentElement.innerHTML='<span class=\\'pl-cover-empty\\'>⚠️</span>'">`
     444        ? `<img src="${esc(u)}" alt="" data-fallback>`
    445445        : `<span class="pl-cover-empty">🎨</span>`;
    446446    });
  • src/views/shell.ejs

    r127f87e rf7d142f  
    747747</script>
    748748
     749<script>
     750// Delegated replacements for inline on* handlers, so the CSP needs no
     751// script-src-attr 'unsafe-inline'. Document-level → also covers htmx-swapped content.
     752(function () {
     753  if (window.__pcmsHandlersWired) return; window.__pcmsHandlersWired = true;
     754  // Confirm before submitting a form that carries data-confirm.
     755  document.addEventListener('submit', function (e) {
     756    var f = e.target;
     757    if (f && f.dataset && f.dataset.confirm && !window.confirm(f.dataset.confirm)) e.preventDefault();
     758  });
     759  // Auto-submit a form / switch language when a <select> changes.
     760  document.addEventListener('change', function (e) {
     761    var el = e.target;
     762    if (!el || !el.dataset) return;
     763    if (el.dataset.autosubmit !== undefined && el.form) el.form.submit();
     764    else if (el.dataset.langSwitch !== undefined) {
     765      location.href = '/lang/' + encodeURIComponent(el.value) + '?r=' + encodeURIComponent(location.pathname + location.search);
     766    }
     767  });
     768  // Misc click helpers (select-all in a field, history-back button).
     769  document.addEventListener('click', function (e) {
     770    var el = e.target.closest && e.target.closest('[data-selectall],[data-back]');
     771    if (!el) return;
     772    if (el.dataset.selectall !== undefined && el.select) el.select();
     773    if (el.dataset.back !== undefined) { e.preventDefault(); history.back(); }
     774  });
     775  // Image fallback (the error event doesn't bubble → capture phase).
     776  document.addEventListener('error', function (e) {
     777    var el = e.target;
     778    if (el && el.tagName === 'IMG' && el.dataset && el.dataset.fallback !== undefined && el.parentElement) {
     779      el.parentElement.innerHTML = '<span class="pl-cover-empty">⚠️</span>';
     780    }
     781  }, true);
     782})();
     783</script>
     784
    749785<!-- Per-site custom footer HTML -->
    750786<% if (safeSite.custom_foot_html) { %>
Note: See TracChangeset for help on using the changeset viewer.