fix(security): sanitize image handling to prevent DOM XSS in PdfContainer (#4267)

# Description of Changes

- Removed the insecure `addImageFile` implementation from
`ImageHighlighter.js`
- Hardened `PdfContainer.addImageFile`:
  - Rejects non-image and SVG files to mitigate DOM XSS risks
- Uses `URL.createObjectURL` safely with automatic revocation after load
- Introduced `bytesFromImageElement` utility to:
  - Safely extract image bytes from `blob:` URLs via Canvas (always PNG)
  - Fetch image data robustly for `http(s)` and `data:` URLs
  - Use HTTP Content-Type as a hint for image type detection
- Updated image type detection to consider explicitly forced types

This change addresses a CodeQL security alert by ensuring user-supplied
image files cannot introduce executable scripts.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Ludy 2025-10-30 00:19:37 +01:00 committed by GitHub
parent 6cc3494e62
commit 6b6699ed70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 203 additions and 34 deletions

View File

@ -1403,6 +1403,8 @@ multiTool.dragDropMessage=Page(s) Selected
multiTool.undo=Undo (CTRL + Z)
multiTool.redo=Redo (CTRL + Y)
multiTool.svgNotSupported=SVG files are not supported in Multi Tool and were ignored.
#decrypt
decrypt.passwordPrompt=This file is password-protected. Please enter the password:
decrypt.cancelled=Operation cancelled for PDF: {0}

View File

@ -29,6 +29,54 @@ const mimeTypes = {
"pdf": "application/pdf",
};
const isMultiToolPage = () => window.location.pathname?.includes('multi-tool');
const isSvgFile = (file) => {
if (!file) return false;
const type = (file.type || '').toLowerCase();
if (type === 'image/svg+xml') {
return true;
}
const name = (file.name || '').toLowerCase();
return name.endsWith('.svg');
};
function filterSvgFiles(files) {
if (!Array.isArray(files) || !isMultiToolPage()) {
return { allowed: files ?? [], rejected: [] };
}
const allowed = [];
const rejected = [];
files.forEach((file) => {
if (isSvgFile(file)) {
rejected.push(file);
} else {
allowed.push(file);
}
});
return { allowed, rejected };
}
function showSvgWarning(rejectedFiles = []) {
if (!rejectedFiles.length) return;
const message = window.multiTool?.svgNotSupported ||
'SVG files are not supported in Multi Tool and were ignored.';
const rejectedNames = rejectedFiles
.map((file) => file?.name)
.filter(Boolean)
.join(', ');
if (rejectedNames) {
alert(`${message}\n${rejectedNames}`);
} else {
alert(message);
}
}
function setupFileInput(chooser) {
const elementId = chooser.getAttribute('data-bs-element-id');
const filesSelected = chooser.getAttribute('data-bs-files-selected');
@ -198,6 +246,24 @@ function setupFileInput(chooser) {
await checkZipFile();
const { allowed: nonSvgFiles, rejected: rejectedSvgFiles } = filterSvgFiles(allFiles);
if (rejectedSvgFiles.length > 0) {
showSvgWarning(rejectedSvgFiles);
allFiles = nonSvgFiles;
const updatedTransfer = toDataTransfer(allFiles);
element.files = updatedTransfer.files;
if (allFiles.length === 0) {
element.value = '';
}
if (allFiles.length === 0) {
inputContainer.querySelector('#fileInputText').innerHTML = originalText;
showOrHideSelectedFilesContainer(allFiles);
return;
}
}
const uploadLimit = window.stirlingPDF?.uploadLimit ?? 0;
if (uploadLimit > 0) {
const oversizedFiles = allFiles.filter(f => f.size > uploadLimit);

View File

@ -42,25 +42,6 @@ class ImageHighlighter {
img.addEventListener("click", this.imageHighlightCallback);
return div;
}
async addImageFile(file, nextSiblingElement) {
const div = document.createElement("div");
div.classList.add("page-container");
var img = document.createElement("img");
img.classList.add("page-image");
img.src = URL.createObjectURL(file);
div.appendChild(img);
this.pdfAdapters.forEach((adapter) => {
adapter.adapt?.(div);
});
if (nextSiblingElement) {
this.pagesContainer.insertBefore(div, nextSiblingElement);
} else {
this.pagesContainer.appendChild(div);
}
}
}
export default ImageHighlighter;

View File

@ -8,6 +8,49 @@ import { AddFilesCommand } from './commands/add-page.js';
import { DecryptFile } from '../DecryptFiles.js';
import { CommandSequence } from './commands/commands-sequence.js';
const isSvgFile = (file) => {
if (!file) return false;
const type = (file.type || '').toLowerCase();
if (type === 'image/svg+xml') {
return true;
}
const name = (file.name || '').toLowerCase();
return name.endsWith('.svg');
};
const partitionSvgFiles = (files = []) => {
const allowed = [];
const rejected = [];
files.forEach((file) => {
if (isSvgFile(file)) {
rejected.push(file);
} else {
allowed.push(file);
}
});
return { allowed, rejected };
};
const notifySvgUnsupported = (files = []) => {
if (!files.length) return;
if (!window.location.pathname?.includes('multi-tool')) return;
const message = window.multiTool?.svgNotSupported ||
'SVG files are not supported in Multi Tool and were ignored.';
const names = files
.map((file) => file?.name)
.filter(Boolean)
.join(', ');
if (names) {
alert(`${message}\n${names}`);
} else {
alert(message);
}
};
class PdfContainer {
fileName;
pagesContainer;
@ -180,10 +223,18 @@ class PdfContainer {
input.onchange = async (e) => {
const files = e.target.files;
if (files.length > 0) {
pages = await this.addFilesFromFiles(files, nextSiblingElement, pages);
this.updateFilename(files[0].name);
const {
pages: updatedPages,
acceptedFileCount,
} = await this.addFilesFromFiles(files, nextSiblingElement, pages);
if(window.selectPage){
pages = updatedPages;
if (acceptedFileCount > 0) {
this.updateFilename();
}
if (window.selectPage && acceptedFileCount > 0) {
this.showButton(document.getElementById('select-pages-container'), true);
}
}
@ -196,11 +247,17 @@ class PdfContainer {
async handleDroppedFiles(files, nextSiblingElement = null) {
if (files.length > 0) {
const pages = await this.addFilesFromFiles(files, nextSiblingElement, []);
this.updateFilename(files[0]?.name || 'untitled');
const {
pages,
acceptedFileCount,
} = await this.addFilesFromFiles(files, nextSiblingElement, []);
if(window.selectPage) {
this.showButton(document.getElementById('select-pages-container'), true);
if (acceptedFileCount > 0) {
this.updateFilename();
if (window.selectPage) {
this.showButton(document.getElementById('select-pages-container'), true);
}
}
return pages;
@ -209,14 +266,24 @@ class PdfContainer {
async addFilesFromFiles(files, nextSiblingElement, pages) {
this.fileName = files[0].name;
for (var i = 0; i < files.length; i++) {
const fileArray = Array.from(files || []);
const { allowed: allowedFiles, rejected: rejectedSvgFiles } = partitionSvgFiles(fileArray);
if (allowedFiles.length > 0) {
this.fileName = allowedFiles[0].name || 'untitled';
}
let acceptedFileCount = 0;
for (let i = 0; i < allowedFiles.length; i++) {
const file = allowedFiles[i];
const startTime = Date.now();
let processingTime,
errorMessage = null,
pageCount = 0;
try {
let decryptedFile = files[i];
let decryptedFile = file;
let isEncrypted = false;
let requiresPassword = false;
await this.decryptFile
@ -245,18 +312,27 @@ class PdfContainer {
processingTime = Date.now() - startTime;
this.captureFileProcessingEvent(true, decryptedFile, processingTime, null, pageCount);
acceptedFileCount++;
} catch (error) {
processingTime = Date.now() - startTime;
errorMessage = error.message || 'Unknown error';
this.captureFileProcessingEvent(false, files[i], processingTime, errorMessage, pageCount);
this.captureFileProcessingEvent(false, file, processingTime, errorMessage, pageCount);
if (isSvgFile(file)) {
rejectedSvgFiles.push(file);
}
}
}
if (rejectedSvgFiles.length > 0) {
notifySvgUnsupported(rejectedSvgFiles);
}
document.querySelectorAll('.enable-on-file').forEach((element) => {
element.disabled = false;
});
return pages;
return { pages, acceptedFileCount };
}
captureFileProcessingEvent(success, file, processingTime, errorMessage, pageCount) {
@ -329,12 +405,20 @@ class PdfContainer {
}
async addImageFile(file, nextSiblingElement, pages) {
// Ensure the provided file is a safe image type to prevent DOM XSS when
// rendering user-supplied content. Reject SVG and non-image files that could
// contain executable scripts.
if (!(file instanceof File) || !file.type.startsWith('image/') || file.type === 'image/svg+xml') {
throw new Error('Invalid image file');
}
const div = document.createElement('div');
div.classList.add('page-container');
var img = document.createElement('img');
const img = document.createElement('img');
img.classList.add('page-image');
img.src = URL.createObjectURL(file);
const objectUrl = URL.createObjectURL(file);
img.src = objectUrl;
img.onload = () => URL.revokeObjectURL(objectUrl);
div.appendChild(img);
this.pdfAdapters.forEach((adapter) => {
@ -740,9 +824,11 @@ class PdfContainer {
pdfDoc.addPage(page);
} else {
page = pdfDoc.addPage([img.naturalWidth, img.naturalHeight]);
const imageBytes = await fetch(img.src).then((res) => res.arrayBuffer());
// NEU: Bildbytes robust ermitteln (Canvas für blob:, fetch für http/https)
const { bytes: imageBytes, forcedType } = await bytesFromImageElement(img);
const uint8Array = new Uint8Array(imageBytes);
const imageType = detectImageType(uint8Array);
const imageType = forcedType || detectImageType(uint8Array);
let image;
switch (imageType) {
@ -967,6 +1053,36 @@ class PdfContainer {
}
}
async function bytesFromImageElement(img) {
// Handle Blob URLs without using fetch()
if (img.src.startsWith('blob:')) {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) throw new Error('Canvas toBlob() failed');
const buf = await blob.arrayBuffer();
return { bytes: buf, forcedType: 'PNG' }; // Canvas always generates PNG
}
// Fetch http(s)/data:-URLs normally (if necessary)
const res = await fetch(img.src, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status} beim Laden von ${img.src}`);
const buf = await res.arrayBuffer();
// Use Content-Type as a hint (optional)
let forcedType = null;
const ct = res.headers.get('content-type') || '';
if (ct.includes('png')) forcedType = 'PNG';
else if (ct.includes('jpeg') || ct.includes('jpg')) forcedType = 'JPEG';
else if (ct.includes('tiff')) forcedType = 'TIFF';
else if (ct.includes('gif')) forcedType = 'GIF';
return { bytes: buf, forcedType };
}
function detectImageType(uint8Array) {
// Check for PNG signature
if (uint8Array[0] === 137 && uint8Array[1] === 80 && uint8Array[2] === 78 && uint8Array[3] === 71) {

View File

@ -147,6 +147,10 @@
redo: '[[#{multiTool.redo}]]',
};
window.multiTool = Object.assign({}, window.multiTool, {
svgNotSupported: '[[#{multiTool.svgNotSupported}]]',
});
window.decrypt = {
passwordPrompt: '[[#{decrypt.passwordPrompt}]]',
cancelled: '[[#{decrypt.cancelled}]]',