This document covers security practices for this site, including Content Security Policy (CSP), secrets management, dependency security, input validation, and CI/CD hardening.
- Content Security Policy (CSP)
- Secrets Management
- Dependency Security
- Input Validation
- CI/CD Security
- Security Checklist
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.
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.
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') - Cloudflares analytics endpoint
- our own origin (
- Inline scripts are not allowed by default.
Only specific inline snippets are allowed, using SHA-256 hashes generated at build time.
- JS is only allowed from:
-
No
unsafe-inlinefor 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 allowstyle-src 'self' 'unsafe-inline'because of framework and Tailwind usage. This is a reasonable trade-off for this site.
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>.
- Example:
-
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'inscript-srcand 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 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>- 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
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.
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 may show a few security suggestions:
-
Add Trusted Types directive
This is an extra hardening step against XSS.
Its 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'.
- Cloudflare Insights.
-
Consider adding 'unsafe-inline' for backwards compatibility
We intentionally do not addunsafe-inlinefor scripts. Older browsers are not a priority for this site, and security wins here.
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-srconce 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.
The site uses Astro's experimental.clientPrerender feature for improved client-side
routing. This affects local CSP testing:
- Local builds generate
.mjsfiles instead of.htmlfiles - The
generate-csp-headers.mjsscript requires static HTML files to parse - Production builds on Cloudflare Pages work correctly
Option 1: Disable clientPrerender temporarily
// astro.config.mjs
// experimental: {
// clientPrerender: true,
// },Then run:
npm run build && node scripts/generate-csp-headers.mjsCheck dist/_headers for generated CSP, then re-enable clientPrerender before committing.
Option 2: Test via preview server
npm run build
npm run previewThe preview server serves HTML dynamically, so you can inspect CSP headers in the browser's
Network tab (though _headers won't be generated locally).
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)
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
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.
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
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-runCI Integration:
- name: Security audit
run: npm audit --audit-level=high --productionAutomated 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-necessaryPin 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
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 -AAutomated 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}`);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'));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>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@v4Common permission levels:
contents: read- Clone repo (most workflows)contents: write- Push commits (docs updates, releases)pages: write- Deploy to GitHub Pagesid-token: write- OIDC authenticationpull-requests: write- Comment on PRs
# DANGEROUS - tags can be moved
- uses: actions/checkout@v4
# SAFE - SHA is immutable
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1Why 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: weeklyWe run eslint with eslint-plugin-security to catch common JS/TS footguns:
npm run lintIf you want deeper SAST (CodeQL), add a dedicated GitHub Actions workflow.
Never log secrets:
- name: Deploy
env:
API_KEY: ${{ secrets.API_KEY }}
run: |
# NEVER echo secrets
# echo "API_KEY=$API_KEY"
# Use secrets safely
./deploy.shGitHub 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 ***Before deploying to production:
- Content Security Policy configured and tested
-
X-Frame-Options: DENYset -
X-Content-Type-Options: nosniffset -
Referrer-Policy: strict-origin-when-cross-originset -
Permissions-Policyconfigured (camera, microphone, geolocation disabled) - HTTPS enforced (HSTS header if applicable)
- 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)
-
.envfiles in.gitignore
-
npm auditpasses with no high/critical vulnerabilities - Dependabot enabled for automated updates
- Production dependencies pinned to exact versions
- CDN scripts use Subresource Integrity (SRI)
- All user input validated with Zod
- Content collections use schema validation
- No
dangerouslySetInnerHTMLwith user input - URL parameters validated before use
- GitHub Actions use least privilege permissions
- Action versions pinned to SHA (not tags)
- Lint includes
eslint-plugin-securitychecks - No secrets logged in CI output
- HTTPS enforced on all routes
- Security headers verified in production
- CSP tested and working (check browser console)
- Error messages don't leak sensitive information