Files
OpenFrame/lib/content-security-policy.ts
T
yusufipk 52ace1a1a8 fix: generate CSP from runtime storage env for self-hosted MinIO
Move Content-Security-Policy generation to proxy.ts so R2_PRESIGN_ENDPOINT
is included at request time instead of being frozen at image build time.
Document reverse-proxy layouts for Docker self-hosting and copy proxy.ts
into the Docker image.

Closes #17
2026-06-12 21:21:31 +02:00

97 lines
3.0 KiB
TypeScript

function resolveBunnyCdnHostname(): string | null {
const raw = process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
if (!raw) return null;
try {
const parsed = new URL(raw);
return parsed.hostname || null;
} catch {
return raw.replace(/^https?:\/\//, '').replace(/\/+$/, '') || null;
}
}
function resolveR2ConnectOrigins(): string[] {
const origins = new Set<string>();
for (const raw of [
process.env.R2_ENDPOINT?.trim(),
process.env.R2_PRESIGN_ENDPOINT?.trim(),
process.env.R2_PUBLIC_BASE_URL?.trim(),
]) {
if (!raw) continue;
try {
origins.add(new URL(raw).origin);
} catch {
// Ignore invalid custom endpoints in CSP generation.
}
}
const accountId = process.env.R2_ACCOUNT_ID?.trim();
const bucket = process.env.R2_BUCKET_NAME?.trim();
if (accountId) {
origins.add(`https://${accountId}.r2.cloudflarestorage.com`);
if (bucket) {
origins.add(`https://${bucket}.${accountId}.r2.cloudflarestorage.com`);
}
origins.add('https://*.r2.cloudflarestorage.com');
}
// Docker/MinIO self-hosted defaults for local development.
origins.add('http://localhost:9000');
origins.add('http://127.0.0.1:9000');
return [...origins];
}
/**
* Build Content-Security-Policy from runtime environment variables.
* Called per request so Docker/self-hosted storage endpoints are available
* without rebuilding the image.
*/
export function buildContentSecurityPolicy(): string {
const isDev = process.env.NODE_ENV === 'development';
const bunnyCdnHostname = resolveBunnyCdnHostname();
const cdnOrigin = bunnyCdnHostname ? `https://${bunnyCdnHostname}` : '';
const connectSrcParts = [
"'self'",
'https://video.bunnycdn.com',
'https://www.youtube.com',
cdnOrigin,
...resolveR2ConnectOrigins(),
// Allow Next.js HMR websocket in development
...(isDev ? ['ws://localhost:* wss://localhost:*'] : []),
].filter(Boolean);
const imgSrcParts = [
"'self'",
'data:',
'blob:',
'https://img.youtube.com',
'https://i.ytimg.com',
'https://images.unsplash.com',
'https://vz-thumbnail.b-cdn.net',
cdnOrigin,
].filter(Boolean);
const mediaSrcParts = ["'self'", 'blob:', cdnOrigin].filter(Boolean);
return [
"default-src 'self'",
// 'unsafe-inline' is required by Next.js App Router (hydration scripts, inline styles)
// https://www.youtube.com is required for the dynamically-injected YouTube IFrame API script
"script-src 'self' 'unsafe-inline' https://www.youtube.com",
"style-src 'self' 'unsafe-inline'",
`img-src ${imgSrcParts.join(' ')}`,
`media-src ${mediaSrcParts.join(' ')}`,
"frame-src 'self' https://www.youtube.com https://iframe.mediadelivery.net",
`connect-src ${connectSrcParts.join(' ')}`,
// next/font self-hosts Google Fonts at build time — no external font origin needed
"font-src 'self'",
"worker-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; ');
}