Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 2024-03-05 - DOM XSS in PDFService Generation
**Vulnerability:** The `PDFService.js` directly assigned unvalidated HTML string to `contentWrapper.innerHTML` during PDF generation or preview when a string was passed as the content element.
**Learning:** Even internal services converting markdown/HTML string representations to export formats (like PDF) need input sanitization to prevent XSS payloads executing during rendering.
**Prevention:** Always sanitize user-derived or dynamic HTML strings using `DOMPurify.sanitize(content)` before injecting them into the DOM for export rendering operations.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"vitest": "4.0.8"
},
"dependencies": {
"dompurify": "3.3.1",
"dompurify": "^3.3.1",
"html2pdf.js": "0.14.0",
"katex": "0.16.27",
"marked": "17.0.1",
Expand Down
10 changes: 7 additions & 3 deletions src/js/services/PDFService.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Provides preview functionality before download.
*/

import DOMPurify from 'dompurify';
import { PDF_CONFIG } from '../config/constants.js';

/**
Expand Down Expand Up @@ -46,7 +47,7 @@ export class PDFService {

// Handle rgba/rgb
const rgbaMatch = color.match(
/rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+)?\s*\)/i
/rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+)?\s*\)/i,
);
if (rgbaMatch) {
const r = parseInt(rgbaMatch[1], 10);
Expand Down Expand Up @@ -189,7 +190,10 @@ export class PDFService {

// Handle content as either a Node or a string
if (typeof content === 'string') {
contentWrapper.innerHTML = content;
// Security: Sanitize HTML string to prevent DOM XSS when generating PDF
contentWrapper.innerHTML = DOMPurify.sanitize(content, {
USE_PROFILES: { html: true }, // Allow standard HTML
});
} else if (content instanceof Node) {
contentWrapper.appendChild(content.cloneNode(true));
} else {
Expand Down Expand Up @@ -294,7 +298,7 @@ export class PDFService {
mergedConfig,
mergedConfig.themeName,
mergedConfig.customTheme,
margins
margins,
);

// Build html2pdf config
Expand Down
16 changes: 9 additions & 7 deletions tests/unit/services/LinkNavigationService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('LinkNavigationService', () => {
it('should clear cache before building', async () => {
service.fileHandleCache.set('old-file.md', {});
const mockDirHandle = {
values: async function* () {},
async *values () {},
};

await service.buildFileCache(mockDirHandle);
Expand All @@ -68,7 +68,7 @@ describe('LinkNavigationService', () => {

it('should cache markdown files only', async () => {
const mockDirHandle = {
values: async function* () {
async *values () {
yield { kind: 'file', name: 'test.md' };
yield { kind: 'file', name: 'test.txt' };
yield { kind: 'file', name: 'README.MD' };
Expand All @@ -84,13 +84,13 @@ describe('LinkNavigationService', () => {

it('should recursively cache files in subdirectories', async () => {
const mockSubDir = {
values: async function* () {
async *values () {
yield { kind: 'file', name: 'nested.md' };
},
};

const mockDirHandle = {
values: async function* () {
async *values () {
yield { kind: 'file', name: 'root.md' };
yield { kind: 'directory', name: 'subfolder', ...mockSubDir };
},
Expand Down Expand Up @@ -202,7 +202,7 @@ describe('LinkNavigationService', () => {
await service.navigateToMarkdownFile('test.md');
expect(showWarningSpy).toHaveBeenCalledWith(
'No file currently open',
'Please open a file from the folder browser first.'
'Please open a file from the folder browser first.',
);
});

Expand All @@ -224,6 +224,7 @@ describe('LinkNavigationService', () => {
name: 'test.md',
path: 'docs/test.md',
handle: mockFileHandle,
anchor: null,
});
});

Expand Down Expand Up @@ -253,6 +254,7 @@ describe('LinkNavigationService', () => {
name: 'test.md',
path: 'docs/test.md',
handle: mockFileHandle,
anchor: null,
});
});

Expand All @@ -278,7 +280,7 @@ describe('LinkNavigationService', () => {

expect(showWarningSpy).toHaveBeenCalledWith(
'Failed to load file',
'Could not read file: Permission denied'
'Could not read file: Permission denied',
);
});
});
Expand Down Expand Up @@ -338,7 +340,7 @@ describe('LinkNavigationService', () => {
});

it('should handle very long file paths', async () => {
const longPath = 'a/'.repeat(100) + 'file.md';
const longPath = `${'a/'.repeat(100) }file.md`;
service.setCurrentFile(longPath);
expect(service.currentFilePath).toBe(longPath);
});
Expand Down
14 changes: 7 additions & 7 deletions tests/unit/utils/pathHelpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ describe('pathHelpers', () => {

it('should resolve ./ correctly', () => {
expect(resolveRelativePath('docs/folder/current.md', './sibling.md')).toBe(
'docs/folder/sibling.md'
'docs/folder/sibling.md',
);
});

it('should resolve ../../ correctly', () => {
expect(resolveRelativePath('docs/folder/subfolder/current.md', '../../other.md')).toBe(
'docs/other.md'
'docs/other.md',
);
});

Expand All @@ -40,13 +40,13 @@ describe('pathHelpers', () => {

it('should ignore . (current directory marker)', () => {
expect(resolveRelativePath('docs/folder/current.md', './././file.md')).toBe(
'docs/folder/file.md'
'docs/folder/file.md',
);
});

it('should handle backslashes on Windows paths', () => {
expect(resolveRelativePath('docs\\\\folder\\\\current.md', '..\\\\other.md')).toBe(
'docs/other.md'
'docs/other.md',
);
});

Expand Down Expand Up @@ -237,9 +237,9 @@ describe('pathHelpers', () => {

it('should handle normalized paths that resolve outside root', () => {
// Normalization converts 'docs/../secret' to 'secret'
// The implementation normalizes the path but doesn't prevent paths that resolved outside root
// It only checks if the normalized path starts with '..' or is empty
expect(isWithinRoot('docs/../secret', 'docs')).toBe(true); // 'secret' is valid normalized path
// The implementation checks if the path starts with the root folder
// Since 'secret' does not start with 'docs', it should return false
expect(isWithinRoot('docs/../secret', 'docs')).toBe(false); // 'secret' is outside 'docs'
});

it('should allow nested paths within root', () => {
Expand Down