diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index c89f3a4..7cfffb2 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -66,5 +66,8 @@ jobs: - name: Run tests run: npm test --if-present - - name: Build frontend - run: npm run build --if-present \ No newline at end of file + - name: Build frontend + run: npm run build --if-present + + - name: Run browser security tests + run: node --test tests/browser/*.test.mjs diff --git a/docs/browser-security.md b/docs/browser-security.md new file mode 100644 index 0000000..b277e07 --- /dev/null +++ b/docs/browser-security.md @@ -0,0 +1,23 @@ +# Browser security baseline + +Production uses a new CSP nonce per response. Scripts and style elements require +the nonce; `unsafe-eval` and inline scripts are disabled. Trusted Types permits +only Next.js runtime policies. Connect, image, media, form, and frame targets are +allowlisted in `src/lib/security/csp.js`. + +`src/lib/security/input.js` normalizes server-side text, HTTPS URLs, redirects, +filenames, and remote images. User SVG and non-allowlisted image hosts are rejected. + +Session cookies remain `HttpOnly`, `SameSite=Strict`, path-scoped, short-lived, +and `Secure` in production. Access tokens last 15 minutes; seven-day refresh +tokens are restricted to `/api/auth/refresh` and rotate on use. + +Responses enforce HSTS, `nosniff`, framing denial, strict-origin referrers, +restricted permissions, COOP `same-origin-allow-popups`, COEP `credentialless`, +and CORP `same-origin`. Test wallet popups, checkout, uploads, images, and downloads +before changing isolation policies. + +`/api/csp-report` caps payloads, strips query data, samples 10% by default via +`CSP_REPORT_SAMPLE_RATE`, and deduplicates reports for ten minutes. Wallet signing +parses XDR and verifies the displayed network, source, operation, recipient or +contract, asset, and amount; mismatches and cancelled reviews never reach the wallet. diff --git a/next.config.mjs b/next.config.mjs index 836ecb9..931fb31 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,10 +1,19 @@ import { PHASE_PRODUCTION_BUILD } from "next/constants.js"; import { assertRuntimeEnv } from "./src/lib/env.js"; +const imageHosts = ["gateway.pinata.cloud", "ipfs.io", "www.gravatar.com"]; +try { + const gateway = new URL(process.env.NEXT_PUBLIC_GATEWAY_URL); + if (gateway.protocol === "https:") imageHosts.push(gateway.hostname); +} catch {} + /** @type {import('next').NextConfig} */ const nextConfig = { reactCompiler: true, serverExternalPackages: [], + images: { + remotePatterns: imageHosts.map((hostname) => ({ protocol: "https", hostname })), + }, webpack: (config, { isServer }) => { if (!isServer) { config.resolve.fallback = { @@ -20,23 +29,6 @@ const nextConfig = { { source: "/(.*)", headers: [ - { - key: "Content-Security-Policy", - value: [ - "default-src 'self'", - "base-uri 'self'", - "form-action 'self'", - "frame-ancestors 'none'", - "object-src 'none'", - "img-src 'self' data: https: blob:", - "font-src 'self' https: data:", - "style-src 'self' 'unsafe-inline'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval'", - "connect-src 'self' https: wss:", - "media-src 'self' https: blob:", - "upgrade-insecure-requests", - ].join("; "), - }, { key: "X-Frame-Options", value: "DENY" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, @@ -48,6 +40,9 @@ const nextConfig = { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains; preload", }, + { key: "Cross-Origin-Opener-Policy", value: "same-origin-allow-popups" }, + { key: "Cross-Origin-Embedder-Policy", value: "credentialless" }, + { key: "Cross-Origin-Resource-Policy", value: "same-origin" }, ], }, ]; diff --git a/src/app/api/csp-report/route.js b/src/app/api/csp-report/route.js new file mode 100644 index 0000000..7c90620 --- /dev/null +++ b/src/app/api/csp-report/route.js @@ -0,0 +1,30 @@ +import { logger } from "@/lib/logger"; +import { normalizeCspReport, shouldRecordCspReport } from "@/lib/security/csp"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const MAX_REPORT_BYTES = 16 * 1024; + +export async function POST(request) { + const contentLength = Number(request.headers.get("content-length") || 0); + if (contentLength > MAX_REPORT_BYTES) return new Response(null, { status: 413 }); + + try { + const text = await request.text(); + if (new TextEncoder().encode(text).byteLength > MAX_REPORT_BYTES) { + return new Response(null, { status: 413 }); + } + const parsed = JSON.parse(text); + for (const payload of (Array.isArray(parsed) ? parsed : [parsed]).slice(0, 20)) { + const report = normalizeCspReport(payload); + if (shouldRecordCspReport(report)) { + logger.warn({ event: "csp_violation", ...report }, "Browser CSP violation"); + } + } + } catch { + // Reports are best-effort and never return parsing details. + } + + return new Response(null, { status: 204, headers: { "Cache-Control": "no-store" } }); +} diff --git a/src/app/api/delivery/stream/route.js b/src/app/api/delivery/stream/route.js index bab858a..405d474 100644 --- a/src/app/api/delivery/stream/route.js +++ b/src/app/api/delivery/stream/route.js @@ -24,6 +24,7 @@ import { withApiHardening } from '@/lib/api/hardening'; import { verifyDeliveryToken } from '@/lib/delivery/token'; import { getMaterialRecord, createUpstreamStream, parseRangeHeader } from '@/lib/delivery/stream'; import { recordDeliveryAudit } from '@/lib/delivery/audit'; +import { contentDispositionAttachment } from '@/lib/security/input'; export const dynamic = 'force-dynamic'; @@ -137,7 +138,7 @@ export async function GET(request) { // ── 7. Build response headers ─────────────────────────────────────────── const headers = { 'Content-Type': material.contentType, - 'Content-Disposition': `attachment; filename="${encodeURIComponent(material.fileName)}"`, + 'Content-Disposition': contentDispositionAttachment(material.fileName), 'Cache-Control': 'private, no-cache, no-store, must-revalidate', 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', @@ -189,4 +190,4 @@ export async function GET(request) { }); } ); -} \ No newline at end of file +} diff --git a/src/app/api/materials/upload/route.js b/src/app/api/materials/upload/route.js index 2b7562a..cf77d76 100644 --- a/src/app/api/materials/upload/route.js +++ b/src/app/api/materials/upload/route.js @@ -5,6 +5,7 @@ import { auditLog } from '@/lib/api/audit' import { withApiHardening } from '@/lib/api/hardening' import { normalizeStringList, + normalizeImageField, sanitizeObject, validateUploadPayload, validateUploadFileMetadata, @@ -156,8 +157,8 @@ export async function POST(request) { const metadataJSON = { ...sanitizedScalarFields, - coverImageUrl: results.imgUrl || sanitizedScalarFields.coverImageUrl || null, - thumbnailUrl: results.imgUrl || sanitizedScalarFields.thumbnailUrl || null, + coverImageUrl: results.imgUrl || normalizeImageField(sanitizedScalarFields.coverImageUrl, 'coverImageUrl'), + thumbnailUrl: results.imgUrl || normalizeImageField(sanitizedScalarFields.thumbnailUrl, 'thumbnailUrl'), learningOutcomes: normalizeStringList(previewInputs.learningOutcomes, { maxItems: 8, maxLength: 180 }), tableOfContents: normalizeStringList(previewInputs.tableOfContents, { maxItems: 16, maxLength: 180 }), sampleNotes: normalizeStringList(previewInputs.sampleNotes, { maxItems: 6, maxLength: 280 }), diff --git a/src/app/api/profile/route.js b/src/app/api/profile/route.js index 908ab33..8bf39d2 100644 --- a/src/app/api/profile/route.js +++ b/src/app/api/profile/route.js @@ -6,11 +6,15 @@ import { auditLog } from "@/lib/api/audit"; import { withApiHardening } from "@/lib/api/hardening"; import { escapeRegExp, + normalizeImageField, + normalizeUrlField, normalizeWalletAddress, + PROFILE_LINK_RULES, + sanitizeString, validateProfilePayload, validatePayoutSettingsPayload, } from "@/lib/api/validation"; -import { getUserFromCookie, sanitizeString } from "@/lib/api/auth"; +import { getUserFromCookie } from "@/lib/api/auth"; import { verifySessionWalletAddress } from "@/lib/auth/sessionVerification"; import { sendWelcomeEmail } from "@/lib/email"; import { getDb } from "@/lib/mongodb"; @@ -133,7 +137,7 @@ export async function PATCH(request) { } if (profileData.avatarUrl && typeof profileData.avatarUrl === 'string') { - updateFields.avatarUrl = sanitizeString(profileData.avatarUrl, { maxLength: 2048 }); + updateFields.avatarUrl = normalizeImageField(profileData.avatarUrl, "avatarUrl"); } if (profileData.institution && typeof profileData.institution === 'string') { @@ -144,16 +148,10 @@ export async function PATCH(request) { updateFields.country = sanitizeString(profileData.country, { maxLength: 80 }); } - if (profileData.twitterUrl && typeof profileData.twitterUrl === 'string') { - updateFields.twitterUrl = sanitizeString(profileData.twitterUrl, { maxLength: 256 }); - } - - if (profileData.githubUrl && typeof profileData.githubUrl === 'string') { - updateFields.githubUrl = sanitizeString(profileData.githubUrl, { maxLength: 256 }); - } - - if (profileData.websiteUrl && typeof profileData.websiteUrl === 'string') { - updateFields.websiteUrl = sanitizeString(profileData.websiteUrl, { maxLength: 256 }); + for (const [field, options] of Object.entries(PROFILE_LINK_RULES)) { + if (typeof profileData[field] === "string" && profileData[field]) { + updateFields[field] = normalizeUrlField(profileData[field], field, options); + } } if ( diff --git a/src/app/api/upload/route.js b/src/app/api/upload/route.js index 130bbe0..dd60576 100644 --- a/src/app/api/upload/route.js +++ b/src/app/api/upload/route.js @@ -3,6 +3,7 @@ import { auditLog } from '@/lib/api/audit' import { withApiHardening } from '@/lib/api/hardening' import { normalizeStringList, + normalizeImageField, sanitizeObject, validateUploadFileMetadata, validateUploadPayload, @@ -315,8 +316,8 @@ export async function POST(request) { // Include storage reference inside the metadata const metadataJSON = { ...sanitizedScalarFields, - coverImageUrl: results.imgUrl || sanitizedScalarFields.coverImageUrl || null, - thumbnailUrl: results.imgUrl || sanitizedScalarFields.thumbnailUrl || null, + coverImageUrl: results.imgUrl || normalizeImageField(sanitizedScalarFields.coverImageUrl, "coverImageUrl"), + thumbnailUrl: results.imgUrl || normalizeImageField(sanitizedScalarFields.thumbnailUrl, "thumbnailUrl"), learningOutcomes: normalizeStringList(previewInputs.learningOutcomes, { maxItems: 8, maxLength: 180, @@ -483,4 +484,4 @@ export async function POST(request) { } }, ) -} \ No newline at end of file +} diff --git a/src/app/layout.js b/src/app/layout.js index fe895b2..02bb717 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -1,4 +1,5 @@ import { Geist, Geist_Mono } from "next/font/google"; +import { headers } from "next/headers"; import "./globals.css"; import Web3Provider from "@/providers/Web3Provider"; import { ToastProvider } from "@/providers/ToastProvider"; @@ -43,13 +44,15 @@ const themeInitScript = ` })(); `; -export default function RootLayout({ children }) { +export default async function RootLayout({ children }) { + const nonce = (await headers()).get("x-nonce") || undefined; + return ( -