Changeset 07de464 in Klonkt


Ignore:
Timestamp:
06/27/2026 09:37:05 AM (2 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
f3551ec
Parents:
5421ce1
Message:

fix(csp): global frame-src (https:) for embeds, locked to 'self' on authorize_interaction

  • server.js — Helmet frameSrc → 'self' https: so embeds work in every context (feed, htmx/PWA, public pages); Robin chose global over per-domain.
  • middleware/render.js — renderPage now LOCKS frame-src down to 'self' on pages/authorize-interaction (untrusted remote content next to the interact buttons → no embeds/clickjacking); removed the now-unused per-domain embedOriginsFor helper.
Location:
src
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/middleware/render.js

    r5421ce1 r07de464  
    5252  return new Date(iso).toLocaleString('nl-NL', { timeZone: siteTimezone(), dateStyle: 'medium', timeStyle: 'short' });
    5353};
    54 
    55 // Cross-site Klonkt audio embeds (a followed/boosted site's /embed player) need their
    56 // origins in the DOCUMENT CSP frame-src — the per-/news injection is ignored once you
    57 // navigate there via htmx (the document's CSP governs, not the partial's). Collect the
    58 // distinct origins from the site's timeline (followed + boosted). Cached 60s.
    59 const _embedOriginsCache = new Map(); // slug -> { origins, exp }
    60 function embedOriginsFor(slug) {
    61   const now = Date.now();
    62   const c = _embedOriginsCache.get(slug);
    63   if (c && c.exp > now) return c.origins;
    64   let origins = [];
    65   try {
    66     const rows = db.prepare('SELECT DISTINCT url FROM ap_timeline WHERE slug = ? AND url IS NOT NULL').all(slug);
    67     const set = new Set();
    68     for (const r of rows) { try { set.add(new URL(r.url).origin); } catch { /* skip bad url */ } }
    69     origins = [...set];
    70   } catch { origins = []; }
    71   _embedOriginsCache.set(slug, { origins, exp: now + 60000 });
    72   return origins;
    73 }
    7454
    7555export async function renderPage(req, res, viewName, data = {}) {
     
    11494  const _isViewer = isViewer(_u);
    11595
    116   // Per-domain CSP: let a logged-in site manager frame the Klonkt players of the sites in
    117   // their timeline (followed + boosted) on the DOCUMENT, so cross-site embeds also work
    118   // after an htmx/PWA navigation. NEVER on authorize_interaction — that page shows
    119   // untrusted remote content next to the interact buttons, so no embeds / no frame-src
    120   // loosening there (clickjacking risk).
    121   try {
    122     if (_site && _site.slug && viewName !== 'pages/authorize-interaction' && _u && PermissionsService.canAdminSite(_u, _site)) {
    123       const origins = embedOriginsFor(_site.slug);
    124       if (origins.length) {
    125         const csp = res.getHeader('Content-Security-Policy');
    126         if (csp) res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${origins.join(' ')}`));
    127       }
    128     }
    129   } catch { /* CSP extension is best-effort */ }
     96  // Embeds are framed broadly (frame-src https: globally), EXCEPT on authorize_interaction:
     97  // that page renders untrusted remote content next to the interact buttons, so lock its
     98  // frame-src down to 'self' (no embeds → no clickjacking/overlay over the buttons).
     99  if (viewName === 'pages/authorize-interaction') {
     100    try {
     101      const csp = res.getHeader('Content-Security-Policy');
     102      if (csp) res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src [^;]*/i, "frame-src 'self'"));
     103    } catch { /* best-effort */ }
     104  }
    130105
    131106  // Who sees the "Admin" link? god/admin, a site owner (artist self-manage),
  • src/server.js

    r5421ce1 r07de464  
    125125      mediaSrc: ["'self'", "https:", "blob:"],
    126126      fontSrc: ["'self'"],
    127       frameSrc: [
    128         "'self'",
    129         "https://open.spotify.com",
    130         "https://w.soundcloud.com",
    131         "https://bandcamp.com",
    132         "https://embed.music.apple.com",
    133         "https://www.youtube-nocookie.com",
    134         "https://www.youtube.com",   // YouTube IFrame API sometimes creates a www.youtube.com iframe
    135         "https://player.vimeo.com",
    136       ],
     127      // Embeds (platform players + cross-site Klonkt audio players) are framed broadly:
     128      // ANY https origin, so embeds work in any context (feed, htmx/PWA nav, public pages).
     129      // The sensitive /authorize_interaction page tightens frame-src back to 'self' in
     130      // renderPage — it shows untrusted remote content next to the interact buttons.
     131      frameSrc: ["'self'", "https:"],
    137132    },
    138133  },
Note: See TracChangeset for help on using the changeset viewer.