diff --git a/Caddyfile.example b/Caddyfile.example index 900cc09..de2b70b 100644 --- a/Caddyfile.example +++ b/Caddyfile.example @@ -2,10 +2,12 @@ # # Security headers below address common baseline scan failures: # Strict-Transport-Security — HTTPS-only (HSTS) -# Content-Security-Policy — XSS / injection hardening # X-Frame-Options — clickjacking protection -# CSP is intentionally practical for Next.js (inline theme bootstrap + styles). -# Tighten script-src (nonces/hashes) later if you outgrow 'unsafe-inline'. +# COOP / CORP / COEP — cross-origin isolation (defense-in-depth) +# +# Content-Security-Policy is set by the Next.js proxy (frontend/src/proxy.ts) +# with per-request nonces — do NOT re-declare CSP here or scanners / browsers +# will see a second policy that still allows unsafe-inline and breaks nonces. operationchildshield.org, www.operationchildshield.org { encode gzip @@ -14,7 +16,7 @@ operationchildshield.org, www.operationchildshield.org { # Force HTTPS for 1 year on this host and subdomains Strict-Transport-Security "max-age=31536000; includeSubDomains" - # Clickjacking: deny framing (also covered by CSP frame-ancestors) + # Clickjacking: deny framing (also covered by Next CSP frame-ancestors) X-Frame-Options "DENY" # MIME sniffing protection @@ -26,8 +28,11 @@ operationchildshield.org, www.operationchildshield.org { # Disable unused powerful browser features Permissions-Policy "camera=(), microphone=(), geolocation=()" - # Baseline CSP for Next.js App Router + congress.gov member photos - Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; img-src 'self' data: blob: https://www.congress.gov; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' https://operationchildshield.org https://www.operationchildshield.org; upgrade-insecure-requests" + # Cross-origin isolation (defense-in-depth) + # COEP credentialless allows third-party images (e.g. congress.gov) without CORP + Cross-Origin-Opener-Policy "same-origin" + Cross-Origin-Resource-Policy "same-origin" + Cross-Origin-Embedder-Policy "credentialless" # Reduce server fingerprinting (Next.js / uvicorn / Caddy defaults) -Server diff --git a/deploy/Caddyfile.ip b/deploy/Caddyfile.ip index 1bb35fd..9fc383b 100644 --- a/deploy/Caddyfile.ip +++ b/deploy/Caddyfile.ip @@ -2,6 +2,7 @@ # Replace :80 with your domain once DNS A records are ready (see Caddyfile.example). # # HSTS is intentionally omitted on plain HTTP. +# CSP is set by the Next.js proxy (frontend/src/proxy.ts) with nonces. :80 { encode gzip @@ -11,7 +12,9 @@ X-Content-Type-Options "nosniff" Referrer-Policy "strict-origin-when-cross-origin" Permissions-Policy "camera=(), microphone=(), geolocation=()" - Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; img-src 'self' data: blob: https://www.congress.gov; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'" + Cross-Origin-Opener-Policy "same-origin" + Cross-Origin-Resource-Policy "same-origin" + Cross-Origin-Embedder-Policy "credentialless" -Server -X-Powered-By } diff --git a/docs/DEPLOY-GITHUB-DO.md b/docs/DEPLOY-GITHUB-DO.md index f631b72..7710623 100644 --- a/docs/DEPLOY-GITHUB-DO.md +++ b/docs/DEPLOY-GITHUB-DO.md @@ -83,24 +83,28 @@ Production `Caddyfile.example` sets baseline browser security headers on both ap | Header | Purpose | |--------|---------| | `Strict-Transport-Security` | HSTS — force HTTPS for 1 year (+ subdomains) | -| `Content-Security-Policy` | Limit script/style/img/connect sources (Next.js-friendly) | | `X-Frame-Options` | Deny framing (clickjacking) | | `X-Content-Type-Options` | `nosniff` | | `Referrer-Policy` / `Permissions-Policy` | Extra hardening | +| `Cross-Origin-Opener-Policy` | `same-origin` — isolate top-level browsing context | +| `Cross-Origin-Resource-Policy` | `same-origin` — block cross-site embedding of our resources | +| `Cross-Origin-Embedder-Policy` | `credentialless` — isolation without breaking congress.gov images | + +**Content-Security-Policy** is set by the Next.js app (`frontend/src/proxy.ts`) with **per-request nonces** — no `script-src 'unsafe-inline'` / `'unsafe-eval'` in production. Do **not** re-declare CSP in Caddy (a second policy would defeat nonces and reintroduce scanner findings). Also: - Strips `Server` and `X-Powered-By` from responses - Overwrites `X-Real-IP` / `X-Forwarded-For` with the real peer (blocks client XFF spoofing used against app rate limits) -After updating the server `Caddyfile`, reload Caddy: +After updating the server `Caddyfile`, reload Caddy (and redeploy the frontend image so proxy CSP is live): ```bash ssh -i ~/.ssh/YOUR_DEPLOY_KEY USER@HOST cd /opt/operationchildshield docker compose -f docker-compose.prod.yml exec caddy caddy reload --config /etc/caddy/Caddyfile # verify: -curl -sI https://operationchildshield.org/ | grep -iE 'strict-transport|content-security|x-frame|x-content-type' +curl -sI https://operationchildshield.org/ | grep -iE 'strict-transport|content-security|x-frame|x-content-type|cross-origin' ``` ## Backend security notes diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 753948c..405fca8 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -2,7 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", - // Prefer Caddy for full security headers in prod; hide framework fingerprint here too. + // CSP nonces are applied in src/proxy.ts. Caddy sets HSTS/COOP/CORP/COEP. poweredByHeader: false, async redirects() { return [ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 7c1ffa2..4b8642d 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -89,8 +89,7 @@ body { padding: 0.5rem 0 1.25rem; background: linear-gradient(rgba(30, 64, 175, 0.45), rgba(30, 64, 175, 0.55)), - url("https://t4.ftcdn.net/jpg/08/24/57/41/360_F_824574155_qai1qN0Wxhdb6owwcolxExe8aLjiIiat.jpg") - center / cover no-repeat; + url("/images/american_flag.jpg") center / cover no-repeat; color: white; display: flex; align-items: center; @@ -102,8 +101,7 @@ body { .dark .hero-header { background: linear-gradient(rgba(11, 18, 32, 0.82), rgba(11, 18, 32, 0.92)), - url("https://t4.ftcdn.net/jpg/08/24/57/41/360_F_824574155_qai1qN0Wxhdb6owwcolxExe8aLjiIiat.jpg") - center / cover no-repeat; + url("/images/american_flag.jpg") center / cover no-repeat; } .grade-circle-a { background: #22c55e; } @@ -133,4 +131,4 @@ body { .dark footer { background-color: #1e3a8a; -} \ No newline at end of file +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 6728f12..e175099 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Suspense } from "react"; import { Inter, Roboto_Mono } from "next/font/google"; +import { headers } from "next/headers"; import { Footer } from "@/components/Footer"; import { Header } from "@/components/Header"; import { NcmecReportBanner } from "@/components/NcmecReportBanner"; @@ -57,11 +58,14 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + // Nonce from proxy.ts CSP; also forces dynamic rendering for per-request nonces + const nonce = (await headers()).get("x-nonce") ?? undefined; + return (
- +