fix: harden media and WebAssembly delivery
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
map $uri $toolbox_cache_control {
|
map $uri $toolbox_cache_control {
|
||||||
default "no-cache";
|
default "no-cache";
|
||||||
"~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$" "public, max-age=31536000, immutable";
|
"~^/(?:.*/)?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 {
|
server {
|
||||||
@@ -20,7 +21,7 @@ server {
|
|||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" 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;
|
add_header Cache-Control $toolbox_cache_control always;
|
||||||
|
|
||||||
gzip on;
|
gzip on;
|
||||||
@@ -32,7 +33,14 @@ server {
|
|||||||
try_files $uri =404;
|
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;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
const compose = readFileSync(path.join(projectRoot, 'compose.yaml'), 'utf8');
|
const compose = readFileSync(path.join(projectRoot, 'compose.yaml'), 'utf8');
|
||||||
|
const composeExample = readFileSync(
|
||||||
|
path.join(projectRoot, 'compose.example.yaml'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
const containerfile = readFileSync(
|
const containerfile = readFileSync(
|
||||||
path.join(projectRoot, 'Containerfile.release'),
|
path.join(projectRoot, 'Containerfile.release'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
const environmentExample = readFileSync(
|
||||||
|
path.join(projectRoot, '.env.example'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
const nginxConfig = readFileSync(
|
const nginxConfig = readFileSync(
|
||||||
path.join(projectRoot, 'deploy', 'nginx.conf'),
|
path.join(projectRoot, 'deploy', 'nginx.conf'),
|
||||||
'utf8'
|
'utf8'
|
||||||
@@ -16,6 +24,23 @@ const releaseLock = JSON.parse(
|
|||||||
readFileSync(path.join(projectRoot, 'release', 'toolbox.lock.json'), 'utf8')
|
readFileSync(path.join(projectRoot, 'release', 'toolbox.lock.json'), 'utf8')
|
||||||
) as { releaseVersion: string };
|
) 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', () => {
|
describe('production deployment', () => {
|
||||||
it('keeps the release version and checksum pins synchronized', () => {
|
it('keeps the release version and checksum pins synchronized', () => {
|
||||||
const containerVersion = containerfile.match(
|
const containerVersion = containerfile.match(
|
||||||
@@ -30,10 +55,22 @@ describe('production deployment', () => {
|
|||||||
const composeChecksum = compose.match(
|
const composeChecksum = compose.match(
|
||||||
/\$\{TOOLBOX_RELEASE_SHA256:-([a-f0-9]{64})\}/u
|
/\$\{TOOLBOX_RELEASE_SHA256:-([a-f0-9]{64})\}/u
|
||||||
)?.[1];
|
)?.[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(containerVersion).toBe(releaseLock.releaseVersion);
|
||||||
expect(composeVersion).toBe(containerVersion);
|
expect(composeVersion).toBe(containerVersion);
|
||||||
expect(composeChecksum).toBe(containerChecksum);
|
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', () => {
|
it('verifies the release before extraction and retains a rootless final image', () => {
|
||||||
@@ -45,6 +82,11 @@ describe('production deployment', () => {
|
|||||||
expect(containerfile).toContain(
|
expect(containerfile).toContain(
|
||||||
'COPY --from=release --chown=101:101 /tmp/toolbox/'
|
'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);
|
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);
|
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'");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user