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¶
- Content Security Policy (CSP)
- Secrets Management
- Dependency Security
- Input Validation
- CI/CD Security
- 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
- our own origin (
-
Inline scripts are not allowed by default.
Only specific inline snippets are allowed, using SHA-256 hashes generated at build time. -
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.
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'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 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 addunsafe-inlinefor 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-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.
Local Development Notes¶
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
Testing CSP Locally¶
Option 1: Disable clientPrerender temporarily
Then run:
Check dist/_headers for generated CSP, then re-enable clientPrerender before committing.
Option 2: Test via preview server
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):
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:
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:
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 Pagesid-token: write- OIDC authenticationpull-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:
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: 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)
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)
-
.envfiles in.gitignore
Dependencies¶
-
npm auditpasses 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
dangerouslySetInnerHTMLwith 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-securitychecks - 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