mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
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:
parent
6cc3494e62
commit
6b6699ed70
@ -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}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -147,6 +147,10 @@
|
||||
redo: '[[#{multiTool.redo}]]',
|
||||
};
|
||||
|
||||
window.multiTool = Object.assign({}, window.multiTool, {
|
||||
svgNotSupported: '[[#{multiTool.svgNotSupported}]]',
|
||||
});
|
||||
|
||||
window.decrypt = {
|
||||
passwordPrompt: '[[#{decrypt.passwordPrompt}]]',
|
||||
cancelled: '[[#{decrypt.cancelled}]]',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user