Skip to content

Security Standards

This document covers security practices for this site, including Content Security Policy (CSP), secrets management, dependency security, input validation, and CI/CD hardening.


Table of Contents

  1. Content Security Policy (CSP)
  2. Secrets Management
  3. Dependency Security
  4. Input Validation
  5. CI/CD Security
  6. Security Checklist

Content Security Policy (CSP)

What is CSP?

CSP tells browsers which scripts, styles, and resources are safe to load. Think of it as a bouncer that checks IDs — only pre-approved code gets in. This blocks XSS attacks by stopping malicious scripts from running, even if they slip into your HTML.

The goal is simple and strict:

  • Only run JavaScript we control.
  • Keep browser rules predictable.
  • Accept a bit of noise from Cloudflare rather than weaken the policy.

Why We Did This

The site started with a simple color scheme rebrand. While updating styles, we noticed inline scripts weren't following best practices. This led us down a CSP hardening path to eliminate XSS risks entirely.

Trade-off: We accept that some Cloudflare edge scripts get blocked rather than weaken our policy. Core functionality is unaffected.

CSP Implementation

We set a CSP header via the _headers file in the built dist/ folder.

Key points:

  • default-src 'self'
    All resources (unless overridden) must come from our own origin.

  • script-src 'self' https://static.cloudflareinsights.com ...hashes...

  • JS is only allowed from:
    • our own origin ('self')
    • Cloudflare’s analytics endpoint
  • Inline scripts are not allowed by default.
    Only specific inline snippets are allowed, using SHA-256 hashes generated at build time.

  • No unsafe-inline for scripts
    We deliberately do not allow arbitrary inline JavaScript.
    This reduces the risk of XSS and makes it harder for injected code to run.

  • Styles are looser
    We currently allow style-src 'self' 'unsafe-inline' because of framework and Tailwind usage. This is a reasonable trade-off for this site.

JavaScript Layout

To keep the CSP simple and stable:

  • Navigation / mobile menu behavior lives in a separate JS module:
  • Example: /_astro/nav.<hash>.js
  • Loaded with <script type="module" src="/_astro/nav..."></script>.

  • Mermaid diagram rendering also uses separate modules:

  • /_astro/projects-mermaid.<hash>.js (lazy-loaded on projects page)
  • /_astro/architecture-mermaid.<hash>.js (lazy-loaded on architecture page)
  • Uses IntersectionObserver for viewport-based loading

  • These work with 'self' in script-src and need no hash or nonce.

  • A few small inline scripts are still needed (theme init, Astro runtime bits). These are allowed via explicit CSP hashes generated by a build script.

Cloudflare Behavior and CSP Warnings

What Cloudflare Injects

Cloudflare may inject its own inline script for bot protection or JS challenges:

<script>
  (function () {
    // Creates a hidden iframe and injects a script that hits /cdn-cgi/...
    // Includes dynamic tokens (r: '...', t: '...')
  })();
</script>

Why It Gets Blocked

  • Script is not in our source — added by Cloudflare at the edge
  • Contents change per request — cannot pre-compute a CSP hash
  • It has no nonce — cannot allowlist dynamically

This is Intentional

Our CSP blocks this inline script. Browser console shows:

Executing inline script violates the following Content Security Policy directive…

This is expected. We prefer strict CSP over allowing dynamic Cloudflare injections. Core site features (navigation, content, theme toggle) still work as designed.

Future Options

If needed, we can:

  • Try to tune Cloudflare settings to reduce or remove these injections, or
  • Loosen CSP on specific paths, but that's not required today.

Lighthouse Suggestions

Lighthouse may show a few security suggestions:

  • “Add Trusted Types directive”
    This is an extra hardening step against XSS.
    It’s useful for large, dynamic apps, but is not necessary for this mostly static personal site right now.

  • “Host allowlists can be bypassed; use nonces/hashes and 'strict-dynamic'”
    We already use hashes for inline scripts and only allow JS from 'self'

  • Cloudflare Insights.
    If we ever move to heavy SSR and lots of dynamic JS, we can revisit a nonce-based policy with 'strict-dynamic'.

  • “Consider adding 'unsafe-inline' for backwards compatibility”
    We intentionally do not add unsafe-inline for scripts. Older browsers are not a priority for this site, and security wins here.

Future Improvements

If the site grows more complex or moves to SSR, possible next steps are:

  • Switch to a nonce-based CSP for inline scripts (script-src 'self' 'nonce-...' 'strict-dynamic').
  • Add a Trusted Types policy if we start doing dynamic HTML string work.
  • Tighten style-src once the CSS pipeline is more stable.

For the current scope (personal site, static Astro build), the existing setup is a good balance of safety, simplicity, and practicality.

Local Development Notes

The site uses Astro's experimental.clientPrerender feature for improved client-side routing. This affects local CSP testing:

  • Local builds generate .mjs files instead of .html files
  • The generate-csp-headers.mjs script requires static HTML files to parse
  • Production builds on Cloudflare Pages work correctly

Testing CSP Locally

Option 1: Disable clientPrerender temporarily

// astro.config.mjs
// experimental: {
//   clientPrerender: true,
// },

Then run:

npm run build && node scripts/generate-csp-headers.mjs

Check dist/_headers for generated CSP, then re-enable clientPrerender before committing.

Option 2: Test via preview server

npm run build
npm run preview

The preview server serves HTML dynamically, so you can inspect CSP headers in the browser's Network tab (though _headers won't be generated locally).


Secrets Management

Never Embed Secrets in Client Code

Static sites bundle everything at build time. Any secrets in your code end up in the browser.

Rules:

  • Never put API keys, tokens, or credentials in client-side code
  • Build-time environment variables must use PUBLIC_ prefix (signals they're exposed)
  • Runtime secrets require serverless functions (API routes, edge functions)

Build-Time Environment Variables

Astro exposes PUBLIC_* variables to the client:

// ❌ EXPOSED - Don't do this
const API_KEY = import.meta.env.API_KEY;

// ✅ SAFE - Clearly marked as public
const PUBLIC_API_URL = import.meta.env.PUBLIC_API_URL;

When you need secrets:

  • Use Cloudflare Workers/Pages Functions for serverless API routes
  • Store secrets in Cloudflare environment variables (not in code)
  • Access secrets server-side only

Secret Scanning

Prevent accidental commits of secrets:

Local + CI (current choice):

# .husky/pre-commit and .github/workflows/ci.yml
npx secretlint **/*

Secretlint runs in:

  • pre-commit
  • npm run check
  • CI

We keep it simple and consistent across local and CI.

What to Scan For

Common patterns to block:

  • API keys: AIza[0-9A-Za-z-_]{35}
  • AWS credentials: AKIA[0-9A-Z]{16}
  • GitHub tokens: ghp_[a-zA-Z0-9]{36}
  • Private keys: -----BEGIN (RSA|OPENSSH|EC) PRIVATE KEY-----
  • Generic secrets: High-entropy strings in config files

Dependency Security

npm Audit (current choice)

Run security audits regularly:

# Check for vulnerabilities (production only)
npm audit --audit-level=high --production

# Fix automatically where possible
npm audit fix

# Review what would change
npm audit fix --dry-run

CI Integration:

- name: Security audit
  run: npm audit --audit-level=high --production

Dependabot Configuration

Automated dependency updates via .github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: npm
    directory: '/'
    schedule:
      interval: weekly
      day: monday
      time: '04:00'
    open-pull-requests-limit: 10
    reviewers:
      - chrislyons-dev
    labels:
      - dependencies
      - automated
    commit-message:
      prefix: 'chore'
      prefix-development: 'chore'
      include: 'scope'

Security-only updates:

- package-ecosystem: npm
  directory: '/'
  schedule:
    interval: daily
  open-pull-requests-limit: 5
  # Only security patches
  versioning-strategy: increase-if-necessary

Version Pinning

Pin exact versions for reproducibility:

{
  "dependencies": {
    "astro": "5.0.0", // ✅ Exact version
    "react": "19.2.1" // ✅ Exact version
  },
  "devDependencies": {
    "vitest": "2.1.8" // ✅ Exact version
  }
}

Why pin?

  • Predictable builds across environments
  • Prevent unexpected breaking changes
  • Easier to track security issues
  • Explicit upgrades via Dependabot

Subresource Integrity (SRI)

For CDN-loaded scripts, use SRI hashes:

<script
  src="https://cdn.example.com/library.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  crossorigin="anonymous"
></script>

Generate SRI hashes:

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

Automated SRI generation:

// scripts/generate-sri.mjs
import { createHash } from 'crypto';
import fetch from 'node-fetch';

const url = 'https://cdn.example.com/library.js';
const response = await fetch(url);
const content = await response.text();
const hash = createHash('sha384').update(content).digest('base64');
console.log(`sha384-${hash}`);

Input Validation

Zod for All Boundaries

Validate all external input with Zod schemas:

Content Collections:

// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const projects = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string().min(1).max(100),
    description: z.string().min(10).max(500),
    tech: z.array(z.string()).min(1).max(20),
    featured: z.boolean().default(false),
    publishDate: z.coerce.date(),
    url: z.string().url().optional(),
  }),
});

Form Validation:

import { z } from 'zod';

const contactFormSchema = z.object({
  name: z.string().min(1, 'Name required').max(100),
  email: z.string().email('Invalid email').max(254),
  message: z.string().min(10, 'Message too short').max(5000),
});

// Validate before processing
const result = contactFormSchema.safeParse(formData);
if (!result.success) {
  return { errors: result.error.flatten() };
}

URL Parameter Validation:

const slugSchema = z.string().regex(/^[a-z0-9-]+$/);
const pageSchema = z.coerce.number().int().positive().max(1000);

// Validate route parameters
const slug = slugSchema.parse(Astro.params.slug);
const page = pageSchema.parse(Astro.url.searchParams.get('page'));

XSS Prevention

React/Astro auto-escape by default:

// ✅ Automatically escaped
function Component({ userInput, project }) {
  return (
    <>
      <h1>{userInput}</h1>
      <p>{project.description}</p>
    </>
  );
}

When you need HTML sanitization:

import DOMPurify from 'isomorphic-dompurify';

const cleanHTML = DOMPurify.sanitize(userHTML, {
  ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'ul', 'ol', 'li'],
  ALLOWED_ATTR: ['href', 'target', 'rel'],
});

Dangerous patterns to avoid:

// ❌ NEVER do this with user input
function BadExample1() {
  return <div dangerouslySetInnerHTML={{ __html: userInput }} />;
}
<!-- ❌ NEVER interpolate user input in script tags -->
<script>
  const data = { userInput };
</script>

<!-- ✅ Use JSON.stringify and validate -->
<script define:vars={{ data: JSON.parse(validatedJSON) }}>
  // Use data safely
</script>

CI/CD Security

Least Privilege for GitHub Actions

Minimize token permissions:

# .github/workflows/ci.yml
name: CI

permissions:
  contents: read # Read-only by default
  pull-requests: write # Only for PR comments

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read # Override per-job if needed

    steps:
      - uses: actions/checkout@v4

Common permission levels:

  • contents: read - Clone repo (most workflows)
  • contents: write - Push commits (docs updates, releases)
  • pages: write - Deploy to GitHub Pages
  • id-token: write - OIDC authentication
  • pull-requests: write - Comment on PRs

Pin Action Versions to SHA

# ❌ DANGEROUS - tags can be moved
- uses: actions/checkout@v4

# ✅ SAFE - SHA is immutable
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

Why SHA pinning?

  • Tags can be force-pushed to malicious code
  • SHAs are immutable
  • Add comment with version for readability

Update pinned actions:

# Use Dependabot to keep SHAs updated
# .github/dependabot.yml
- package-ecosystem: github-actions
  directory: "/"
  schedule:
    interval: weekly

Static Analysis (current choice)

We run eslint with eslint-plugin-security to catch common JS/TS footguns:

npm run lint

If you want deeper SAST (CodeQL), add a dedicated GitHub Actions workflow.

Secure Environment Variables

Never log secrets:

- name: Deploy
  env:
    API_KEY: ${{ secrets.API_KEY }}
  run: |
    # ❌ NEVER echo secrets
    # echo "API_KEY=$API_KEY"

    # ✅ Use secrets safely
    ./deploy.sh

GitHub automatically masks registered secrets in logs:

- name: Test
  env:
    SECRET_TOKEN: ${{ secrets.SECRET_TOKEN }}
  run: npm test
  # If $SECRET_TOKEN appears in output, GitHub redacts it as ***

Security Checklist

Before deploying to production:

Headers & CSP

  • Content Security Policy configured and tested
  • X-Frame-Options: DENY set
  • X-Content-Type-Options: nosniff set
  • Referrer-Policy: strict-origin-when-cross-origin set
  • Permissions-Policy configured (camera, microphone, geolocation disabled)
  • HTTPS enforced (HSTS header if applicable)

Secrets & Environment

  • No secrets in client-side code
  • All public env vars use PUBLIC_ prefix
  • Runtime secrets use serverless functions
  • Secret scanning enabled (git hooks or CI)
  • .env files in .gitignore

Dependencies

  • npm audit passes with no high/critical vulnerabilities
  • Dependabot enabled for automated updates
  • Production dependencies pinned to exact versions
  • CDN scripts use Subresource Integrity (SRI)

Input Validation

  • All user input validated with Zod
  • Content collections use schema validation
  • No dangerouslySetInnerHTML with user input
  • URL parameters validated before use

CI/CD

  • GitHub Actions use least privilege permissions
  • Action versions pinned to SHA (not tags)
  • Lint includes eslint-plugin-security checks
  • No secrets logged in CI output

Deployment

  • HTTPS enforced on all routes
  • Security headers verified in production
  • CSP tested and working (check browser console)
  • Error messages don't leak sensitive information