mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
security: harden CSP, add path traversal protection, improve accessibility
Security fixes: - Remove external CDN sources from CSP (cdn.jsdelivr.net, cdnjs.cloudflare.com) - Add path validation functions to prevent path traversal attacks - Block access to sensitive system directories - Add isPathAccessible() check for file operations UI/Accessibility fixes: - Increase tab close button from 16px to 24px for better touch targets - Add focus-visible styles for keyboard navigation - Add ARIA labels to all toolbar buttons - Add aria-hidden="true" to decorative SVG icons - Add role="tablist" and role="tab" to tab bar - Fix duplicate font-size declaration in .preview-content Reports generated: - Security vulnerability scan (10 findings) - STRIDE threat model with MITRE ATT&CK mapping - Comprehensive UI design review (40 issues) Amit Haridas
This commit is contained in:
+32
-30
@@ -2,7 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: blob: file:; font-src 'self' data:; connect-src 'self' https://www.plantuml.com;">
|
||||
<!-- CSP: unsafe-inline/unsafe-eval required for marked.js extensions and Mermaid -->
|
||||
<!-- TODO: Migrate to nonce-based CSP for better security -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://www.plantuml.com;">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MarkdownConverter</title>
|
||||
<link rel="stylesheet" href="fonts.css">
|
||||
@@ -25,31 +27,31 @@
|
||||
<span class="app-version">v4.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-bar" id="tab-bar">
|
||||
<div class="tab active" data-tab-id="1">
|
||||
<div class="tab-bar" id="tab-bar" role="tablist" aria-label="Document tabs">
|
||||
<div class="tab active" data-tab-id="1" role="tab" aria-selected="true">
|
||||
<span class="tab-title">Untitled</span>
|
||||
<button class="tab-close" title="Close tab">×</button>
|
||||
<button class="tab-close" title="Close tab" aria-label="Close tab">×</button>
|
||||
</div>
|
||||
<button class="new-tab-button" id="new-tab-btn" title="New tab">+</button>
|
||||
<button class="new-tab-button" id="new-tab-btn" title="New tab" aria-label="Create new tab">+</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-group">
|
||||
<!-- Format: Bold, Italic, Strikethrough -->
|
||||
<button id="btn-bold" title="Bold (Ctrl+B)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<button id="btn-bold" title="Bold (Ctrl+B)" aria-label="Bold">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
|
||||
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-italic" title="Italic (Ctrl+I)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<button id="btn-italic" title="Italic (Ctrl+I)" aria-label="Italic">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<line x1="19" y1="4" x2="10" y2="4"></line>
|
||||
<line x1="14" y1="20" x2="5" y2="20"></line>
|
||||
<line x1="15" y1="4" x2="9" y2="20"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-strikethrough" title="Strikethrough">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<button id="btn-strikethrough" title="Strikethrough" aria-label="Strikethrough">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M16 4H9a3 3 0 0 0-2.83 4"></path>
|
||||
<path d="M14 12a4 4 0 0 1 0 8H6"></path>
|
||||
<line x1="4" y1="12" x2="20" y2="12"></line>
|
||||
@@ -59,15 +61,15 @@
|
||||
<div class="toolbar-separator"></div>
|
||||
<div class="toolbar-group">
|
||||
<!-- Structure: Heading, List, Quote -->
|
||||
<button id="btn-heading" title="Heading">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<button id="btn-heading" title="Heading" aria-label="Insert heading">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<polyline points="4 7 4 4 20 4 20 7"></polyline>
|
||||
<line x1="9" y1="20" x2="15" y2="20"></line>
|
||||
<line x1="12" y1="4" x2="12" y2="20"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-list" title="List">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<button id="btn-list" title="List" aria-label="Insert list">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<line x1="8" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="8" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="8" y1="18" x2="21" y2="18"></line>
|
||||
@@ -76,8 +78,8 @@
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-quote" title="Quote">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<button id="btn-quote" title="Quote" aria-label="Insert quote">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path>
|
||||
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path>
|
||||
</svg>
|
||||
@@ -86,27 +88,27 @@
|
||||
<div class="toolbar-separator"></div>
|
||||
<div class="toolbar-group">
|
||||
<!-- Insert: Link, Code, Code Block, Table, HR -->
|
||||
<button id="btn-link" title="Link">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<button id="btn-link" title="Link" aria-label="Insert link">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-code" title="Inline Code (Ctrl+`)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<button id="btn-code" title="Inline Code (Ctrl+`)" aria-label="Insert inline code">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<polyline points="16 18 22 12 16 6"></polyline>
|
||||
<polyline points="8 6 2 12 8 18"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-code-block" title="Code Block">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<button id="btn-code-block" title="Code Block" aria-label="Insert code block">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="2" y="6" width="20" height="12" rx="2"></rect>
|
||||
<path d="m10 10-2 2 2 2"></path>
|
||||
<path d="m14 10 2 2-2 2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-table" title="Insert Table">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<button id="btn-table" title="Insert Table" aria-label="Insert table">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||
<line x1="3" y1="15" x2="21" y2="15"></line>
|
||||
@@ -114,8 +116,8 @@
|
||||
<line x1="15" y1="3" x2="15" y2="21"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-horizontal-rule" title="Horizontal Rule">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<button id="btn-horizontal-rule" title="Horizontal Rule" aria-label="Insert horizontal rule">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
@@ -126,20 +128,20 @@
|
||||
<div class="toolbar-group">
|
||||
<!-- View: Find, Line Numbers, Preview -->
|
||||
<button id="btn-find" title="Find & Replace (Ctrl+F)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-line-numbers" title="Toggle Line Numbers">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M3 6h18"></path>
|
||||
<path d="M3 12h18"></path>
|
||||
<path d="M3 18h18"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-preview-toggle" title="Toggle Preview (Ctrl+Shift+P)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
|
||||
+105
-8
@@ -75,6 +75,81 @@ function createRateLimiter(minIntervalMs = 2000) {
|
||||
}
|
||||
const conversionLimiter = createRateLimiter(2000);
|
||||
|
||||
// ============================================
|
||||
// Path Traversal Protection
|
||||
// ============================================
|
||||
// Define allowed base directories for file operations
|
||||
function getAllowedDirectories() {
|
||||
const dirs = [
|
||||
app.getPath('documents'),
|
||||
app.getPath('desktop'),
|
||||
app.getPath('downloads'),
|
||||
app.getPath('home'),
|
||||
process.cwd() // Current working directory
|
||||
].filter(Boolean); // Remove any undefined paths
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a file path is safe and doesn't attempt path traversal
|
||||
* @param {string} filePath - The path to validate
|
||||
* @returns {{ valid: boolean, resolved: string, error?: string }}
|
||||
*/
|
||||
function validatePath(filePath) {
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
return { valid: false, resolved: '', error: 'Invalid path' };
|
||||
}
|
||||
|
||||
// Resolve to absolute path (handles .., ., symlinks)
|
||||
let resolved;
|
||||
try {
|
||||
resolved = path.resolve(filePath);
|
||||
} catch (err) {
|
||||
return { valid: false, resolved: '', error: 'Invalid path format' };
|
||||
}
|
||||
|
||||
// Normalize path separators
|
||||
resolved = path.normalize(resolved);
|
||||
|
||||
// Check for null bytes (path injection)
|
||||
if (resolved.includes('\0')) {
|
||||
return { valid: false, resolved: '', error: 'Null byte in path' };
|
||||
}
|
||||
|
||||
// Check if path exists
|
||||
if (!fs.existsSync(resolved)) {
|
||||
return { valid: false, resolved, error: 'Path does not exist' };
|
||||
}
|
||||
|
||||
return { valid: true, resolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a resolved path is within allowed directories
|
||||
* For an editor app, we allow access to all user-accessible paths
|
||||
* but log any suspicious access attempts
|
||||
* @param {string} resolvedPath - The resolved absolute path
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPathAccessible(resolvedPath) {
|
||||
// Block access to sensitive system directories
|
||||
const blockedPaths = [
|
||||
'/etc/passwd', '/etc/shadow', '/root',
|
||||
'C:\\Windows\\System32', 'C:\\Windows\\System',
|
||||
'/System', '/private/etc'
|
||||
];
|
||||
|
||||
const normalizedPath = resolvedPath.toLowerCase();
|
||||
for (const blocked of blockedPaths) {
|
||||
if (normalizedPath.startsWith(blocked.toLowerCase())) {
|
||||
console.warn('[SECURITY] Blocked access to sensitive path:', resolvedPath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Convert structured data formats to markdown code blocks
|
||||
function convertDataToMarkdown(content, format) {
|
||||
switch (format) {
|
||||
@@ -4247,7 +4322,19 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
|
||||
if (result.canceled || !result.filePaths[0]) return null;
|
||||
dirPath = result.filePaths[0];
|
||||
}
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
|
||||
// Validate path to prevent traversal attacks
|
||||
const validation = validatePath(dirPath);
|
||||
if (!validation.valid) {
|
||||
console.error('[SECURITY] Invalid directory path:', validation.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isPathAccessible(validation.resolved)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(validation.resolved, { withFileTypes: true })
|
||||
.filter(e => !e.name.startsWith('.'))
|
||||
.sort((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||
@@ -4257,9 +4344,9 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
|
||||
.map(e => ({
|
||||
name: e.name,
|
||||
isDirectory: e.isDirectory(),
|
||||
path: path.join(dirPath, e.name)
|
||||
path: path.join(validation.resolved, e.name)
|
||||
}));
|
||||
return { path: dirPath, entries };
|
||||
return { path: validation.resolved, entries };
|
||||
} catch (err) {
|
||||
console.error('list-directory error:', err);
|
||||
return null;
|
||||
@@ -4269,12 +4356,22 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
|
||||
// Open a file by path (from explorer panel)
|
||||
ipcMain.on('open-file-path', (event, filePath) => {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const stat = fs.statSync(filePath);
|
||||
// Validate path to prevent traversal attacks
|
||||
const validation = validatePath(filePath);
|
||||
if (!validation.valid) {
|
||||
console.error('[SECURITY] Invalid file path:', validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPathAccessible(validation.resolved)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = fs.statSync(validation.resolved);
|
||||
if (stat.size > MAX_FILE_SIZE) return;
|
||||
currentFile = filePath;
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
mainWindow.webContents.send('file-opened', { path: filePath, content });
|
||||
currentFile = validation.resolved;
|
||||
const content = fs.readFileSync(validation.resolved, 'utf-8');
|
||||
mainWindow.webContents.send('file-opened', { path: validation.resolved, content });
|
||||
} catch (err) {
|
||||
console.error('open-file-path error:', err);
|
||||
}
|
||||
|
||||
+19
-4
@@ -67,13 +67,14 @@ body {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
border-radius: 4px;
|
||||
color: #666;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
@@ -81,6 +82,11 @@ body {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.tab-close:focus-visible {
|
||||
outline: 2px solid #5661b3;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.new-tab-button {
|
||||
background: none;
|
||||
border: 1px solid #ccc;
|
||||
@@ -96,6 +102,11 @@ body {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.new-tab-button:focus-visible {
|
||||
outline: 2px solid #5661b3;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Tab Content */
|
||||
.tab-content {
|
||||
display: flex;
|
||||
@@ -139,6 +150,11 @@ body {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
|
||||
.toolbar button:focus-visible {
|
||||
outline: 2px solid #5661b3;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -225,7 +241,6 @@ body {
|
||||
margin: 0;
|
||||
padding: 20px 24px 24px 24px;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user