From 5bf06756eb15765d0ba4c8bd7fdddb9e3a9d060c Mon Sep 17 00:00:00 2001 From: Ashminita Date: Fri, 5 Jun 2026 08:26:31 +0530 Subject: [PATCH 01/11] fix(export): add test and fix arbitary file read issue in export inline image --- apps/main-processor/__tests__/export.test.ts | 20 ++++++++++-- apps/main-processor/src/export/inlineImage.ts | 31 +++++++++++++++++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/main-processor/__tests__/export.test.ts b/apps/main-processor/__tests__/export.test.ts index 6c5ebac..1fd9559 100644 --- a/apps/main-processor/__tests__/export.test.ts +++ b/apps/main-processor/__tests__/export.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { mkdtemp, writeFile, rm, realpath } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { buildDocument } from '../src/export/buildDocument'; import { sanitizeCss } from '../src/export/sanitizeCss'; import { getImage } from '../src/export/getImage'; import { inlineImages } from '../src/export/inlineImage'; +import { allowedFolderRoots } from '../src/utils/constants/ipc-validation'; describe('build html document', () => { it('wraps content in a valid HTML5 document shell', () => { @@ -58,11 +59,26 @@ describe('get image of mime type', () => { describe('inline images for HTML export', () => { it('keeps local images as base 64 data URIs', async () => { const dir = await mkdtemp(join(tmpdir(), 'markdown-reader-export-')); + const resolvedDir = await realpath(dir); + allowedFolderRoots.add(resolvedDir); try { - const imagePath = join(dir, 'image.png'); + const imagePath = join(resolvedDir, 'image.png'); await writeFile(imagePath, Buffer.from([137, 80, 78, 71])); const html = await inlineImages(``); expect(html).toContain('src="data:image/png;base64,'); + } finally { + allowedFolderRoots.delete(resolvedDir); + await rm(dir, { recursive: true, force: true }); + } + }); + + it('does not inline images outside approved folders', async () => { + const dir = await mkdtemp(join(tmpdir(), 'markdown-reader-export-deny-')); + try { + const imagePath = join(dir, 'private.png'); + await writeFile(imagePath, Buffer.from([137, 80, 78, 71])); + const html = await inlineImages(``); + expect(html).toBe(``); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/apps/main-processor/src/export/inlineImage.ts b/apps/main-processor/src/export/inlineImage.ts index be5d112..17fd90b 100644 --- a/apps/main-processor/src/export/inlineImage.ts +++ b/apps/main-processor/src/export/inlineImage.ts @@ -1,7 +1,13 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, realpath } from 'node:fs/promises'; import { EXPORT_CONST } from '../utils/constants/export-constants'; import { normaliseImagePath } from '../utils/helper/path-helper'; import { getImage } from './getImage'; +import { + allowedFolderRoots, + allowedMarkdownFiles, + isPathInside, +} from '../utils/constants/ipc-validation'; +import { dirname } from 'node:path'; export async function inlineImages(html: string): Promise { const matches = [...html.matchAll(EXPORT_CONST.EXPORT_IMAGE_SRC_REGEX)]; @@ -15,8 +21,27 @@ export async function inlineImages(html: string): Promise { try { const imagePath = normaliseImagePath(src); - const data = await readFile(imagePath); - const mimeType = getImage(imagePath); + const resolveImagePath = await realpath(imagePath); + let isAllowed = false; + for (const root of allowedFolderRoots) { + if (isPathInside(resolveImagePath, root)) { + isAllowed = true; + break; + } + } + if (!isAllowed) { + for (const file of allowedMarkdownFiles) { + const parentDir = dirname(file); + if (isPathInside(resolveImagePath, parentDir)) { + isAllowed = true; + break; + } + } + } + + if (!isAllowed) continue; + const data = await readFile(resolveImagePath); + const mimeType = getImage(resolveImagePath); const base64 = data.toString('base64'); output = output.replace(fullMatch, `src="data:${mimeType};base64,${base64}"`); From b1c1282e89a77fbc86675cc2a56eba58f3bb8578 Mon Sep 17 00:00:00 2001 From: Ashminita Date: Fri, 5 Jun 2026 08:32:50 +0530 Subject: [PATCH 02/11] refactor(export): add test for @import and url block in dangerous css patterns --- apps/main-processor/__tests__/export.test.ts | 11 ++++++++++- .../src/utils/constants/export-constants.ts | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/main-processor/__tests__/export.test.ts b/apps/main-processor/__tests__/export.test.ts index 1fd9559..a1d7279 100644 --- a/apps/main-processor/__tests__/export.test.ts +++ b/apps/main-processor/__tests__/export.test.ts @@ -38,11 +38,20 @@ describe('sanitise css for export ', () => { it('removes unsafe css patterns', () => { const css = sanitizeCss( - 'body { behavior: url(test.htc); background: url(javascript:alert(1)); }' + 'body { behavior: url(test.htc); background: url(javascript:alert(1)); } ' + + '@import "http://example.com/style.css"; ' + + '@import url("https://example.com/no-semicolon")' + + 'div { background: url(https://example.com/bg.png); } ' + + 'p { background: url(//example.com/bg.png); } ' + + 'span { background: url(http://example.com/bg.png); }' ); expect(css).not.toContain('behavior'); expect(css).not.toContain('javascript:'); + expect(css).not.toContain('@import'); + expect(css).not.toContain('https://'); + expect(css).not.toContain('http://'); + expect(css).not.toContain('//example.com'); }); }); diff --git a/apps/main-processor/src/utils/constants/export-constants.ts b/apps/main-processor/src/utils/constants/export-constants.ts index 6ce9194..1a728f5 100644 --- a/apps/main-processor/src/utils/constants/export-constants.ts +++ b/apps/main-processor/src/utils/constants/export-constants.ts @@ -8,5 +8,7 @@ export const EXPORT_CONST = { /-moz-binding\s*:/gi, /expression\s*\(/gi, /javascript\s*:/gi, + /@import\b[^;]*(?:;|$)/gi, + /url\s*\(\s*['"]?\s*(?:https?:|\/\/)[^)]*\)/gi, ], }; From 273d939109956936cc59b42e802f1a087a7ad52b Mon Sep 17 00:00:00 2001 From: Ashminita Date: Fri, 5 Jun 2026 11:59:13 +0530 Subject: [PATCH 03/11] refactor(settings): add unbounced custom css length check --- apps/main-processor/__tests__/settings.test.ts | 1 + apps/main-processor/src/utils/helper/setting-helper.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/main-processor/__tests__/settings.test.ts b/apps/main-processor/__tests__/settings.test.ts index 149afa4..3232315 100644 --- a/apps/main-processor/__tests__/settings.test.ts +++ b/apps/main-processor/__tests__/settings.test.ts @@ -42,6 +42,7 @@ describe('settings validation', () => { customCss: '.markdown-body { color: red; }', }); expect(() => validateSettings({ customCss: 42 as never })).toThrow('Invalid customCss'); + expect(() => validateSettings({ customCss: 'a'.repeat(10001) })).toThrow('Invalid customCss'); }); it('validates zoom range', () => { diff --git a/apps/main-processor/src/utils/helper/setting-helper.ts b/apps/main-processor/src/utils/helper/setting-helper.ts index 5e96b3d..90463af 100644 --- a/apps/main-processor/src/utils/helper/setting-helper.ts +++ b/apps/main-processor/src/utils/helper/setting-helper.ts @@ -1,6 +1,6 @@ import { app } from 'electron'; import path from 'path'; -import { mkdir, open, rename, unlink, writeFile } from 'node:fs/promises'; +import { mkdir, open, rename, unlink } from 'node:fs/promises'; import { AppSettings } from '@package/shared-types'; import { THEMES } from '@package/shared-constants'; import { SETTINGS_KEYS, READING_WIDTHS } from '../constants/setting-constants'; @@ -44,7 +44,7 @@ export function validateSettings(partial: Partial): Partial 10000) throw new Error('Invalid customCss'); validated.customCss = value; } if (key === 'zoom') { From 4a0e7942e6f1917591f314e17c5d586904669bf3 Mon Sep 17 00:00:00 2001 From: Ashminita Date: Fri, 5 Jun 2026 12:05:59 +0530 Subject: [PATCH 04/11] refactor(renderer): add mount check in scroll timeout callback --- apps/renderer/src/hooks/useFilePersistence.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/renderer/src/hooks/useFilePersistence.ts b/apps/renderer/src/hooks/useFilePersistence.ts index 86a3c5c..7165b1e 100644 --- a/apps/renderer/src/hooks/useFilePersistence.ts +++ b/apps/renderer/src/hooks/useFilePersistence.ts @@ -35,7 +35,6 @@ export function useFilePersistence({ window.clearTimeout(debounceTimer.current); } debounceTimer.current = window.setTimeout(async () => { - if (!debounceTimer.current) return; const currentScroll = contentRef.current?.scrollTop ?? 0; const result = await loadFile(activeTab.filePath); if (!result || !isMounted.current) return; @@ -64,14 +63,14 @@ export function useFilePersistence({ } scrollTimer.current = window.setTimeout(() => { - if (!scrollTimer.current || !contentRef.current) return; - saveScrollPos(activeTab.filePath, contentRef.current!.scrollTop); + if (!isMounted.current || !contentRef.current) return; + saveScrollPos(activeTab.filePath, contentRef.current.scrollTop); dispatch({ type: 'UPDATE_TAB_STATE', payload: { tabId: activeTab.id, - scrollTop: contentRef.current?.scrollTop ?? 0, + scrollTop: contentRef.current.scrollTop, }, }); }, 100); From 6cf3e04849b96af5bcd28c4fa212561d302f8e6b Mon Sep 17 00:00:00 2001 From: Ashminita Date: Fri, 5 Jun 2026 12:07:31 +0530 Subject: [PATCH 05/11] refactor(renderer): increase error boundary coverage --- apps/renderer/src/App.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx index ce554f6..f701f34 100644 --- a/apps/renderer/src/App.tsx +++ b/apps/renderer/src/App.tsx @@ -177,6 +177,7 @@ useShortcuts({ )} {activeTab && !isLoading && !error && ( +
{!focusMode && ( - + -
+
)} setShowToast(false)} /> From a7222a57227ce72d32bd6580713664a93a9e2e5a Mon Sep 17 00:00:00 2001 From: Ashminita Date: Fri, 5 Jun 2026 12:12:15 +0530 Subject: [PATCH 06/11] fix(main-processor): remove realpath fallback in folder traversal --- apps/main-processor/src/folder.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/main-processor/src/folder.ts b/apps/main-processor/src/folder.ts index 36bf038..be8555a 100644 --- a/apps/main-processor/src/folder.ts +++ b/apps/main-processor/src/folder.ts @@ -1,4 +1,4 @@ -import { readdir, realpath } from 'node:fs/promises'; +import { readdir } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { FileType } from '@package/shared-types'; import { isMarkdownFile } from './utils/helper/path-helper'; @@ -10,13 +10,7 @@ export async function getFolder( currentDepth = 0, seenPaths = new Set() ): Promise { - let realFolderPath: string; - try { - realFolderPath = await realpath(folderPath); - } catch { - realFolderPath = folderPath; - } - if (currentDepth >= maxDepth || seenPaths.has(realFolderPath)) { + if (currentDepth >= maxDepth || seenPaths.has(folderPath)) { return { name: basename(folderPath), path: folderPath, @@ -24,7 +18,7 @@ export async function getFolder( children: [], }; } - seenPaths.add(realFolderPath); + seenPaths.add(folderPath); const entries = (await readdir(folderPath, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name) ); From 6843c06319676ad4340d9f89fe6619c820a32c36 Mon Sep 17 00:00:00 2001 From: Ashminita Date: Fri, 5 Jun 2026 12:46:13 +0530 Subject: [PATCH 07/11] fix(ipc): restrict validateSender from accepting file:// origins to known renderer locations --- apps/main-processor/__tests__/ipc.test.ts | 13 +++++++++++++ .../src/utils/constants/ipc-validation.ts | 8 +++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/main-processor/__tests__/ipc.test.ts b/apps/main-processor/__tests__/ipc.test.ts index 7985fc4..966d41f 100644 --- a/apps/main-processor/__tests__/ipc.test.ts +++ b/apps/main-processor/__tests__/ipc.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi } from 'vitest'; import { realpath, stat } from 'node:fs/promises'; import { validatePath, validateSender } from '../src/utils/constants/ipc-validation'; import { resolveMarkdownFilePath } from '../src/utils/helper/ipc-path-resolver'; +import { pathToFileURL } from 'node:url'; +import { PATHS } from '../src/utils/constants/path-constants'; vi.mock('node:fs/promises', () => ({ realpath: vi.fn(), @@ -37,6 +39,17 @@ describe('ipc - validation test', () => { expect(validateSender(event)).toBe(false); }); + it('returns true for valid file:// url matching RENDERER_HTML', () => { + const validUrl = pathToFileURL(PATHS.RENDERER_HTML).href; + const event = mockEvent(validUrl); + expect(validateSender(event)).toBe(true); + }); + + it('returns false for invalid file:// url', () => { + const event = mockEvent('file:///etc/passwd'); + expect(validateSender(event)).toBe(false); + }); + //test 3:- to check correct file path it('returns true for a valid file path string', () => { expect(validatePath('C:\\Users\\file.md')).toBe(true); diff --git a/apps/main-processor/src/utils/constants/ipc-validation.ts b/apps/main-processor/src/utils/constants/ipc-validation.ts index b48925d..cbbe155 100644 --- a/apps/main-processor/src/utils/constants/ipc-validation.ts +++ b/apps/main-processor/src/utils/constants/ipc-validation.ts @@ -1,5 +1,7 @@ import path from 'path'; +import { fileURLToPath } from 'node:url'; import { IpcMainInvokeEvent } from 'electron'; +import { PATHS } from './path-constants'; // production and dev urls export const ALLOWED_MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']); @@ -16,7 +18,11 @@ export function validateSender(event: IpcMainInvokeEvent): boolean { const parsedUrl = new URL(url); if (parsedUrl.protocol === 'file:') { - return true; + parsedUrl.hash = ''; + parsedUrl.search = ''; + const filePath = path.resolve(fileURLToPath(parsedUrl.href)); + const expectedPath = path.resolve(PATHS.RENDERER_HTML); + return filePath === expectedPath; } return parsedUrl.protocol === 'http:' && parsedUrl.hostname === 'localhost'; From 41dd589914f2dfe84042e72c277fd31602d228b1 Mon Sep 17 00:00:00 2001 From: Ashminita Date: Fri, 5 Jun 2026 15:35:02 +0530 Subject: [PATCH 08/11] test(marked): add marked config tests --- apps/renderer/__tests__/config/marked.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 apps/renderer/__tests__/config/marked.test.ts diff --git a/apps/renderer/__tests__/config/marked.test.ts b/apps/renderer/__tests__/config/marked.test.ts new file mode 100644 index 0000000..9a8304d --- /dev/null +++ b/apps/renderer/__tests__/config/marked.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getMarkdown } from '../../src/config/marked'; +import { heading } from '../../src/utils/helpers/heading-helper'; + +vi.mock('../../src/utils/helpers/heading-helper', () => ({ + escapeHtml: (str: string) => str, + heading: vi.fn().mockImplementation((props, registry) => { + const count = registry.get(props.text) || 0; + return `${props.text}-${count}`; + }), +})); + +describe('get markdown instance and registry tests', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return the same instance on multiple calls', () => { + const registry1 = new Map(); + const registry2 = new Map(); + const firstInstance = getMarkdown(registry1); + const secondInstance = getMarkdown(registry2); + expect(firstInstance).toBe(secondInstance); + }); + + it('should pass the correct registry data down to the heading renderer', async () => { + const registry = new Map([['Hello', 5]]); + const instance = getMarkdown(registry); + const result = await instance.parse('# Hello'); + expect(heading).toHaveBeenCalledWith( + expect.objectContaining({ text: 'Hello', depth: 1 }), + registry + ); + expect(result).toContain('Hello-5'); + }); + + it('should switch to a new registry map on a subsequent call', async () => { + const firstRegistry = new Map([['Title', 1]]); + const secondRegistry = new Map([['Title', 99]]); + const instance1 = getMarkdown(firstRegistry); + await instance1.parse('# Title'); + const instance2 = getMarkdown(secondRegistry); + const result = await instance2.parse('# Title'); + expect(result).toContain('Title-99'); + expect(result).not.toContain('Title-1'); + }); + + it('should handle an empty registry map without throwing error', async () => { + const emptyRegistry = new Map(); + const instance = getMarkdown(emptyRegistry); + const parseAction = () => instance.parse('# Empty Test'); + await expect(parseAction()).resolves.not.toThrow(); + }); +}); From 92f228d3d6204e83c285b32ee043ed330468eeb6 Mon Sep 17 00:00:00 2001 From: Ashminita Date: Fri, 5 Jun 2026 15:39:22 +0530 Subject: [PATCH 09/11] fix(marked): fix marked instance recreation --- apps/renderer/src/config/marked.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/renderer/src/config/marked.ts b/apps/renderer/src/config/marked.ts index f4de237..717d079 100644 --- a/apps/renderer/src/config/marked.ts +++ b/apps/renderer/src/config/marked.ts @@ -1,12 +1,17 @@ import { Marked } from 'marked'; import { markedHighlight } from 'marked-highlight'; import { shikiHighlighter } from '../renderer/shiki'; -import { THEMES } from '@package/shared-constants'; import { escapeHtml, heading } from '../utils/helpers/heading-helper'; import { MARKDOWN_LANGUAGES } from '../utils/constants/markdown-constants'; +import { DEFAULT_THEME } from '../utils/constants/theme-constants'; + +let instance: Marked | null = null; +let currentRegistry: Map; export function getMarkdown(registry: Map): Marked { - const instance = new Marked(); + currentRegistry = registry; + if (instance) return instance; + instance = new Marked(); // configure marked with GFM options instance.use({ @@ -14,7 +19,7 @@ export function getMarkdown(registry: Map): Marked { breaks: false, renderer: { heading(props) { - return heading(props, registry); + return heading(props, currentRegistry); }, }, }); @@ -29,7 +34,7 @@ export function getMarkdown(registry: Map): Marked { return escapeHtml(code); } const highlighter = await shikiHighlighter(); - const theme = THEMES[1]; + const theme = DEFAULT_THEME; try { return highlighter.codeToHtml(code, { lang: language, From 20821bc0581234b0c9528e372dec0b552dbb9a32 Mon Sep 17 00:00:00 2001 From: Ashminita Date: Fri, 5 Jun 2026 15:41:24 +0530 Subject: [PATCH 10/11] refactor(renderer): add default theme constant --- apps/renderer/src/types/component-types.ts | 7 ------- apps/renderer/src/utils/constants/theme-constants.ts | 2 ++ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/renderer/src/types/component-types.ts b/apps/renderer/src/types/component-types.ts index 82a1cde..c83978c 100644 --- a/apps/renderer/src/types/component-types.ts +++ b/apps/renderer/src/types/component-types.ts @@ -36,13 +36,6 @@ export interface ThemeContextType { setTheme: (theme: Theme) => void; } -export type BuiltThemeType = - | 'github-light' - | 'github-dark' - | 'notion' - | 'nord' - | 'minimal' - | 'dracula'; export interface HeadingProps { text: string; depth: number; diff --git a/apps/renderer/src/utils/constants/theme-constants.ts b/apps/renderer/src/utils/constants/theme-constants.ts index a405cc8..45e8951 100644 --- a/apps/renderer/src/utils/constants/theme-constants.ts +++ b/apps/renderer/src/utils/constants/theme-constants.ts @@ -6,3 +6,5 @@ export const APPTHEMES = [ 'minimal', 'dracula', ] as const; + +export const DEFAULT_THEME = 'github-dark'; From 25803409a9deeb2613b2518d4768663c2f7a785f Mon Sep 17 00:00:00 2001 From: Ashminita Date: Fri, 5 Jun 2026 15:46:51 +0530 Subject: [PATCH 11/11] test(markdown): add heading edge case test in markdown renderer --- apps/renderer/__tests__/renderer/markdown.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/renderer/__tests__/renderer/markdown.test.ts b/apps/renderer/__tests__/renderer/markdown.test.ts index f711eb0..9e8dd11 100644 --- a/apps/renderer/__tests__/renderer/markdown.test.ts +++ b/apps/renderer/__tests__/renderer/markdown.test.ts @@ -55,4 +55,12 @@ describe('renderMarkdown', () => { `); expect(html).toContain('onerror'); }); + + it('it should reset heading counters between independent render markdown calls', async () => { + const html1 = await renderMarkdown('# Hello World'); + expect(html1).toContain('

Hello World

'); + const html2 = await renderMarkdown('# Hello World'); + expect(html2).toContain('

Hello World

'); + expect(html2).not.toContain('id="hello-world-1"'); + }); });