From a9c31c8986c40a0097966318e925083302e91e13 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 24 Jul 2026 15:09:33 +0200 Subject: [PATCH] fix: harden media and WebAssembly delivery --- deploy/nginx.conf | 12 ++++- scripts/deployment.test.ts | 94 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/deploy/nginx.conf b/deploy/nginx.conf index b90d21f..a09f3f0 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -1,6 +1,7 @@ map $uri $toolbox_cache_control { default "no-cache"; "~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$" "public, max-age=31536000, immutable"; + "~^/apps/[a-z0-9][a-z0-9-]*/vendor/ffmpeg/(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)/(?:version\.json|(?:st|mt)/ffmpeg-core\.(?:js|wasm)|mt/ffmpeg-core\.worker\.js)$" "public, max-age=31536000, immutable"; } server { @@ -20,7 +21,7 @@ server { add_header Cross-Origin-Embedder-Policy "require-corp" always; add_header Cross-Origin-Resource-Policy "same-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; - add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'" always; + add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'" always; add_header Cache-Control $toolbox_cache_control always; gzip on; @@ -32,7 +33,14 @@ server { try_files $uri =404; } - location ~* \.(?:css|js|mjs|wasm|woff2?|png|jpe?g|gif|webp|svg|ico|webmanifest)$ { + location ~* \.wasm$ { + types { + application/wasm wasm; + } + try_files $uri =404; + } + + location ~* \.(?:css|js|mjs|woff2?|png|jpe?g|gif|webp|svg|ico|webmanifest)$ { try_files $uri =404; } diff --git a/scripts/deployment.test.ts b/scripts/deployment.test.ts index eb95b75..b59f645 100644 --- a/scripts/deployment.test.ts +++ b/scripts/deployment.test.ts @@ -4,10 +4,18 @@ import { describe, expect, it } from 'vitest'; const projectRoot = process.cwd(); const compose = readFileSync(path.join(projectRoot, 'compose.yaml'), 'utf8'); +const composeExample = readFileSync( + path.join(projectRoot, 'compose.example.yaml'), + 'utf8' +); const containerfile = readFileSync( path.join(projectRoot, 'Containerfile.release'), 'utf8' ); +const environmentExample = readFileSync( + path.join(projectRoot, '.env.example'), + 'utf8' +); const nginxConfig = readFileSync( path.join(projectRoot, 'deploy', 'nginx.conf'), 'utf8' @@ -16,6 +24,23 @@ const releaseLock = JSON.parse( readFileSync(path.join(projectRoot, 'release', 'toolbox.lock.json'), 'utf8') ) as { releaseVersion: string }; +const immutableCacheControl = 'public, max-age=31536000, immutable'; +const immutableUriPatterns = Array.from( + nginxConfig.matchAll( + new RegExp( + `^\\s*"~([^"]+)"\\s+"${immutableCacheControl.replaceAll( + ', ', + ',\\s+' + )}";$`, + 'gmu' + ) + ), + (match) => new RegExp(match[1], 'u') +); + +const isImmutable = (uri: string): boolean => + immutableUriPatterns.some((pattern) => pattern.test(uri)); + describe('production deployment', () => { it('keeps the release version and checksum pins synchronized', () => { const containerVersion = containerfile.match( @@ -30,10 +55,22 @@ describe('production deployment', () => { const composeChecksum = compose.match( /\$\{TOOLBOX_RELEASE_SHA256:-([a-f0-9]{64})\}/u )?.[1]; + const environmentVersion = environmentExample.match( + /^TOOLBOX_RELEASE_VERSION=(\S+)$/mu + )?.[1]; + const environmentChecksum = environmentExample.match( + /^TOOLBOX_RELEASE_SHA256=([a-f0-9]{64})$/mu + )?.[1]; + const localImageVersion = composeExample.match( + /^\s+image: git\.add-ideas\.de\/zemion\/toolbox:(\S+)$/mu + )?.[1]; expect(containerVersion).toBe(releaseLock.releaseVersion); expect(composeVersion).toBe(containerVersion); expect(composeChecksum).toBe(containerChecksum); + expect(environmentVersion).toBe(containerVersion); + expect(environmentChecksum).toBe(containerChecksum); + expect(localImageVersion).toBe(containerVersion); }); it('verifies the release before extraction and retains a rootless final image', () => { @@ -45,6 +82,11 @@ describe('production deployment', () => { expect(containerfile).toContain( 'COPY --from=release --chown=101:101 /tmp/toolbox/' ); + for (const app of ['pdf', 'xslt', 'onenote', 'av']) { + expect(containerfile).toContain( + `test -f /tmp/toolbox/apps/${app}/toolbox-app.json` + ); + } expect(containerfile).toMatch(/\nUSER 101:101\nEXPOSE 8080\n/u); }); @@ -73,4 +115,56 @@ describe('production deployment', () => { ); expect(nginxConfig).not.toMatch(/^\s*~[^\n]*\{\d/mu); }); + + it('caches only hashed assets and the versioned FFmpeg runtime immutably', () => { + expect(immutableUriPatterns).toHaveLength(2); + + for (const uri of [ + '/assets/index-Bp9nK_3a.js', + '/apps/av/vendor/ffmpeg/0.12.10/version.json', + '/apps/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.js', + '/apps/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm', + '/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js', + '/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm', + '/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js', + ]) { + expect(isImmutable(uri), uri).toBe(true); + } + + for (const uri of [ + '/', + '/index.html', + '/SOURCE.md', + '/LICENSE', + '/toolbox.catalog.json', + '/toolbox.release.json', + '/apps/av/toolbox-app.json', + '/apps/av/THIRD_PARTY_NOTICES.md', + '/apps/av/vendor/ffmpeg/version.json', + '/apps/av/vendor/ffmpeg/latest/st/ffmpeg-core.wasm', + '/apps/av/vendor/ffmpeg/0.12/st/ffmpeg-core.wasm', + '/apps/av/vendor/ffmpeg/01.2.3/st/ffmpeg-core.wasm', + '/apps/av/vendor/ffmpeg/0.12.10/st/other.wasm', + '/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.wasm', + '/apps/av/vendor/ffmpeg/0.12.10/ffmpeg-core.wasm', + ]) { + expect(isImmutable(uri), uri).toBe(false); + } + }); + + it('serves WebAssembly through an explicit MIME-mapped location', () => { + expect(nginxConfig).toMatch( + /location ~\* \\\.wasm\$\s*\{\s*types\s*\{\s*application\/wasm wasm;\s*\}\s*try_files \$uri =404;\s*\}/mu + ); + expect(nginxConfig).not.toMatch( + /location ~\* [^\n]*\(\?:[^\n|)]*\bwasm\b[^\n)]*\)/u + ); + }); + + it('allows same-origin WebAssembly workers and local blob media previews', () => { + expect(nginxConfig).toContain("script-src 'self' 'wasm-unsafe-eval'"); + expect(nginxConfig).toContain("worker-src 'self' blob:"); + expect(nginxConfig).toContain("media-src 'self' blob:"); + expect(nginxConfig).not.toContain("'unsafe-eval'"); + }); });