diff --git a/apps/blog/app/layout.tsx b/apps/blog/app/layout.tsx
index 9575e635ace7b695380dfd300e2db9ff8d6dce7a..66710cba7cc2da3a84ecfd942de356f6547fed24 100644
--- a/apps/blog/app/layout.tsx
+++ b/apps/blog/app/layout.tsx
@@ -2,7 +2,7 @@ import '@hive/tailwindcss-config/globals.css';
import { ReactNode } from 'react';
import Script from 'next/script';
import { Metadata } from 'next';
-import { cookies } from 'next/headers';
+import { cookies, headers } from 'next/headers';
import MainBar from '../features/layouts/site-header/main-bar';
import ClientEffects from '../features/layouts/site-header/client-effects';
import { Providers } from '../features/layouts/providers';
@@ -11,6 +11,15 @@ import VisitLoggerClient from '../lib/visit-logger-client';
// Get basePath from build-time environment
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
+/**
+ * Get the CSP nonce from the request headers.
+ * The nonce is generated in middleware and passed via x-nonce header.
+ */
+function getNonce(): string {
+ const headersList = headers();
+ return headersList.get('x-nonce') || '';
+}
+
const SITE_DESC =
'Communities without borders. A social network owned and operated by its users, powered by Hive.';
@@ -48,6 +57,9 @@ export default async function RootLayout({ children }: { children: ReactNode })
const locale = cookieStore.get('NEXT_LOCALE')?.value || 'en';
const isRTL = locale === 'ar';
+ // Get nonce for CSP-compliant script loading
+ const nonce = getNonce();
+
return (
@@ -60,7 +72,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
>
-
+
diff --git a/apps/blog/middleware.ts b/apps/blog/middleware.ts
index aa3b5b4d721f115ea78981ad90209901386d2df5..aaaf9f2390abf65b41dd97fdbd49f31d2a6d8214 100644
--- a/apps/blog/middleware.ts
+++ b/apps/blog/middleware.ts
@@ -1,15 +1,72 @@
import { type NextRequest, NextResponse } from 'next/server';
import { setLoginChallengeCookies } from '@hive/smart-signer/lib/middleware-challenge-cookies';
+/**
+ * Generate a cryptographically secure nonce for CSP.
+ * In production, crypto.randomUUID() is available in Edge runtime.
+ */
+function generateNonce(): string {
+ // Use crypto.randomUUID() which is available in modern Edge runtime
+ // Convert to base64 for CSP compatibility
+ const uuid = crypto.randomUUID();
+ // Use btoa-safe encoding (remove hyphens, then encode)
+ return Buffer.from(uuid.replace(/-/g, ''), 'hex').toString('base64');
+}
+
+/**
+ * Build CSP header value with nonce for script and style execution.
+ * This provides strong XSS protection while allowing legitimate inline scripts.
+ */
+function buildCspHeader(nonce: string): string {
+ return [
+ // Default fallback for unspecified resource types
+ "default-src 'self'",
+ // Scripts: only allow same-origin and nonced scripts
+ // 'strict-dynamic' allows scripts loaded by nonced scripts
+ `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'wasm-unsafe-eval' https://platform.twitter.com`,
+ // Styles: nonce-based (fallback to unsafe-inline for older browsers)
+ `style-src 'self' 'nonce-${nonce}' 'unsafe-inline'`,
+ // Images: self + any HTTPS + data URIs + blob
+ "img-src 'self' https: data: blob:",
+ // Fonts: self + data URIs
+ "font-src 'self' data:",
+ // API connections: whitelist of trusted Hive API nodes
+ // Only nodes running proper haf_api_node software are allowed
+ "connect-src 'self' https://api.hive.blog https://api.syncad.com https://api.openhive.network https://images.hive.blog",
+ // Embedded content whitelist
+ "frame-src https://platform.twitter.com https://www.instagram.com https://player.vimeo.com https://www.youtube.com https://w.soundcloud.com https://player.twitch.tv https://open.spotify.com https://3speak.tv https://3speak.online https://3speak.co https://emb.d.tube https://odysee.com https://openhive.chat",
+ // Web Workers
+ "worker-src 'self' blob:",
+ // Clickjacking protection
+ "frame-ancestors 'self'",
+ // Restrict base URI
+ "base-uri 'self'",
+ // Restrict form submissions
+ "form-action 'self'",
+ // Report violations
+ "report-uri /api/csp-report"
+ ].join('; ');
+}
+
export async function middleware(request: NextRequest): Promise {
const { pathname } = request.nextUrl;
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
+ // Generate nonce for this request
+ const nonce = generateNonce();
+
const res = NextResponse.next();
// Set login challenge cookies (needed for console logging process)
setLoginChallengeCookies(request, res);
+ // Set CSP header with nonce
+ // Using Report-Only initially for safety - switch to Content-Security-Policy when ready
+ res.headers.set('Content-Security-Policy-Report-Only', buildCspHeader(nonce));
+
+ // Pass nonce to app via header (to be read in layout.tsx)
+ res.headers.set('x-nonce', nonce);
+
// In blog, redirect root path to /trending
if (pathname === '/' || pathname === `${basePath}` || pathname === `${basePath}/`) {
return NextResponse.redirect(new URL(`${basePath}/trending`, request.url), { status: 302 });