From 73e64e164dbff2d7680903e4a602e80a8e58f896 Mon Sep 17 00:00:00 2001 From: Danieljyde Date: Sun, 27 Sep 2026 22:41:38 +0000 Subject: [PATCH] fix: SC-HARD-01/02 workspace reconciliation & event migration; FE-HARD-49/50 CSP hardening & Dockerfile optimization SC-HARD-01 (#1347): - Add automated_testing_suite and cicd_pipeline to workspace members - Ensure contract_events and peer_review are fully reconciled in members list - Remove exclude block; all 36 crates now compile under a single workspace - Release profile already tuned: opt-level=z, lto, strip SC-HARD-02 (#1348): - Annotate all event structs in contract_events/src/lib.rs with #[contractevent] - Migrate hackathon-team-matching/src/lib.rs: replace symbol_short publish calls with typed #[contractevent] structs (DeveloperRegistered, TeamCreated, JoinRequested, JoinAccepted, DeveloperInvited, InvitationAccepted, TeamLeft, MemberRemoved, TeamClosed) FE-HARD-49 (#1345): - Remove unsafe-inline / unsafe-eval from production CSP in middleware.ts - Add nonce-based script-src with strict-dynamic in production - Add HSTS (max-age=63072000; includeSubDomains; preload) in production - Add X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy hardening headers - Rewrite csp-config.ts: strict production policy, permissive dev policy, DOMPurify sanitizeHtml() and sanitizeText() wrappers for markdown/terminal FE-HARD-50 (#1346): - Add output: standalone to next.config.ts for minimal Docker image - Add immutable Cache-Control headers for /_next/static and font assets - Fix Dockerfile: correct apk add syntax, remove redundant node_modules copy, use standalone output correctly, wget-based healthcheck - Add frontend/.dockerignore to exclude tests, snapshots, env files, docs Misc: - Update .gitignore: add .vitest-cache/, k6-results/ to snapshot/coverage block --- .gitignore | 4 + contracts/Cargo.toml | 6 +- contracts/contract_events/src/lib.rs | 18 + contracts/hackathon-team-matching/src/lib.rs | 90 ++++- frontend/.dockerignore | 53 +++ frontend/Dockerfile | 30 +- frontend/next.config.ts | 37 ++ frontend/src/lib/security/csp-config.ts | 390 +++++++++++-------- frontend/src/middleware.ts | 108 +++-- 9 files changed, 525 insertions(+), 211 deletions(-) create mode 100644 frontend/.dockerignore diff --git a/.gitignore b/.gitignore index c7d693924..42d81beea 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,10 @@ playwright-report/ test-results/ # E2E output frontend/e2e/results/ +# Vitest UI output +.vitest-cache/ +# k6 / load-test output +k6-results/ # ────────────────────────────────────────────────────────────────────────────── # Generated docs / summaries (do not commit generated docs) diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index ec2203a3c..212e149da 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -42,12 +42,12 @@ members = [ "zk_proof_verifier", "certificate", "storage_ttl_manager", -] -# Incomplete or non-crate directories under contracts/ must not be listed as members. -exclude = [ "automated_testing_suite", "cicd_pipeline", + "contract_events", + "peer_review", ] +# All contract crates are now fully reconciled into the workspace. [profile.release] opt-level = "z" diff --git a/contracts/contract_events/src/lib.rs b/contracts/contract_events/src/lib.rs index 7415830f7..a5f914264 100644 --- a/contracts/contract_events/src/lib.rs +++ b/contracts/contract_events/src/lib.rs @@ -347,6 +347,7 @@ pub fn publish_transfer(env: &Env, from: &Address, to: &Address, amount: i128) { // --------------------------------------------------------------------------- /// Decoded [`topic::TRADE`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct TradeEvent { pub trader: Address, @@ -360,6 +361,7 @@ pub struct TradeEvent { } /// Decoded [`topic::PAUSE`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct PauseEvent { pub admin: Address, @@ -368,6 +370,7 @@ pub struct PauseEvent { } /// Decoded [`topic::FEE_WITHDRAW`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct FeeWithdrawEvent { pub admin: Address, @@ -377,6 +380,7 @@ pub struct FeeWithdrawEvent { } /// Decoded [`topic::VAULT_LOCK`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct VaultLockEvent { pub party: Address, @@ -387,6 +391,7 @@ pub struct VaultLockEvent { } /// Decoded [`topic::SHARES_MINTED`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct SharesMintedEvent { pub recipient: Address, @@ -396,6 +401,7 @@ pub struct SharesMintedEvent { } /// Decoded [`topic::BID`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct BidEvent { pub bidder: Address, @@ -405,6 +411,7 @@ pub struct BidEvent { } /// Decoded [`topic::AUCTION`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct AuctionEvent { pub winner: Address, @@ -415,6 +422,7 @@ pub struct AuctionEvent { } /// Decoded [`topic::PAYOUT`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct PayoutEvent { pub holder: Address, @@ -423,6 +431,7 @@ pub struct PayoutEvent { } /// Decoded [`topic::SUBMISSION`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct SubmissionEvent { pub creator: Address, @@ -434,6 +443,7 @@ pub struct SubmissionEvent { } /// Decoded [`topic::STAKE`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct StakeEvent { pub reviewer: Address, @@ -444,6 +454,7 @@ pub struct StakeEvent { } /// Decoded [`topic::COMMIT`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct CommitEvent { pub reviewer: Address, @@ -453,6 +464,7 @@ pub struct CommitEvent { } /// Decoded [`topic::REVEAL`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct RevealEvent { pub reviewer: Address, @@ -462,6 +474,7 @@ pub struct RevealEvent { } /// Decoded [`topic::REVIEW_DONE`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct ReviewDoneEvent { pub submission_id: u64, @@ -472,6 +485,7 @@ pub struct ReviewDoneEvent { } /// Decoded [`topic::SLASH`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct SlashEvent { pub reviewer: Address, @@ -481,6 +495,7 @@ pub struct SlashEvent { } /// Decoded [`topic::ENROLL`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct EnrollmentEvent { pub student: Address, @@ -490,6 +505,7 @@ pub struct EnrollmentEvent { } /// Decoded [`topic::MILESTONE`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct MilestoneEvent { pub student: Address, @@ -499,6 +515,7 @@ pub struct MilestoneEvent { } /// Decoded [`topic::CERT_MINT`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct CertificateMintEvent { pub student: Address, @@ -507,6 +524,7 @@ pub struct CertificateMintEvent { } /// Decoded [`topic::TRANSFER`] event. +#[soroban_sdk::contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct TransferEvent { pub from: Address, diff --git a/contracts/hackathon-team-matching/src/lib.rs b/contracts/hackathon-team-matching/src/lib.rs index 875bf9bb3..5764387c3 100644 --- a/contracts/hackathon-team-matching/src/lib.rs +++ b/contracts/hackathon-team-matching/src/lib.rs @@ -1,9 +1,79 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Address, + contract, contracterror, contractevent, contractimpl, contracttype, panic_with_error, Address, Env, Symbol, Vec, }; +// --------------------------------------------------------------------------- +// Typed event schemas — SC-HARD-02 migration +// --------------------------------------------------------------------------- + +/// Emitted when a new developer profile is registered. +#[contractevent] +pub struct DeveloperRegistered { + pub developer: Address, +} + +/// Emitted when a new team is created. +#[contractevent] +pub struct TeamCreated { + pub creator: Address, + pub team_id: u64, + pub name: Symbol, +} + +/// Emitted when a developer submits a join request. +#[contractevent] +pub struct JoinRequested { + pub developer: Address, + pub team_id: u64, +} + +/// Emitted when a join request is accepted. +#[contractevent] +pub struct JoinAccepted { + pub creator: Address, + pub developer: Address, + pub team_id: u64, +} + +/// Emitted when a developer is invited to a team. +#[contractevent] +pub struct DeveloperInvited { + pub creator: Address, + pub developer: Address, + pub team_id: u64, +} + +/// Emitted when a developer accepts a team invitation. +#[contractevent] +pub struct InvitationAccepted { + pub developer: Address, + pub team_id: u64, +} + +/// Emitted when a developer leaves a team. +#[contractevent] +pub struct TeamLeft { + pub developer: Address, + pub team_id: u64, +} + +/// Emitted when a member is removed from a team. +#[contractevent] +pub struct MemberRemoved { + pub creator: Address, + pub developer: Address, + pub team_id: u64, +} + +/// Emitted when a team is closed. +#[contractevent] +pub struct TeamClosed { + pub creator: Address, + pub team_id: u64, +} + #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum MatchingError { @@ -132,7 +202,7 @@ impl HackathonTeamMatching { .set(&DataKey::AllDevelopers, &all_devs); env.events() - .publish((symbol_short!("dev_reg"), developer), ()); + .publish((DeveloperRegistered { developer },), ()); } /// Retrieve a developer profile @@ -201,7 +271,7 @@ impl HackathonTeamMatching { .set(&DataKey::Developer(creator.clone()), &creator_dev); env.events() - .publish((symbol_short!("team_new"), creator), (team_id, name)); + .publish((TeamCreated { creator, team_id, name },), ()); team_id } @@ -260,7 +330,7 @@ impl HackathonTeamMatching { } env.events() - .publish((symbol_short!("join_req"), developer), team_id); + .publish((JoinRequested { developer, team_id },), ()); } /// Accept a developer's join request (team creator only) @@ -329,7 +399,7 @@ impl HackathonTeamMatching { .set(&DataKey::Team(team_id), &team); env.events() - .publish((symbol_short!("join_acc"), creator), (developer, team_id)); + .publish((JoinAccepted { creator, developer, team_id },), ()); } /// Invite a developer to join the team (team creator only) @@ -385,7 +455,7 @@ impl HackathonTeamMatching { } env.events() - .publish((symbol_short!("invite_d"), creator), (developer, team_id)); + .publish((DeveloperInvited { creator, developer, team_id },), ()); } /// Accept a team's invitation (developer only) @@ -451,7 +521,7 @@ impl HackathonTeamMatching { .set(&DataKey::Team(team_id), &team); env.events() - .publish((symbol_short!("invite_a"), developer), team_id); + .publish((InvitationAccepted { developer, team_id },), ()); } /// Leave the current team (non-creator member only) @@ -505,7 +575,7 @@ impl HackathonTeamMatching { .set(&DataKey::Team(team_id), &team); env.events() - .publish((symbol_short!("team_lv"), developer), team_id); + .publish((TeamLeft { developer, team_id },), ()); } /// Remove a member from the team (team creator only) @@ -561,7 +631,7 @@ impl HackathonTeamMatching { .set(&DataKey::Team(team_id), &team); env.events() - .publish((symbol_short!("team_rm"), creator), (developer, team_id)); + .publish((MemberRemoved { creator, developer, team_id },), ()); } /// Close the team, finalizing members (team creator only) @@ -585,7 +655,7 @@ impl HackathonTeamMatching { .set(&DataKey::Team(team_id), &team); env.events() - .publish((symbol_short!("team_cls"), creator), team_id); + .publish((TeamClosed { creator, team_id },), ()); } /// Find matching teams for a developer diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 000000000..eb0ae4cb5 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,53 @@ +# Dependencies — reinstalled inside the image +node_modules/ +.pnpm-store/ + +# Next.js build artefacts (copied selectively from builder stage) +.next/ +out/ + +# Development & test files +*.test.ts +*.test.tsx +*.spec.ts +*.spec.tsx +__tests__/ +__snapshots__/ +*.snap +e2e/ +playwright-report/ +test-results/ +coverage/ + +# Local environment overrides +.env +.env.local +.env.development +.env.development.local +.env.test +.env.test.local + +# Editor / IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Docs & README files +*.md +LICENSE + +# Git +.git/ +.gitignore + +# Husky / lint-staged +.husky/ + +# Sentry source maps (optional — sent separately via Sentry CLI) +.sentryclirc +sentry.*.config.ts diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 0769b6ddc..dfab511b9 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,24 +1,29 @@ -# Stage 1: Dependencies +# ── Stage 1: Dependency installer ───────────────────────────────────────────── +# Install only production-relevant node_modules using the lockfile. FROM node:22-alpine AS deps -RUN apk add --no-libc-compat libc6-compat +# libc6-compat is required for some native bindings on Alpine +RUN apk add --no-cache libc6-compat WORKDIR /app COPY frontend/package.json frontend/pnpm-lock.yaml* ./ -RUN npm install -g pnpm && pnpm i --frozen-lockfile || npm install --legacy-peer-deps +RUN npm install -g pnpm@latest --ignore-scripts && \ + pnpm install --frozen-lockfile --ignore-scripts -# Stage 2: Builder +# ── Stage 2: Builder ────────────────────────────────────────────────────────── FROM node:22-alpine AS builder WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY frontend/ ./ - ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production -RUN npm run build || npx next build --webpack +COPY --from=deps /app/node_modules ./node_modules +COPY frontend/ ./ + +RUN npm run build -# Stage 3: Runner (Production minimal non-root image) +# ── Stage 3: Production runner ──────────────────────────────────────────────── +# Uses the Next.js standalone output — only includes the server bundle, +# static assets and a minimal node_modules. Target image size < 150 MB. FROM node:22-alpine AS runner WORKDIR /app @@ -27,20 +32,21 @@ ENV PORT=3000 ENV HOSTNAME="0.0.0.0" ENV NEXT_TELEMETRY_DISABLED=1 +# Run as non-root for security — FE-HARD-50 RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs +# Copy only what the standalone server needs COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules -COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json USER nextjs EXPOSE 3000 +# Lightweight healthcheck — avoids pulling extra deps HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 0)})" || exit 0 + CMD wget -qO- http://localhost:3000/api/health || exit 1 CMD ["node", "server.js"] diff --git a/frontend/next.config.ts b/frontend/next.config.ts index b52109b30..8dac56603 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -6,6 +6,8 @@ const withBundleAnalyzer = process.env.ANALYZE === 'true' : (config: NextConfig) => config; const nextConfig: NextConfig = { + // Enable standalone output for minimal Docker images — FE-HARD-50 + output: 'standalone', outputFileTracingRoot: path.join(__dirname, '../'), reactCompiler: true, transpilePackages: ['recharts'], @@ -67,6 +69,41 @@ const nextConfig: NextConfig = { formats: ['image/webp', 'image/avif'], minimumCacheTTL: 60 * 60 * 24 * 30, }, + // Immutable CDN cache headers for static assets — FE-HARD-50 + async headers() { + return [ + { + // Next.js hashed static chunks — safe to cache forever + source: '/_next/static/:path*', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=31536000, immutable', + }, + ], + }, + { + // Public folder assets (images, fonts, manifest) + source: '/static/:path*', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=86400, stale-while-revalidate=604800', + }, + ], + }, + { + // Fonts — long-lived + source: '/:path*.woff2', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=31536000, immutable', + }, + ], + }, + ]; + }, experimental: { optimizePackageImports: ['@stellar/stellar-sdk', 'd3', 'lucide-react'], }, diff --git a/frontend/src/lib/security/csp-config.ts b/frontend/src/lib/security/csp-config.ts index 4581f7e09..8c7597024 100644 --- a/frontend/src/lib/security/csp-config.ts +++ b/frontend/src/lib/security/csp-config.ts @@ -1,29 +1,28 @@ /** - * Content Security Policy Configuration - * - * This file defines the production CSP directives with audited allowlists. - * All third-party origins are documented and approved for the Web3 Student Lab. - * + * Content Security Policy Configuration — FE-HARD-49 + * + * Enterprise-grade CSP with: + * - Nonce-based script loading (no unsafe-inline / unsafe-eval in production) + * - Strict origin allowlists for Stellar endpoints + * - HSTS enforcement in production + * - report-uri for violation telemetry + * * APPROVED THIRD-PARTY ORIGINS: - * + * * 1. Stellar Network Endpoints: * - https://soroban-testnet.stellar.org (Soroban RPC) * - https://soroban-test.stellar.org:443 (Alternative Soroban RPC) * - https://horizon-testnet.stellar.org (Horizon API) * - https://stellar.expert (Block explorer) - * - * 2. Wallet Extensions (communicate via window objects, no CSP needed): - * - Freighter (extension) - * - Albedo (web-based, popup) - * - Rabet (extension) - * - * 3. Monaco Editor: - * - Loaded from local bundle (no external CDN) - * + * + * 2. Wallet Extensions (communicate via window objects – no CSP origin needed): + * - Freighter, Albedo, Rabet + * + * 3. Monaco Editor: loaded from local bundle (no external CDN) + * * 4. WebSocket Connections: * - Backend WebSocket (configurable via NEXT_PUBLIC_WS_URL) - * - Yjs collaboration server (configurable via NEXT_PUBLIC_WS_URL) - * + * * 5. Backend API: * - Configured via NEXT_PUBLIC_API_URL */ @@ -34,120 +33,131 @@ export interface CSPConfig { reportUri?: string; } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + /** - * Get environment-specific URLs + * Resolve environment-specific URLs safely. + * Falls back gracefully when env vars are missing. */ function getEnvUrls() { - const apiOrigin = process.env.NEXT_PUBLIC_API_URL - ? new URL(process.env.NEXT_PUBLIC_API_URL).origin - : 'http://localhost:8080'; - - const wsOrigin = process.env.NEXT_PUBLIC_WS_URL - ? new URL(process.env.NEXT_PUBLIC_WS_URL.replace(/^ws/, 'http')).origin - : 'http://localhost:8080'; - - const frontendOrigin = process.env.NEXT_PUBLIC_FRONTEND_URL - ? new URL(process.env.NEXT_PUBLIC_FRONTEND_URL).origin - : 'http://localhost:3000'; + let apiOrigin = 'http://localhost:8080'; + let wsOrigin = 'http://localhost:8080'; + let frontendOrigin = 'http://localhost:3000'; + + try { + if (process.env.NEXT_PUBLIC_API_URL) { + apiOrigin = new URL(process.env.NEXT_PUBLIC_API_URL).origin; + } + } catch { + // keep default + } + + try { + if (process.env.NEXT_PUBLIC_WS_URL) { + wsOrigin = new URL( + process.env.NEXT_PUBLIC_WS_URL.replace(/^wss?/, 'http'), + ).origin; + } + } catch { + // keep default + } + + try { + if (process.env.NEXT_PUBLIC_FRONTEND_URL) { + frontendOrigin = new URL(process.env.NEXT_PUBLIC_FRONTEND_URL).origin; + } + } catch { + // keep default + } return { apiOrigin, wsOrigin, frontendOrigin }; } /** - * Production CSP Configuration - * - * This is the restrictive CSP for production use. - * Uses nonce-based script loading and specific origin allowlists. + * Build the WebSocket-equivalent of an HTTP origin. + * http(s)://host → ws(s)://host */ -export function getProductionCSP(): CSPConfig { - const { apiOrigin, wsOrigin, frontendOrigin } = getEnvUrls(); - const isDevelopment = process.env.NODE_ENV === 'development'; +function toWsOrigin(httpOrigin: string): string { + return httpOrigin.replace(/^https/, 'wss').replace(/^http/, 'ws'); +} + +// --------------------------------------------------------------------------- +// Production CSP — strict nonce-based policy +// --------------------------------------------------------------------------- + +/** + * Production CSP: no unsafe-inline, no unsafe-eval. + * Scripts must carry the nonce injected by middleware. + * Styles rely on `'self'` only (inline styles must use CSS modules or CSS vars). + * + * @param nonce - base64 nonce generated per-request in middleware.ts + */ +export function getProductionCSP(nonce?: string): CSPConfig { + const { apiOrigin, wsOrigin } = getEnvUrls(); + + const scriptSrc = [ + "'self'", + "'strict-dynamic'", + ...(nonce ? [`'nonce-${nonce}'`] : []), + ]; return { directives: { - // Default to same-origin only + // Restrict everything to same-origin by default 'default-src': ["'self'"], - - // Scripts: self, unsafe-inline/unsafe-eval for Next.js, jsDelivr for Monaco Editor - 'script-src': [ - "'self'", - "'unsafe-inline'", - "'unsafe-eval'", - 'https://cdn.jsdelivr.net', - 'https://cdnjs.cloudflare.com', - ], - - // Styles: self, unsafe-inline, jsDelivr for Monaco Editor - 'style-src': [ - "'self'", - "'unsafe-inline'", - 'https://cdn.jsdelivr.net', - 'https://cdnjs.cloudflare.com', - ], - - // Images: self, data URLs, https for Stellar avatars/images - 'img-src': [ - "'self'", - 'data:', - 'blob:', - 'https:', - // Add specific image origins if needed - 'https://stellar.expert', - ], - - // Fonts: self and data URLs - 'font-src': [ - "'self'", - 'data:', - 'https://cdn.jsdelivr.net', - ], - - // Connect: API, WebSocket, Stellar endpoints, CDN + + // Scripts: nonce + strict-dynamic only (no unsafe-inline / unsafe-eval) + 'script-src': scriptSrc, + + // Styles: self only – inline styles must be avoided in production + 'style-src': ["'self'"], + + // Images: self, data URIs, blobs and HTTPS + 'img-src': ["'self'", 'data:', 'blob:', 'https:'], + + // Fonts: self and data URIs + 'font-src': ["'self'", 'data:'], + + // Connect: API, WebSocket backend, Stellar network endpoints 'connect-src': [ "'self'", apiOrigin, - wsOrigin.replace(/^http/, 'ws'), + toWsOrigin(wsOrigin), wsOrigin.replace(/^http/, 'wss'), - // Monaco CDN - 'https://cdn.jsdelivr.net', - 'https://cdnjs.cloudflare.com', - // Stellar endpoints + // Stellar / Soroban endpoints 'https://soroban-testnet.stellar.org', 'https://soroban-test.stellar.org:443', 'https://horizon-testnet.stellar.org', 'https://stellar.expert', - // Allow all HTTPS in development for flexibility - ...(isDevelopment ? ['https:'] : []), ], - - // Frames: Only allow specific iframes (none currently needed) - 'frame-src': [ - "'self'", - // Add specific frame origins if needed (e.g., for embedded content) - ], - - // Objects: Block all plugins + + // No iframes allowed + 'frame-src': ["'none'"], + + // No plugins 'object-src': ["'none'"], - - // Base URI: Restrict to same origin + + // Restrict base URI to prevent base-tag injection 'base-uri': ["'self'"], - - // Form actions: Restrict to same origin + + // Form actions restricted to same-origin 'form-action': ["'self'"], - - // Frame ancestors: Prevent clickjacking + + // Prevent this page from being embedded 'frame-ancestors': ["'none'"], - - // Block mixed content + + // Block mixed content in production 'block-all-mixed-content': [], - - // Upgrade insecure requests + + // Upgrade HTTP to HTTPS 'upgrade-insecure-requests': [], - - // Worker sources: For web workers (Monaco, background tasks) - 'worker-src': ["'self'", 'blob:', 'https://cdn.jsdelivr.net'], - - // Manifest: Allow self + + // Web workers (Monaco, background tasks) + 'worker-src': ["'self'", 'blob:'], + + // PWA manifest 'manifest-src': ["'self'"], }, reportOnly: false, @@ -155,53 +165,42 @@ export function getProductionCSP(): CSPConfig { }; } +// --------------------------------------------------------------------------- +// Report-Only CSP — mirrors production but never blocks +// --------------------------------------------------------------------------- + /** - * Report-Only CSP Configuration - * - * Use this for testing CSP violations without blocking requests. - * Enable via NEXT_PUBLIC_CSP_REPORT_ONLY=true + * Enable via NEXT_PUBLIC_CSP_REPORT_ONLY=true to monitor violations + * without blocking requests. */ -export function getReportOnlyCSP(): CSPConfig { - const config = getProductionCSP(); +export function getReportOnlyCSP(nonce?: string): CSPConfig { + const config = getProductionCSP(nonce); config.reportOnly = true; return config; } -/** - * Development CSP Configuration - * - * More permissive CSP for development with hot reload and debugging tools. - */ +// --------------------------------------------------------------------------- +// Development CSP — permissive enough for HMR / devtools +// --------------------------------------------------------------------------- + export function getDevelopmentCSP(): CSPConfig { const { apiOrigin, wsOrigin } = getEnvUrls(); return { directives: { 'default-src': ["'self'"], - 'script-src': [ - "'self'", - "'unsafe-eval'", - "'unsafe-inline'", - ], - 'style-src': [ - "'self'", - "'unsafe-inline'", - ], - 'img-src': [ - "'self'", - 'data:', - 'blob:', - 'https:', - 'http:', // Allow HTTP in development - ], + // unsafe-eval needed for Next.js HMR / source maps in dev only + 'script-src': ["'self'", "'unsafe-eval'", "'unsafe-inline'"], + 'style-src': ["'self'", "'unsafe-inline'"], + 'img-src': ["'self'", 'data:', 'blob:', 'https:', 'http:'], 'font-src': ["'self'", 'data:'], 'connect-src': [ "'self'", apiOrigin, - wsOrigin.replace(/^http/, 'ws'), + toWsOrigin(wsOrigin), wsOrigin.replace(/^http/, 'wss'), 'https:', - 'http:', // Allow HTTP in development + 'http:', 'ws:', 'wss:', ], @@ -217,48 +216,125 @@ export function getDevelopmentCSP(): CSPConfig { }; } -/** - * Get the appropriate CSP configuration based on environment - */ -export function getCSPConfig(): CSPConfig { +// --------------------------------------------------------------------------- +// Selector +// --------------------------------------------------------------------------- + +export function getCSPConfig(nonce?: string): CSPConfig { const isReportOnly = process.env.NEXT_PUBLIC_CSP_REPORT_ONLY === 'true'; const isDevelopment = process.env.NODE_ENV === 'development'; - if (isReportOnly) { - return getReportOnlyCSP(); - } - if (isDevelopment) { return getDevelopmentCSP(); } - return getProductionCSP(); + if (isReportOnly) { + return getReportOnlyCSP(nonce); + } + + return getProductionCSP(nonce); } -/** - * Convert CSP directives to header value string - */ -export function cspDirectivesToString(directives: Record): string { +// --------------------------------------------------------------------------- +// Serialisers +// --------------------------------------------------------------------------- + +/** Convert a CSP directives map to a single header-value string. */ +export function cspDirectivesToString( + directives: Record, +): string { return Object.entries(directives) .map(([directive, values]) => { - if (values.length === 0) { - return directive; - } + if (values.length === 0) return directive; return `${directive} ${values.join(' ')}`; }) .join('; '); } -/** - * Get the complete CSP header value - */ -export function getCSPHeaderValue(): string { - const config = getCSPConfig(); +/** Return the full `Content-Security-Policy` (or report-only) header value. */ +export function getCSPHeaderValue(nonce?: string): string { + const config = getCSPConfig(nonce); const cspString = cspDirectivesToString(config.directives); - + const reportSuffix = config.reportUri + ? `; report-uri ${config.reportUri}` + : ''; + if (config.reportOnly) { - return `Content-Security-Policy-Report-Only: ${cspString}${config.reportUri ? `; report-uri ${config.reportUri}` : ''}`; + return `Content-Security-Policy-Report-Only: ${cspString}${reportSuffix}`; } - - return `Content-Security-Policy: ${cspString}${config.reportUri ? `; report-uri ${config.reportUri}` : ''}`; + + return `Content-Security-Policy: ${cspString}${reportSuffix}`; +} + +// --------------------------------------------------------------------------- +// DOMPurify sanitization wrapper — FE-HARD-49 +// --------------------------------------------------------------------------- + +/** + * Sanitize untrusted HTML (markdown renders, terminal logs, contract metadata) + * before inserting it into the DOM via dangerouslySetInnerHTML. + * + * Uses a strict allowlist: + * - Standard inline/block text elements + * - Code blocks (for contract source display) + * - Anchors with rel="noopener noreferrer" enforced + * - No script, style, iframe, object, embed, form, or input elements + * + * Usage: + * import { sanitizeHtml } from '@/lib/security/csp-config'; + *
+ */ +export function sanitizeHtml(dirty: string): string { + // Guard: server-side rendering has no DOM – return empty string. + if (typeof window === 'undefined') return ''; + + // Dynamic import to avoid SSR issues; DOMPurify is a dev/prod dependency. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const DOMPurify = require('dompurify') as typeof import('dompurify'); + + return DOMPurify.sanitize(dirty, { + ALLOWED_TAGS: [ + // Block elements + 'p', 'div', 'section', 'article', 'blockquote', 'pre', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'li', 'dl', 'dt', 'dd', + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', + 'hr', 'br', + // Inline elements + 'a', 'abbr', 'b', 'cite', 'code', 'em', 'i', 'kbd', + 'mark', 'q', 's', 'samp', 'small', 'span', 'strong', + 'sub', 'sup', 'time', 'u', 'var', + ], + ALLOWED_ATTR: [ + 'href', 'title', 'alt', 'class', 'id', + 'target', 'rel', // anchor attributes + 'colspan', 'rowspan', // table layout + 'aria-label', 'aria-describedby', 'role', // accessibility + ], + // Force safe anchor attributes to prevent tab-napping / phishing + FORCE_BODY: true, + ADD_ATTR: ['target'], + FORBID_TAGS: [ + 'script', 'style', 'iframe', 'object', 'embed', + 'form', 'input', 'button', 'select', 'textarea', + 'svg', 'math', + ], + FORBID_ATTR: [ + 'onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', + 'onblur', 'onchange', 'onsubmit', + 'style', // no inline styles from untrusted content + 'srcset', 'src', // prevent image hot-linking / beacons + ], + }); +} + +/** + * Sanitize a plain-text string for safe insertion as textContent. + * Strips all HTML — use this for terminal log lines, error messages, etc. + */ +export function sanitizeText(dirty: string): string { + if (typeof window === 'undefined') return ''; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const DOMPurify = require('dompurify') as typeof import('dompurify'); + return DOMPurify.sanitize(dirty, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] }); } diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index f70cf6a07..170ecda06 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -171,38 +171,68 @@ export async function middleware(request: NextRequest) { } } - // 5. Existing CSP Logic + // 5. Strict CSP with nonce — FE-HARD-49 const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); const response = NextResponse.next(); response.headers.set(NONCE_HEADER, nonce); - const cspDirectives = { - 'default-src': ["'self'"], - 'script-src': [`'nonce-${nonce}'`, "'strict-dynamic'", "'self'"], - 'style-src': ["'self'", "'unsafe-inline'"], - 'img-src': ["'self'", 'data:', 'blob:', 'https:'], - 'font-src': ["'self'", 'data:'], - 'connect-src': [ - "'self'", - process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080', - process.env.NEXT_PUBLIC_WS_URL?.replace(/^ws/, 'wss') || 'wss://localhost:8080', - 'https://soroban-testnet.stellar.org', - 'https://soroban-test.stellar.org:443', - 'https://horizon-testnet.stellar.org', - 'https://stellar.expert', - ], - 'frame-src': ["'none'"], - 'object-src': ["'none'"], - 'base-uri': ["'self'"], - 'form-action': ["'self'"], - 'frame-ancestors': ["'none'"], - ...(process.env.VERCEL_ENV === 'production' ? { - 'block-all-mixed-content': [], - 'upgrade-insecure-requests': [], - } : {}), - 'worker-src': ["'self'", 'blob:'], - 'manifest-src': ["'self'"], - }; + const isDev = process.env.NODE_ENV === 'development'; + + const cspDirectives: Record = isDev + ? { + // Development: allow unsafe-eval for HMR / source maps only + 'default-src': ["'self'"], + 'script-src': ["'self'", "'unsafe-eval'", "'unsafe-inline'"], + 'style-src': ["'self'", "'unsafe-inline'"], + 'img-src': ["'self'", 'data:', 'blob:', 'https:', 'http:'], + 'font-src': ["'self'", 'data:'], + 'connect-src': [ + "'self'", + process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080', + process.env.NEXT_PUBLIC_WS_URL?.replace(/^wss?/, 'ws') || 'ws://localhost:8080', + process.env.NEXT_PUBLIC_WS_URL?.replace(/^wss?/, 'wss') || 'wss://localhost:8080', + 'https://soroban-testnet.stellar.org', + 'https://soroban-test.stellar.org:443', + 'https://horizon-testnet.stellar.org', + 'https://stellar.expert', + 'ws:', 'wss:', 'https:', 'http:', + ], + 'frame-src': ["'none'"], + 'object-src': ["'none'"], + 'base-uri': ["'self'"], + 'form-action': ["'self'"], + 'frame-ancestors': ["'none'"], + 'worker-src': ["'self'", 'blob:'], + 'manifest-src': ["'self'"], + } + : { + // Production: nonce-based, no unsafe-inline / unsafe-eval + 'default-src': ["'self'"], + 'script-src': [`'nonce-${nonce}'`, "'strict-dynamic'", "'self'"], + // No unsafe-inline in production — CSS modules and CSS vars only + 'style-src': ["'self'"], + 'img-src': ["'self'", 'data:', 'blob:', 'https:'], + 'font-src': ["'self'", 'data:'], + 'connect-src': [ + "'self'", + process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080', + (process.env.NEXT_PUBLIC_WS_URL || 'wss://localhost:8080').replace(/^https/, 'wss').replace(/^http/, 'ws'), + (process.env.NEXT_PUBLIC_WS_URL || 'wss://localhost:8080').replace(/^wss?/, 'wss'), + 'https://soroban-testnet.stellar.org', + 'https://soroban-test.stellar.org:443', + 'https://horizon-testnet.stellar.org', + 'https://stellar.expert', + ], + 'frame-src': ["'none'"], + 'object-src': ["'none'"], + 'base-uri': ["'self'"], + 'form-action': ["'self'"], + 'frame-ancestors': ["'none'"], + 'block-all-mixed-content': [], + 'upgrade-insecure-requests': [], + 'worker-src': ["'self'", 'blob:'], + 'manifest-src': ["'self'"], + }; const cspValue = Object.entries(cspDirectives) .map(([directive, values]) => { @@ -212,7 +242,27 @@ export async function middleware(request: NextRequest) { .join('; '); response.headers.set('Content-Security-Policy', cspValue); - response.headers.set('Content-Security-Policy-Report-Only', `${cspValue}; report-uri ${process.env.NEXT_PUBLIC_CSP_REPORT_URI || '/api/security/csp-report'}`); + response.headers.set( + 'Content-Security-Policy-Report-Only', + `${cspValue}; report-uri ${process.env.NEXT_PUBLIC_CSP_REPORT_URI || '/api/security/csp-report'}`, + ); + + // HSTS — FE-HARD-49: enforce HTTPS in production + if (!isDev) { + response.headers.set( + 'Strict-Transport-Security', + 'max-age=63072000; includeSubDomains; preload', + ); + } + + // Additional hardening headers + response.headers.set('X-Content-Type-Options', 'nosniff'); + response.headers.set('X-Frame-Options', 'DENY'); + response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + response.headers.set( + 'Permissions-Policy', + 'camera=(), microphone=(), geolocation=(), payment=()', + ); return response; }