Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions Caddyfile.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion deploy/Caddyfile.ip
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
10 changes: 7 additions & 3 deletions docs/DEPLOY-GITHUB-DO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion frontend/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
8 changes: 3 additions & 5 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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; }
Expand Down Expand Up @@ -133,4 +131,4 @@ body {

.dark footer {
background-color: #1e3a8a;
}
}
13 changes: 10 additions & 3 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -57,19 +58,25 @@ 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 (
<html
lang="en"
className={`${inter.variable} ${robotoMono.variable} h-full`}
suppressHydrationWarning
>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
<script
nonce={nonce}
dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }}
/>
</head>
<body className="min-h-full flex flex-col antialiased bg-background text-foreground transition-colors duration-200">
<NcmecReportBanner variant="banner" />
Expand All @@ -82,4 +89,4 @@ export default function RootLayout({
</body>
</html>
);
}
}
57 changes: 57 additions & 0 deletions frontend/src/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";

/**
* Strict CSP with per-request nonces (Next.js 16 proxy convention).
* Avoids script-src 'unsafe-inline' / 'unsafe-eval' in production.
* React style={{...}} props use style-src-attr (CSP3); style tags use nonces.
*/
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const isDev = process.env.NODE_ENV === "development";

const csp = [
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"object-src 'none'",
// Local assets + congress.gov member photos
"img-src 'self' data: blob: https://www.congress.gov",
"font-src 'self' data:",
// Next applies nonces to its style tags; React style attributes need style-src-attr
`style-src 'self' 'nonce-${nonce}'`,
"style-src-attr 'unsafe-inline'",
// Nonce + strict-dynamic: no 'unsafe-inline'. Dev-only eval for React refresh.
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""}`,
"connect-src 'self'",
"upgrade-insecure-requests",
]
.join("; ")
.replace(/\s{2,}/g, " ")
.trim();

const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
// Next.js reads CSP from the request to auto-apply nonces to framework scripts
requestHeaders.set("Content-Security-Policy", csp);

const response = NextResponse.next({
request: { headers: requestHeaders },
});
response.headers.set("Content-Security-Policy", csp);

return response;
}

export const config = {
matcher: [
{
source:
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|jfif|ico)$).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};