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:
+253
-143
@@ -2,12 +2,12 @@
|
||||
|
||||
/**
|
||||
* Translation Checker for BentoPDF
|
||||
*
|
||||
*
|
||||
* This script compares translation files across languages and reports:
|
||||
* - Missing keys (keys present in English but absent in other languages)
|
||||
* - Extra keys (keys present in other languages but not in English)
|
||||
* - Untranslated keys (keys with the same value as English)
|
||||
*
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/check-translations.js
|
||||
* node scripts/check-translations.js --verbose
|
||||
@@ -25,212 +25,322 @@ const LOCALES_DIR = path.join(__dirname, '../public/locales');
|
||||
const REFERENCE_LANG = 'en';
|
||||
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
dim: '\x1b[2m',
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
dim: '\x1b[2m',
|
||||
};
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const verbose = args.includes('--verbose');
|
||||
const specificLang = args.find(arg => arg.startsWith('--lang='))?.split('=')[1];
|
||||
const specificLang = args
|
||||
.find((arg) => arg.startsWith('--lang='))
|
||||
?.split('=')[1];
|
||||
|
||||
/**
|
||||
* Flatten nested JSON object into dot notation
|
||||
* { a: { b: 'value' } } => { 'a.b': 'value' }
|
||||
*/
|
||||
function flattenObject(obj, prefix = '') {
|
||||
const flattened = {};
|
||||
const flattened = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(flattened, flattenObject(value, newKey));
|
||||
} else {
|
||||
flattened[newKey] = value;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(flattened, flattenObject(value, newKey));
|
||||
} else {
|
||||
flattened[newKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return flattened;
|
||||
return flattened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse a translation file
|
||||
*/
|
||||
function loadTranslation(lang) {
|
||||
const filePath = path.join(LOCALES_DIR, lang, 'common.json');
|
||||
const filePath = path.join(LOCALES_DIR, lang, 'common.json');
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error(`${colors.red}✗ Error parsing ${lang}/common.json:${colors.reset}`, error.message);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`${colors.red}✗ Error parsing ${lang}/common.json:${colors.reset}`,
|
||||
error.message
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available languages
|
||||
*/
|
||||
function getAvailableLanguages() {
|
||||
if (!fs.existsSync(LOCALES_DIR)) {
|
||||
console.error(`${colors.red}✗ Locales directory not found: ${LOCALES_DIR}${colors.reset}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(LOCALES_DIR)) {
|
||||
console.error(
|
||||
`${colors.red}✗ Locales directory not found: ${LOCALES_DIR}${colors.reset}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return fs.readdirSync(LOCALES_DIR)
|
||||
.filter(item => {
|
||||
const itemPath = path.join(LOCALES_DIR, item);
|
||||
return fs.statSync(itemPath).isDirectory();
|
||||
})
|
||||
.filter(lang => {
|
||||
// Only include if common.json exists
|
||||
return fs.existsSync(path.join(LOCALES_DIR, lang, 'common.json'));
|
||||
});
|
||||
return fs
|
||||
.readdirSync(LOCALES_DIR)
|
||||
.filter((item) => {
|
||||
const itemPath = path.join(LOCALES_DIR, item);
|
||||
return fs.statSync(itemPath).isDirectory();
|
||||
})
|
||||
.filter((lang) => {
|
||||
// Only include if common.json exists
|
||||
return fs.existsSync(path.join(LOCALES_DIR, lang, 'common.json'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two sets of keys and report differences
|
||||
*/
|
||||
function compareKeys(refKeys, targetKeys, refLang, targetLang, refFlat, targetFlat) {
|
||||
const missing = refKeys.filter(key => !targetKeys.includes(key));
|
||||
const extra = targetKeys.filter(key => !refKeys.includes(key));
|
||||
const untranslated = refKeys.filter(key =>
|
||||
targetKeys.includes(key) && refFlat[key] === targetFlat[key] && typeof refFlat[key] === 'string'
|
||||
);
|
||||
function compareKeys(
|
||||
refKeys,
|
||||
targetKeys,
|
||||
refLang,
|
||||
targetLang,
|
||||
refFlat,
|
||||
targetFlat
|
||||
) {
|
||||
const missing = refKeys.filter((key) => !targetKeys.includes(key));
|
||||
const extra = targetKeys.filter((key) => !refKeys.includes(key));
|
||||
const untranslated = refKeys.filter(
|
||||
(key) =>
|
||||
targetKeys.includes(key) &&
|
||||
refFlat[key] === targetFlat[key] &&
|
||||
typeof refFlat[key] === 'string'
|
||||
);
|
||||
|
||||
return { missing, extra, untranslated };
|
||||
return { missing, extra, untranslated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Print section header
|
||||
*/
|
||||
function printHeader(text) {
|
||||
console.log(`\n${colors.cyan}${'='.repeat(60)}${colors.reset}`);
|
||||
console.log(`${colors.cyan}${text}${colors.reset}`);
|
||||
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
|
||||
console.log(`\n${colors.cyan}${'='.repeat(60)}${colors.reset}`);
|
||||
console.log(`${colors.cyan}${text}${colors.reset}`);
|
||||
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print section
|
||||
*/
|
||||
function printSection(title, items, color = colors.yellow) {
|
||||
if (items.length === 0) return;
|
||||
if (items.length === 0) return;
|
||||
|
||||
console.log(`${color}${title} (${items.length}):${colors.reset}`);
|
||||
items.forEach(item => {
|
||||
console.log(` ${colors.dim}•${colors.reset} ${item}`);
|
||||
});
|
||||
console.log();
|
||||
console.log(`${color}${title} (${items.length}):${colors.reset}`);
|
||||
items.forEach((item) => {
|
||||
console.log(` ${colors.dim}•${colors.reset} ${item}`);
|
||||
});
|
||||
console.log();
|
||||
}
|
||||
|
||||
const ALLOWED_HTML_TAGS = ['br', 'strong', 'em'];
|
||||
const ALLOWED_TAG = new RegExp(
|
||||
`^</?(?:${ALLOWED_HTML_TAGS.join('|')})\\s*/?>`,
|
||||
'i'
|
||||
);
|
||||
|
||||
function findUnsafeHtml(value) {
|
||||
const problems = [];
|
||||
if (/<script/i.test(value)) problems.push('<script');
|
||||
if (/javascript:/i.test(value)) problems.push('javascript:');
|
||||
if (/\bon[a-z]+\s*=/i.test(value)) problems.push('on* attribute');
|
||||
for (const match of value.matchAll(/<[a-z/!?]/gi)) {
|
||||
const markup = value.slice(match.index);
|
||||
if (!ALLOWED_TAG.test(markup)) {
|
||||
problems.push(`disallowed markup "${markup.slice(0, 40)}"`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
function collectStrings(value, keyPath, out) {
|
||||
if (typeof value === 'string') {
|
||||
out.push([keyPath, value]);
|
||||
} else if (value && typeof value === 'object') {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
collectStrings(child, keyPath ? `${keyPath}.${key}` : key, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkHtmlSafety(langs) {
|
||||
const findings = [];
|
||||
for (const lang of langs) {
|
||||
const dir = path.join(LOCALES_DIR, lang);
|
||||
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
||||
} catch (error) {
|
||||
findings.push(`${lang}/${file}: invalid JSON (${error.message})`);
|
||||
continue;
|
||||
}
|
||||
for (const [keyPath, value] of collectStrings(data, '', [])) {
|
||||
for (const problem of findUnsafeHtml(value)) {
|
||||
findings.push(`${lang}/${file} ${keyPath}: ${problem}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
function main() {
|
||||
console.log(`${colors.blue}🌍 BentoPDF Translation Checker${colors.reset}\n`);
|
||||
console.log(`${colors.blue}🌍 BentoPDF Translation Checker${colors.reset}\n`);
|
||||
|
||||
const languages = getAvailableLanguages();
|
||||
const languages = getAvailableLanguages();
|
||||
|
||||
if (languages.length === 0) {
|
||||
console.error(`${colors.red}✗ No translation files found in ${LOCALES_DIR}${colors.reset}`);
|
||||
process.exit(1);
|
||||
if (languages.length === 0) {
|
||||
console.error(
|
||||
`${colors.red}✗ No translation files found in ${LOCALES_DIR}${colors.reset}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Load reference language (English)
|
||||
const refTranslation = loadTranslation(REFERENCE_LANG);
|
||||
if (!refTranslation) {
|
||||
console.error(
|
||||
`${colors.red}✗ Reference language (${REFERENCE_LANG}) not found${colors.reset}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const refFlat = flattenObject(refTranslation);
|
||||
const refKeys = Object.keys(refFlat);
|
||||
|
||||
console.log(
|
||||
`${colors.green}✓ Reference language (${REFERENCE_LANG}): ${refKeys.length} keys${colors.reset}`
|
||||
);
|
||||
console.log(
|
||||
`${colors.dim} Available languages: ${languages.join(', ')}${colors.reset}\n`
|
||||
);
|
||||
|
||||
// Filter languages to check
|
||||
const langsToCheck = specificLang
|
||||
? languages.filter((lang) => lang === specificLang)
|
||||
: languages.filter((lang) => lang !== REFERENCE_LANG);
|
||||
|
||||
if (langsToCheck.length === 0) {
|
||||
console.log(`${colors.yellow}⚠ No languages to check${colors.reset}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let hasIssues = false;
|
||||
|
||||
// Check each language
|
||||
for (const lang of langsToCheck) {
|
||||
printHeader(`Checking: ${lang.toUpperCase()}`);
|
||||
|
||||
const translation = loadTranslation(lang);
|
||||
if (!translation) {
|
||||
hasIssues = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load reference language (English)
|
||||
const refTranslation = loadTranslation(REFERENCE_LANG);
|
||||
if (!refTranslation) {
|
||||
console.error(`${colors.red}✗ Reference language (${REFERENCE_LANG}) not found${colors.reset}`);
|
||||
process.exit(1);
|
||||
const targetFlat = flattenObject(translation);
|
||||
const targetKeys = Object.keys(targetFlat);
|
||||
|
||||
const { missing, extra, untranslated } = compareKeys(
|
||||
refKeys,
|
||||
targetKeys,
|
||||
REFERENCE_LANG,
|
||||
lang,
|
||||
refFlat,
|
||||
targetFlat
|
||||
);
|
||||
|
||||
// Summary
|
||||
console.log(
|
||||
`${colors.dim}Total keys: ${targetKeys.length} / ${refKeys.length}${colors.reset}\n`
|
||||
);
|
||||
|
||||
// Missing keys
|
||||
if (missing.length > 0) {
|
||||
hasIssues = true;
|
||||
printSection(`Missing Keys`, missing, colors.red);
|
||||
}
|
||||
|
||||
const refFlat = flattenObject(refTranslation);
|
||||
const refKeys = Object.keys(refFlat);
|
||||
|
||||
console.log(`${colors.green}✓ Reference language (${REFERENCE_LANG}): ${refKeys.length} keys${colors.reset}`);
|
||||
console.log(`${colors.dim} Available languages: ${languages.join(', ')}${colors.reset}\n`);
|
||||
|
||||
// Filter languages to check
|
||||
const langsToCheck = specificLang
|
||||
? languages.filter(lang => lang === specificLang)
|
||||
: languages.filter(lang => lang !== REFERENCE_LANG);
|
||||
|
||||
if (langsToCheck.length === 0) {
|
||||
console.log(`${colors.yellow}⚠ No languages to check${colors.reset}`);
|
||||
process.exit(0);
|
||||
// Extra keys
|
||||
if (extra.length > 0) {
|
||||
hasIssues = true;
|
||||
printSection(`Extra Keys (not in English)`, extra, colors.yellow);
|
||||
}
|
||||
|
||||
let hasIssues = false;
|
||||
|
||||
// Check each language
|
||||
for (const lang of langsToCheck) {
|
||||
printHeader(`Checking: ${lang.toUpperCase()}`);
|
||||
|
||||
const translation = loadTranslation(lang);
|
||||
if (!translation) {
|
||||
hasIssues = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetFlat = flattenObject(translation);
|
||||
const targetKeys = Object.keys(targetFlat);
|
||||
|
||||
const { missing, extra, untranslated } = compareKeys(
|
||||
refKeys,
|
||||
targetKeys,
|
||||
REFERENCE_LANG,
|
||||
lang,
|
||||
refFlat,
|
||||
targetFlat
|
||||
);
|
||||
|
||||
// Summary
|
||||
console.log(`${colors.dim}Total keys: ${targetKeys.length} / ${refKeys.length}${colors.reset}\n`);
|
||||
|
||||
// Missing keys
|
||||
if (missing.length > 0) {
|
||||
hasIssues = true;
|
||||
printSection(`Missing Keys`, missing, colors.red);
|
||||
}
|
||||
|
||||
// Extra keys
|
||||
if (extra.length > 0) {
|
||||
hasIssues = true;
|
||||
printSection(`Extra Keys (not in English)`, extra, colors.yellow);
|
||||
}
|
||||
|
||||
// Untranslated keys (same as English)
|
||||
if (verbose && untranslated.length > 0) {
|
||||
printSection(`Possibly Untranslated (same as English)`, untranslated, colors.cyan);
|
||||
} else if (untranslated.length > 0) {
|
||||
console.log(`${colors.cyan}Possibly Untranslated: ${untranslated.length}${colors.reset}`);
|
||||
console.log(`${colors.dim} (use --verbose to see details)${colors.reset}\n`);
|
||||
}
|
||||
|
||||
// All good
|
||||
if (missing.length === 0 && extra.length === 0) {
|
||||
console.log(`${colors.green} No missing or extra keys!${colors.reset}\n`);
|
||||
}
|
||||
// Untranslated keys (same as English)
|
||||
if (verbose && untranslated.length > 0) {
|
||||
printSection(
|
||||
`Possibly Untranslated (same as English)`,
|
||||
untranslated,
|
||||
colors.cyan
|
||||
);
|
||||
} else if (untranslated.length > 0) {
|
||||
console.log(
|
||||
`${colors.cyan}Possibly Untranslated: ${untranslated.length}${colors.reset}`
|
||||
);
|
||||
console.log(
|
||||
`${colors.dim} (use --verbose to see details)${colors.reset}\n`
|
||||
);
|
||||
}
|
||||
|
||||
// Final summary
|
||||
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
|
||||
|
||||
if (!hasIssues) {
|
||||
console.log(`${colors.green} All translations are in sync!${colors.reset}\n`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`${colors.yellow} Issues found. Please review the output above.${colors.reset}\n`);
|
||||
process.exit(1);
|
||||
// All good
|
||||
if (missing.length === 0 && extra.length === 0) {
|
||||
console.log(`${colors.green} No missing or extra keys!${colors.reset}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const htmlFindings = checkHtmlSafety(
|
||||
specificLang ? languages.filter((lang) => lang === specificLang) : languages
|
||||
);
|
||||
if (htmlFindings.length > 0) {
|
||||
hasIssues = true;
|
||||
printHeader('Unsafe HTML in translations');
|
||||
printSection(
|
||||
`Allowed tags: ${ALLOWED_HTML_TAGS.join(', ')} (no attributes)`,
|
||||
htmlFindings,
|
||||
colors.red
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`${colors.green} No unsafe HTML in translations (allowed tags: ${ALLOWED_HTML_TAGS.join(', ')})${colors.reset}\n`
|
||||
);
|
||||
}
|
||||
|
||||
// Final summary
|
||||
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
|
||||
|
||||
if (!hasIssues) {
|
||||
console.log(
|
||||
`${colors.green} All translations are in sync!${colors.reset}\n`
|
||||
);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(
|
||||
`${colors.yellow} Issues found. Please review the output above.${colors.reset}\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { serializeJsonLd } from './json-ld.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -62,7 +63,7 @@ function fixApplicationCategory(document) {
|
||||
data.applicationCategory !== 'UtilitiesApplication'
|
||||
) {
|
||||
data.applicationCategory = 'UtilitiesApplication';
|
||||
script.textContent = JSON.stringify(data, null, 2);
|
||||
script.textContent = serializeJsonLd(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,15 +93,11 @@ function injectFaqSchema(document) {
|
||||
if (items.length === 0) return false;
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('type', 'application/ld+json');
|
||||
script.textContent = JSON.stringify(
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: items,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
script.textContent = serializeJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: items,
|
||||
});
|
||||
document.body.appendChild(script);
|
||||
return true;
|
||||
}
|
||||
@@ -109,17 +106,13 @@ function injectWebsiteSchema(document) {
|
||||
if (hasLdType(document, 'WebSite')) return;
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('type', 'application/ld+json');
|
||||
script.textContent = JSON.stringify(
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'BentoPDF',
|
||||
alternateName: ['Bento PDF', 'BentoPDF.com'],
|
||||
url: `${SITE_URL}/`,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
script.textContent = serializeJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'BentoPDF',
|
||||
alternateName: ['Bento PDF', 'BentoPDF.com'],
|
||||
url: `${SITE_URL}/`,
|
||||
});
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
|
||||
+60
-75
@@ -2,6 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { marked } from 'marked';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { serializeJsonLd } from './json-ld.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -123,19 +124,15 @@ function faqSchema(entries) {
|
||||
if (entries.length === 0) return '';
|
||||
return `
|
||||
<script type="application/ld+json">
|
||||
${JSON.stringify(
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: entries.map((f) => ({
|
||||
'@type': 'Question',
|
||||
name: f.q,
|
||||
acceptedAnswer: { '@type': 'Answer', text: plainText(f.a) },
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
${serializeJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: entries.map((f) => ({
|
||||
'@type': 'Question',
|
||||
name: f.q,
|
||||
acceptedAnswer: { '@type': 'Answer', text: plainText(f.a) },
|
||||
})),
|
||||
})}
|
||||
</script>`;
|
||||
}
|
||||
|
||||
@@ -246,55 +243,47 @@ ${renderFaqs(meta.faqs)}
|
||||
</script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
${JSON.stringify(
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: meta.h1,
|
||||
description: meta.description,
|
||||
url,
|
||||
datePublished: meta.date,
|
||||
dateModified: updated,
|
||||
image: `${SITE_URL}/images/og-tools.png`,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: AUTHOR.name,
|
||||
url: AUTHOR.url,
|
||||
sameAs: AUTHOR.profiles,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'BentoPDF',
|
||||
url: SITE_URL,
|
||||
},
|
||||
${serializeJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: meta.h1,
|
||||
description: meta.description,
|
||||
url,
|
||||
datePublished: meta.date,
|
||||
dateModified: updated,
|
||||
image: `${SITE_URL}/images/og-tools.png`,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: AUTHOR.name,
|
||||
url: AUTHOR.url,
|
||||
sameAs: AUTHOR.profiles,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'BentoPDF',
|
||||
url: SITE_URL,
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
${JSON.stringify(
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Blog',
|
||||
item: `${SITE_URL}/blog/`,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: meta.breadcrumb,
|
||||
item: url,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
${serializeJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Blog',
|
||||
item: `${SITE_URL}/blog/`,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: meta.breadcrumb,
|
||||
item: url,
|
||||
},
|
||||
],
|
||||
})}
|
||||
</script>${faqSchema(faqEntries)}
|
||||
</body>
|
||||
</html>
|
||||
@@ -370,23 +359,19 @@ ${cards}
|
||||
<script type="module" src="/src/js/mobileMenu.ts"></script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
${JSON.stringify(
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Blog',
|
||||
name: 'The BentoPDF Blog',
|
||||
url: `${SITE_URL}/blog/`,
|
||||
description:
|
||||
'Guides and honest comparisons from the maintainer of BentoPDF.',
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'BentoPDF',
|
||||
url: SITE_URL,
|
||||
},
|
||||
${serializeJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Blog',
|
||||
name: 'The BentoPDF Blog',
|
||||
url: `${SITE_URL}/blog/`,
|
||||
description:
|
||||
'Guides and honest comparisons from the maintainer of BentoPDF.',
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'BentoPDF',
|
||||
url: SITE_URL,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
})}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { serializeJsonLd } from './json-ld.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -133,7 +134,7 @@ function injectOrganizationLd(document) {
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('type', 'application/ld+json');
|
||||
script.setAttribute(ORGANIZATION_LD_MARKER, '');
|
||||
script.textContent = JSON.stringify(data, null, 2);
|
||||
script.textContent = serializeJsonLd(data);
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
@@ -214,7 +215,7 @@ function injectToolBreadcrumb(document, lang, toolName, toolUrl) {
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('type', 'application/ld+json');
|
||||
script.setAttribute(BREADCRUMB_MARKER, '');
|
||||
script.textContent = JSON.stringify(ld, null, 2);
|
||||
script.textContent = serializeJsonLd(ld);
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { DOCS_INLINE_SCRIPT_HASHES } from './generate-security-headers.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -29,6 +31,32 @@ function copyDir(src, dest) {
|
||||
}
|
||||
}
|
||||
|
||||
function findUnlistedInlineScripts(dir, unlisted = new Set()) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
findUnlistedInlineScripts(entryPath, unlisted);
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.endsWith('.html')) continue;
|
||||
const html = fs.readFileSync(entryPath, 'utf8');
|
||||
const inlineScripts = html.matchAll(
|
||||
/<script(?![^>]*\bsrc=)([^>]*)>([\s\S]*?)<\/script>/g
|
||||
);
|
||||
for (const [, attributes, code] of inlineScripts) {
|
||||
if (
|
||||
!code.trim() ||
|
||||
/type=["']?application\/(ld\+)?json/.test(attributes)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const hash = `sha256-${crypto.createHash('sha256').update(code).digest('base64')}`;
|
||||
if (!DOCS_INLINE_SCRIPT_HASHES.includes(hash)) unlisted.add(hash);
|
||||
}
|
||||
}
|
||||
return unlisted;
|
||||
}
|
||||
|
||||
console.log('Including docs in dist...');
|
||||
|
||||
if (!fs.existsSync(docsDistPath)) {
|
||||
@@ -44,6 +72,18 @@ if (!fs.existsSync(mainDistPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const unlistedScripts = findUnlistedInlineScripts(docsDistPath);
|
||||
if (unlistedScripts.size > 0) {
|
||||
console.error(
|
||||
'Error: The docs contain inline scripts that the docs Content-Security-Policy does not allow:'
|
||||
);
|
||||
for (const hash of unlistedScripts) console.error(` '${hash}'`);
|
||||
console.error(
|
||||
'Add these hashes to DOCS_INLINE_SCRIPT_HASHES in scripts/generate-security-headers.mjs and to the /docs script-src in vercel.json and serve.json, then rebuild.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Copying from: ${docsDistPath}`);
|
||||
console.log(`Copying to: ${targetDocsPath}`);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function serializeJsonLd(data) {
|
||||
return JSON.stringify(data, null, 2)
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029');
|
||||
}
|
||||
Reference in New Issue
Block a user