Changeset 73abbfd in Klonkt


Ignore:
Timestamp:
06/28/2026 01:55:31 PM (2 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
be5d86c
Parents:
9910ba1
Message:

experiment(csp): strict script-src (nonce + strict-dynamic) — BETA canary

Drops 'unsafe-inline' + broad host sources from script-src; a per-request nonce is injected into
every <script> at render time and 'strict-dynamic' covers htmx-swapped + player-API scripts.
Testing on BETA only first — htmx nav / embeds may break under the strict policy; do NOT roll to
the fleet until validated.

Location:
src
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/middleware/render.js

    r9910ba1 r73abbfd  
    2121import ActivityPubService from '../services/ActivityPubService.js';
    2222import { audioEnabled as audioFeatureEnabled } from '../config/features.js';
     23
     24// Add the per-request CSP nonce to every <script> tag that doesn't already have one, so the
     25// strict script-src (nonce + 'strict-dynamic') allows them — including scripts in htmx
     26// partials. HTML-escaped "&lt;script" in rendered content (e.g. sanitized post bodies) won't
     27// match, so this only touches real tags.
     28function injectCspNonce(html, nonce) {
     29  if (!html || !nonce) return html;
     30  return String(html).replace(/<script(?![^>]*\snonce=)/gi, () => `<script nonce="${nonce}"`);
     31}
    2332import { PLATFORMS as PLATFORMS_CATALOG } from '../services/PlatformIcons.js';
    2433import { t as i18nT, resolveLang, SUPPORTED as LANGS, LANG_NAMES } from '../services/i18n.js';
     
    204213        );
    205214      } catch (e) { /* skip chrome OOB */ }
    206       return res.send(pageContent + oobChrome);
     215      return res.send(injectCspNonce(pageContent + oobChrome, res.locals.cspNonce));
    207216    }
    208217
    209     // Full: wrap content in shell
     218    // Full: wrap content in shell (rendered to a string so we can inject the CSP nonce).
    210219    locals.pageContent = pageContent;
    211     res.render('shell', locals);
     220    const shellHtml = await ejs.renderFile(path.join(VIEWS_DIR, 'shell.ejs'), locals, { async: false });
     221    res.send(injectCspNonce(shellHtml, res.locals.cspNonce));
    212222  } catch (err) {
    213223    console.error('[renderPage] Error rendering', viewName, err);
  • src/server.js

    r9910ba1 r73abbfd  
    9191const server = http.createServer(app);
    9292
     93// Per-request CSP nonce for the strict script-src (nonce + strict-dynamic). Must be set
     94// before helmet builds the CSP header below. The nonce is injected into every <script> tag
     95// at render time (see middleware/render.js injectCspNonce).
     96app.use((req, res, next) => { res.locals.cspNonce = crypto.randomBytes(16).toString('base64'); next(); });
     97
    9398app.use(helmet({
    9499  contentSecurityPolicy: {
    95100    directives: {
    96101      defaultSrc: ["'self'"],
     102      // Strict CSP: a per-request nonce + 'strict-dynamic' (no 'unsafe-inline', no broad host
     103      // sources — securityheaders/Observatory flag those). Trusted (nonce'd) scripts may load
     104      // further scripts, which covers htmx-swapped inline scripts AND the external player APIs
     105      // that embed-player.js injects (YouTube/SoundCloud/Spotify). The nonce is added to every
     106      // <script> tag at render time (middleware/render.js injectCspNonce).
    97107      scriptSrc: [
    98         "'self'",
    99         "'unsafe-inline'",
    100         // Our custom embeds (embed-player.js) load the OFFICIAL player APIs
    101         // from these hosts. Without this whitelist the CSP silently blocks them
    102         // (only a console error) and the embed player fails.
    103         "https://www.youtube.com",   // YouTube IFrame Player API (+ www-widgetapi.js)
    104         "https://s.ytimg.com",       // YouTube player assets
    105         "https://w.soundcloud.com",  // SoundCloud Widget API (api.js)
    106         "https://open.spotify.com",  // Spotify iFrame API (loader)
    107         "https://*.spotifycdn.com",  // Spotify iFrame API (real bundle: embed-cdn.spotifycdn.com)
     108        "'strict-dynamic'",
     109        (req, res) => `'nonce-${res.locals.cspNonce}'`,
    108110      ],
    109111      // Helmet's default sets script-src-attr to 'none', which blocks ALL inline
Note: See TracChangeset for help on using the changeset viewer.