mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 20:02:47 +01:00
fix(security): harden sanitize, flatten, redaction, signature validation, CSP, proxies and CI
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { existsSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
@@ -16,103 +17,187 @@ function originOf(urlStr) {
|
||||
}
|
||||
}
|
||||
|
||||
const JSDELIVR_HOST = 'cdn.jsdelivr.net';
|
||||
|
||||
export const DOCS_INLINE_SCRIPT_HASHES = [
|
||||
'sha256-DQUgNM9X0iH2019NkUxeBvnBoEoRKkHBc/I0iLqPNPA=',
|
||||
'sha256-La1r0VSk0Po4KFI0duEKhmPu+u0I416JW3oONqtdf4M=',
|
||||
];
|
||||
|
||||
function toScriptSource(url) {
|
||||
const origin = originOf(url);
|
||||
if (!origin) return null;
|
||||
const { host, pathname } = new URL(url);
|
||||
if (host !== JSDELIVR_HOST) return origin;
|
||||
const directory = /\.m?js$/.test(pathname)
|
||||
? pathname.replace(/[^/]*$/, '')
|
||||
: pathname.replace(/\/?$/, '/');
|
||||
return `${origin}${directory.replace(/;/g, '%3B').replace(/,/g, '%2C')}`;
|
||||
}
|
||||
|
||||
function uniq(values) {
|
||||
return Array.from(new Set(values.filter(Boolean)));
|
||||
}
|
||||
|
||||
const DEFAULT_WASM_ORIGINS = {
|
||||
pymupdf: 'https://cdn.jsdelivr.net',
|
||||
gs: 'https://cdn.jsdelivr.net',
|
||||
cpdf: 'https://cdn.jsdelivr.net',
|
||||
const DEFAULT_WASM_URLS = {
|
||||
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/',
|
||||
gs: 'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/assets/',
|
||||
cpdf: 'https://cdn.jsdelivr.net/npm/[email protected]/dist/',
|
||||
};
|
||||
|
||||
const tesseractPackage = createRequire(import.meta.url)(
|
||||
'tesseract.js/package.json'
|
||||
);
|
||||
const DEFAULT_TESSERACT_WORKER_URL = `https://cdn.jsdelivr.net/npm/tesseract.js@v${tesseractPackage.version}/dist/worker.min.js`;
|
||||
const DEFAULT_TESSERACT_CORE_URL = `https://cdn.jsdelivr.net/npm/tesseract.js-core@v${tesseractPackage.dependencies['tesseract.js-core'].substring(1)}/`;
|
||||
|
||||
const DEFAULT_CORS_PROXY_ORIGIN =
|
||||
'https://bentopdf-cors-proxy.bentopdf.workers.dev';
|
||||
const DEFAULT_OCR_FONT_CDN_ORIGIN = 'https://rawcdn.githack.com';
|
||||
|
||||
const wasmOrigins = [
|
||||
originOf(process.env.VITE_WASM_PYMUPDF_URL) || DEFAULT_WASM_ORIGINS.pymupdf,
|
||||
originOf(process.env.VITE_WASM_GS_URL) || DEFAULT_WASM_ORIGINS.gs,
|
||||
originOf(process.env.VITE_WASM_CPDF_URL) || DEFAULT_WASM_ORIGINS.cpdf,
|
||||
const COMMON_HEADERS = [
|
||||
['X-Frame-Options', 'SAMEORIGIN'],
|
||||
['X-Content-Type-Options', 'nosniff'],
|
||||
['X-XSS-Protection', '1; mode=block'],
|
||||
['Referrer-Policy', 'strict-origin-when-cross-origin'],
|
||||
[
|
||||
'Permissions-Policy',
|
||||
'geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()',
|
||||
],
|
||||
['Cross-Origin-Opener-Policy', 'same-origin'],
|
||||
['Cross-Origin-Embedder-Policy', 'credentialless'],
|
||||
['Cross-Origin-Resource-Policy', 'cross-origin'],
|
||||
];
|
||||
|
||||
const tesseractOrigins = uniq([
|
||||
originOf(process.env.VITE_TESSERACT_WORKER_URL),
|
||||
originOf(process.env.VITE_TESSERACT_CORE_URL),
|
||||
originOf(process.env.VITE_TESSERACT_LANG_URL),
|
||||
]);
|
||||
function urlOrDefault(value, fallback) {
|
||||
return originOf(value) ? value : fallback;
|
||||
}
|
||||
|
||||
const corsProxyOrigin =
|
||||
originOf(process.env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN;
|
||||
export function buildSecurityHeaders(env = process.env) {
|
||||
const wasmUrls = [
|
||||
urlOrDefault(env.VITE_WASM_PYMUPDF_URL, DEFAULT_WASM_URLS.pymupdf),
|
||||
urlOrDefault(env.VITE_WASM_GS_URL, DEFAULT_WASM_URLS.gs),
|
||||
urlOrDefault(env.VITE_WASM_CPDF_URL, DEFAULT_WASM_URLS.cpdf),
|
||||
];
|
||||
const wasmOrigins = wasmUrls.map(originOf);
|
||||
|
||||
const ocrFontOrigin =
|
||||
originOf(process.env.VITE_OCR_FONT_BASE_URL) || DEFAULT_OCR_FONT_CDN_ORIGIN;
|
||||
const tesseractOrigins = uniq([
|
||||
originOf(env.VITE_TESSERACT_WORKER_URL),
|
||||
originOf(env.VITE_TESSERACT_CORE_URL),
|
||||
originOf(env.VITE_TESSERACT_LANG_URL),
|
||||
]);
|
||||
|
||||
const scriptOrigins = uniq([...wasmOrigins, ...tesseractOrigins]);
|
||||
const connectOrigins = uniq([
|
||||
...wasmOrigins,
|
||||
...tesseractOrigins,
|
||||
corsProxyOrigin,
|
||||
ocrFontOrigin,
|
||||
]);
|
||||
const fontOrigins = uniq([ocrFontOrigin].filter(Boolean));
|
||||
const corsProxyOrigin =
|
||||
originOf(env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN;
|
||||
|
||||
const githubStarsDisabled =
|
||||
process.env.DISABLE_GITHUB_STARS === 'true' ||
|
||||
process.env.SIMPLE_MODE === 'true';
|
||||
const githubApiSource = githubStarsDisabled ? '' : ' https://api.github.com';
|
||||
const ocrFontOrigin =
|
||||
originOf(env.VITE_OCR_FONT_BASE_URL) || DEFAULT_OCR_FONT_CDN_ORIGIN;
|
||||
|
||||
const directives = [
|
||||
`default-src 'self'`,
|
||||
`script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: ${scriptOrigins.join(' ')}`.trim(),
|
||||
`worker-src 'self' blob:`,
|
||||
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
|
||||
`img-src 'self' data: blob: https:`,
|
||||
`font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(),
|
||||
`connect-src 'self' blob:${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
|
||||
`object-src 'none'`,
|
||||
`base-uri 'self'`,
|
||||
`frame-src 'self' blob:`,
|
||||
`frame-ancestors 'self'`,
|
||||
`form-action 'self'`,
|
||||
];
|
||||
const scriptSources = uniq([
|
||||
...wasmUrls.map(toScriptSource),
|
||||
toScriptSource(
|
||||
urlOrDefault(env.VITE_TESSERACT_WORKER_URL, DEFAULT_TESSERACT_WORKER_URL)
|
||||
),
|
||||
toScriptSource(
|
||||
urlOrDefault(env.VITE_TESSERACT_CORE_URL, DEFAULT_TESSERACT_CORE_URL)
|
||||
),
|
||||
]);
|
||||
const connectOrigins = uniq([
|
||||
...wasmOrigins,
|
||||
...tesseractOrigins,
|
||||
corsProxyOrigin,
|
||||
ocrFontOrigin,
|
||||
]);
|
||||
const fontOrigins = uniq([ocrFontOrigin]);
|
||||
|
||||
const docsDirectives = [
|
||||
`default-src 'self'`,
|
||||
`script-src 'self' 'unsafe-inline' ${scriptOrigins.join(' ')}`.trim(),
|
||||
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
|
||||
`img-src 'self' data: blob: https:`,
|
||||
`font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(),
|
||||
`connect-src 'self'${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
|
||||
`object-src 'none'`,
|
||||
`base-uri 'self'`,
|
||||
`frame-ancestors 'self'`,
|
||||
`form-action 'self'`,
|
||||
];
|
||||
const githubStarsDisabled =
|
||||
env.DISABLE_GITHUB_STARS === 'true' || env.SIMPLE_MODE === 'true';
|
||||
const githubApiSource = githubStarsDisabled ? '' : ' https://api.github.com';
|
||||
|
||||
const csp = directives.join('; ');
|
||||
const docsCsp = docsDirectives.join('; ');
|
||||
const directives = [
|
||||
`default-src 'self'`,
|
||||
`script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: ${scriptSources.join(' ')}`.trim(),
|
||||
`worker-src 'self' blob:`,
|
||||
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
|
||||
`img-src 'self' data: blob: https:`,
|
||||
`font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(),
|
||||
`connect-src 'self' blob:${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
|
||||
`object-src 'none'`,
|
||||
`base-uri 'self'`,
|
||||
`frame-src 'self' blob:`,
|
||||
`frame-ancestors 'self'`,
|
||||
`form-action 'self'`,
|
||||
];
|
||||
|
||||
const commonHeaders = `add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
||||
add_header Cross-Origin-Resource-Policy "cross-origin" always;
|
||||
`;
|
||||
const docsDirectives = [
|
||||
`default-src 'self'`,
|
||||
`script-src 'self' ${DOCS_INLINE_SCRIPT_HASHES.map((hash) => `'${hash}'`).join(' ')}`,
|
||||
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
|
||||
`img-src 'self' data: blob: https:`,
|
||||
`font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(),
|
||||
`connect-src 'self'${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
|
||||
`object-src 'none'`,
|
||||
`base-uri 'self'`,
|
||||
`frame-ancestors 'self'`,
|
||||
`form-action 'self'`,
|
||||
];
|
||||
|
||||
const contents = `add_header Content-Security-Policy "${csp}" always;
|
||||
${commonHeaders}`;
|
||||
const csp = directives.join('; ');
|
||||
const docsCsp = docsDirectives.join('; ');
|
||||
|
||||
const docsContents = `add_header Content-Security-Policy "${docsCsp}" always;
|
||||
${commonHeaders}`;
|
||||
const nginxHeaders = (policy) =>
|
||||
[['Content-Security-Policy', policy], ...COMMON_HEADERS]
|
||||
.map(([name, value]) => `add_header ${name} "${value}" always;\n`)
|
||||
.join('');
|
||||
|
||||
const outPath = join(repoRoot, 'security-headers.conf');
|
||||
const docsOutPath = join(repoRoot, 'security-headers-docs.conf');
|
||||
writeFileSync(outPath, contents);
|
||||
writeFileSync(docsOutPath, docsContents);
|
||||
console.log(
|
||||
`[security-headers] wrote ${outPath} with ${scriptOrigins.length} script-src / ${connectOrigins.length} connect-src origin(s)`
|
||||
);
|
||||
console.log(`[security-headers] wrote ${docsOutPath} (docs CSP)`);
|
||||
const staticHeaders = [
|
||||
'/*',
|
||||
` Content-Security-Policy: ${csp}`,
|
||||
...COMMON_HEADERS.map(([name, value]) => ` ${name}: ${value}`),
|
||||
'',
|
||||
...['/docs', '/docs/*'].flatMap((docsPath) => [
|
||||
docsPath,
|
||||
' ! Content-Security-Policy',
|
||||
` Content-Security-Policy: ${docsCsp}`,
|
||||
'',
|
||||
]),
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
csp,
|
||||
docsCsp,
|
||||
scriptSources,
|
||||
connectOrigins,
|
||||
nginx: nginxHeaders(csp),
|
||||
nginxDocs: nginxHeaders(docsCsp),
|
||||
staticHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const headers = buildSecurityHeaders();
|
||||
const outPath = join(repoRoot, 'security-headers.conf');
|
||||
const docsOutPath = join(repoRoot, 'security-headers-docs.conf');
|
||||
const staticOutPath = join(repoRoot, 'public', '_headers');
|
||||
writeFileSync(outPath, headers.nginx);
|
||||
writeFileSync(docsOutPath, headers.nginxDocs);
|
||||
writeFileSync(staticOutPath, headers.staticHeaders);
|
||||
console.log(
|
||||
`[security-headers] wrote ${outPath} with ${headers.scriptSources.length} script-src / ${headers.connectOrigins.length} connect-src source(s)`
|
||||
);
|
||||
console.log(`[security-headers] wrote ${docsOutPath} (docs CSP)`);
|
||||
console.log(`[security-headers] wrote ${staticOutPath}`);
|
||||
const distDir = join(repoRoot, 'dist');
|
||||
if (existsSync(distDir)) {
|
||||
const distOutPath = join(distDir, '_headers');
|
||||
writeFileSync(distOutPath, headers.staticHeaders);
|
||||
console.log(`[security-headers] wrote ${distOutPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
main();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user