jevstrudel.git / website / src / jev / sandboxPolicy.mjs
1// What the sandbox a listener's song plays in may do (sandbox.mjs), as
2// headers: one source for every place that serves it.
3//
4// - `pages/jev/sandbox.astro` puts the policy in a meta tag, so it holds on
5//   any static host.
6// - The build writes `_headers` (astro.config.mjs, `jevHeaders`), which
7//   Cloudflare applies to the deploy's static files: the page gets the
8//   policy as a header too, with `sandbox` (so even opened on its own, the
9//   page has an opaque origin) and `frame-ancestors` (only this site may
10//   frame it), which a meta tag cannot carry.
11// - The dev server sends the same headers (astro.config.mjs,
12//   `jevSandboxDev`).
13//
14// The frame has an opaque origin, so everything it loads from the site is
15// a cross-origin request with `Origin: null`: its scripts (module scripts
16// and AudioWorklets are fetched with CORS) and the sample pack. Those
17// public files are served with `Access-Control-Allow-Origin: *`, which
18// sends no credentials.
19
20// Hosts songs load sounds from: Strudel's CDN (the default banks,
21// prebakeSounds.mjs), GitHub's raw files (`samples('github:…')`, as the
22// site's songs load tidalcycles/dirt-samples), and the soundfonts'.
23export const SAMPLE_HOSTS = [
24  'https://strudel.b-cdn.net',
25  'https://raw.githubusercontent.com',
26  'https://felixroos.github.io/webaudiofontdata/',
27];
28
29export const SANDBOX_PAGE = '/jev/sandbox/';
30
31// `meta`: the policy as a meta tag may hold it (no `sandbox`,
32// `frame-ancestors`); otherwise the header's.
33export function sandboxCsp({ meta = false } = {}) {
34  const directives = [
35    ...(meta ? [] : ['sandbox allow-scripts']),
36    "default-src 'none'",
37    // the site's own scripts; a song is code, evaluated (unsafe-eval), and
38    // AudioWorklets load from blob: (the meter) and data: (superdough's dough
39    // worklet in dev); their bundled file is 'self' in production
40    "script-src 'self' 'unsafe-eval' blob: data:",
41    `connect-src 'self' ${SAMPLE_HOSTS.join(' ')}`,
42    "style-src 'unsafe-inline'",
43    "img-src 'none'",
44    "media-src 'none'",
45    "font-src 'none'",
46    "object-src 'none'",
47    "frame-src 'none'",
48    "manifest-src 'none'",
49    // AudioWorklet module scripts are governed by worker-src
50    "worker-src 'self' blob: data:",
51    "form-action 'none'",
52    // the page sets <base href="/"> so a song's root-relative sample URL
53    // resolves to the site root, as it does on the main page; 'self' is the
54    // origin the frame was served from (the site), not its opaque runtime
55    // origin, so the tag is allowed but cannot be pointed off-site
56    "base-uri 'self'",
57    ...(meta ? [] : ["frame-ancestors 'self'"]),
58  ];
59  return directives.join('; ');
60}
61
62export const sandboxHeaders = () => ({
63  'Content-Security-Policy': sandboxCsp(),
64  'Referrer-Policy': 'no-referrer',
65  'X-Content-Type-Options': 'nosniff',
66});
67
68// Paths the sandbox loads from the site, served to it with CORS.
69export const CORS_PATHS = ['/_astro/*', '/jev-samples/*'];
70
71// The deploy's `_headers` (Workers static assets).
72export function headersFile() {
73  const block = (path, headers) => [path, ...Object.entries(headers).map(([k, v]) => `  ${k}: ${v}`)].join('\n');
74  return [
75    '# Written by the build (website/src/jev/sandboxPolicy.mjs); do not edit.',
76    block(`${SANDBOX_PAGE}*`, sandboxHeaders()),
77    ...CORS_PATHS.map((path) => block(path, { 'Access-Control-Allow-Origin': '*' })),
78    '',
79  ].join('\n\n');
80}