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 (
- + { + async (unsignedXdr, { description = "Transaction", explorerBaseUrl, intent } = {}) => { if (!isConnected) { const error = new Error( "Wallet not connected. Please connect your Stellar wallet first.", @@ -142,7 +142,7 @@ export function useStellarTransaction() { let signedXdr; try { - signedXdr = await signTransaction(unsignedXdr); + signedXdr = await signTransaction(unsignedXdr, { intent }); } catch (err) { const msg = err?.message ?? String(err); const isDismissal = /clos|cancel|reject|dismiss/i.test(msg); diff --git a/src/lib/api/storage.js b/src/lib/api/storage.js index c59cc79..bd14787 100644 --- a/src/lib/api/storage.js +++ b/src/lib/api/storage.js @@ -1,3 +1,5 @@ +import { normalizeExternalUrl, REMOTE_IMAGE_HOSTS } from "../security/input.js"; + export class StorageError extends Error { constructor(message, details = {}) { super(message); @@ -35,13 +37,19 @@ export function validatePinataResponse(response, type = "file") { * @returns {string} The verified URL */ export function validateGatewayUrl(url, type = "file") { - if (!url || typeof url !== "string" || !url.startsWith("http")) { + try { + const gatewayHost = process.env.NEXT_PUBLIC_GATEWAY_URL + ? new URL(process.env.NEXT_PUBLIC_GATEWAY_URL).hostname + : null; + return normalizeExternalUrl(url, { + allowedHosts: [...REMOTE_IMAGE_HOSTS, gatewayHost].filter(Boolean), + }); + } catch { throw new StorageError(`Invalid gateway URL returned for ${type}: "${url || ""}"`, { type, url, }); } - return url; } /** diff --git a/src/lib/api/validation.js b/src/lib/api/validation.js index 9c88c5e..5302ff2 100644 --- a/src/lib/api/validation.js +++ b/src/lib/api/validation.js @@ -4,6 +4,11 @@ import { normalizeLevel, validateCategorySubject, } from "../backend/taxonomy.js"; +import { + normalizeExternalUrl, + normalizePlainText, + normalizeRemoteImageUrl, +} from "../security/input.js"; export class ValidationError extends Error { constructor(message, details = {}) { @@ -13,15 +18,34 @@ export class ValidationError extends Error { } } -const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EVM_ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/; const STELLAR_ADDRESS_PATTERN = /^G[A-Z2-7]{55}$/; const CURRENCY_CODE_PATTERN = /^[A-Z][A-Z0-9]{2,11}$/; +export const PROFILE_LINK_RULES = { + twitterUrl: { allowedHosts: ["x.com", "twitter.com"], allowSubdomains: true, maxLength: 256 }, + githubUrl: { allowedHosts: ["github.com"], allowSubdomains: true, maxLength: 256 }, + websiteUrl: { maxLength: 256 }, +}; export function sanitizeString(value, { maxLength = 5000 } = {}) { - if (value === undefined || value === null) return ""; - return String(value).replace(CONTROL_CHARS, "").trim().slice(0, maxLength); + return normalizePlainText(value, { maxLength }); +} + +export function normalizeUrlField(value, field, options) { + try { + return normalizeExternalUrl(value, options); + } catch (error) { + throw new ValidationError(error.message, { field }); + } +} + +export function normalizeImageField(value, field = "imageUrl") { + try { + return normalizeRemoteImageUrl(value); + } catch (error) { + throw new ValidationError(error.message, { field }); + } } export function sanitizeObject(input, fieldLimits = {}) { @@ -91,10 +115,10 @@ export function validateProfilePayload(body) { institution: sanitizeString(body?.institution, { maxLength: 160 }) || null, country: sanitizeString(body?.country, { maxLength: 80 }) || null, bio: sanitizeString(body?.bio, { maxLength: 1000 }) || null, - avatarUrl: sanitizeString(body?.avatarUrl, { maxLength: 2048 }) || null, - twitterUrl: sanitizeString(body?.twitterUrl, { maxLength: 256 }) || null, - githubUrl: sanitizeString(body?.githubUrl, { maxLength: 256 }) || null, - websiteUrl: sanitizeString(body?.websiteUrl, { maxLength: 256 }) || null, + avatarUrl: normalizeImageField(body?.avatarUrl, "avatarUrl"), + ...Object.fromEntries(Object.entries(PROFILE_LINK_RULES).map(([field, rules]) => [ + field, normalizeUrlField(body?.[field], field, rules), + ])), walletAddress, walletAddressLower: walletAddress ? walletAddress.toLowerCase() : null, }; @@ -183,8 +207,8 @@ export function validateMaterialPayload(body) { price, usageRights: sanitizeString(body?.usageRights, { maxLength: 1000 }), visibility, - coverImageUrl: sanitizeString(body?.coverImageUrl, { maxLength: 2048 }) || null, - thumbnailUrl: sanitizeString(body?.thumbnailUrl, { maxLength: 2048 }) || null, + coverImageUrl: normalizeImageField(body?.coverImageUrl, "coverImageUrl"), + thumbnailUrl: normalizeImageField(body?.thumbnailUrl, "thumbnailUrl"), tokenId: sanitizeString(body?.tokenId, { maxLength: 80 }) || null, txHash: sanitizeString(body?.txHash, { maxLength: 100 }) || null, category, @@ -241,7 +265,7 @@ export function validateMaterialUpdatePayload(body) { } if (body.thumbnailUrl !== undefined) { - allowed.thumbnailUrl = sanitizeString(body.thumbnailUrl, { maxLength: 2048 }) || null; + allowed.thumbnailUrl = normalizeImageField(body.thumbnailUrl, "thumbnailUrl"); } if (body.category !== undefined) { diff --git a/src/lib/backend/materialImport.js b/src/lib/backend/materialImport.js index 3302574..5eb87cd 100644 --- a/src/lib/backend/materialImport.js +++ b/src/lib/backend/materialImport.js @@ -1,4 +1,4 @@ -import { sanitizeString, normalizeStringList } from "../api/validation.js"; +import { sanitizeString, normalizeStringList, normalizeImageField } from "../api/validation.js"; import { normalizeSubject, normalizeCategory, @@ -52,6 +52,18 @@ export function validateImportSchema(body) { export function validateImportRow(row, index) { const errors = []; + const images = {}; + + for (const [field, value] of [ + ["coverImageUrl", row?.coverImageUrl], + ["thumbnailUrl", row?.thumbnailUrl], + ]) { + try { + images[field] = normalizeImageField(value, field); + } catch (error) { + errors.push({ field, message: error.message }); + } + } const title = sanitizeString(row?.title, { maxLength: 160 }); if (!title) { @@ -120,8 +132,7 @@ export function validateImportRow(row, index) { price, usageRights: sanitizeString(row?.usageRights, { maxLength: 1000 }) || "", visibility, - coverImageUrl: sanitizeString(row?.coverImageUrl, { maxLength: 2048 }) || null, - thumbnailUrl: sanitizeString(row?.thumbnailUrl, { maxLength: 2048 }) || null, + ...images, category, subject, level, diff --git a/src/lib/delivery/stream.js b/src/lib/delivery/stream.js index ea6cd29..7d5094e 100644 --- a/src/lib/delivery/stream.js +++ b/src/lib/delivery/stream.js @@ -14,6 +14,7 @@ import { getDb } from '@/lib/mongodb'; import { IPFS_GATEWAY_URL } from '@/lib/config/chain'; +import { normalizeDownloadFilename, normalizeExternalUrl } from '@/lib/security/input'; const DEFAULT_UPSTREAM_TIMEOUT_MS = 30_000; // 30s upstream timeout const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 * 1024; // 5GB hard limit @@ -60,7 +61,7 @@ export async function getMaterialRecord(materialId) { return { cid, - fileName: material.fileName || material.title || materialId, + fileName: normalizeDownloadFilename(material.fileName || material.title || materialId), contentType: material.contentType || 'application/octet-stream', fileSize: material.fileSize || 0, }; @@ -75,8 +76,12 @@ export async function getMaterialRecord(materialId) { */ export function buildUpstreamUrl(cid) { const gateway = process.env.PRIVATE_IPFS_GATEWAY_URL || IPFS_GATEWAY_URL; - if (cid.startsWith('http')) return cid; - return `${gateway}/ipfs/${cid}`; + const allowedHost = new URL(gateway).hostname; + if (cid.startsWith('http')) { + return normalizeExternalUrl(cid, { allowedHosts: [allowedHost] }); + } + if (!/^[a-zA-Z0-9]+$/.test(cid || '')) throw new Error('Invalid IPFS content identifier'); + return `${gateway.replace(/\/$/, '')}/ipfs/${cid}`; } /** @@ -247,4 +252,4 @@ export function validateFileSize(fileSize) { }; } return { valid: true }; -} \ No newline at end of file +} diff --git a/src/lib/security/csp.js b/src/lib/security/csp.js new file mode 100644 index 0000000..1226024 --- /dev/null +++ b/src/lib/security/csp.js @@ -0,0 +1,137 @@ +const DEFAULT_CONNECT_ORIGINS = [ + "https://soroban-testnet.stellar.org", + "https://soroban-mainnet.stellar.org", + "https://horizon-testnet.stellar.org", + "https://horizon.stellar.org", + "https://eth.merkle.io", + "https://rpc.sepolia.org", + "https://*.coinbase.com", + "https://*.walletconnect.com", + "https://*.walletconnect.org", + "wss://*.walletconnect.com", + "wss://*.walletconnect.org", +]; + +const DEFAULT_MEDIA_ORIGINS = ["https://gateway.pinata.cloud", "https://ipfs.io", "https://www.gravatar.com"]; + +function configuredOrigin(value) { + try { + const url = new URL(value); + return ["https:", "wss:"].includes(url.protocol) ? url.origin : null; + } catch { + return null; + } +} + +export function createCspNonce() { + return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +export function buildContentSecurityPolicy( + nonce, + { development = process.env.NODE_ENV === "development" } = {}, +) { + if (!/^[a-f0-9]{32}$/i.test(nonce || "")) throw new TypeError("Invalid CSP nonce"); + + const connectOrigins = [...new Set([ + ...DEFAULT_CONNECT_ORIGINS, + configuredOrigin(process.env.NEXT_PUBLIC_STELLAR_RPC_URL), + configuredOrigin(process.env.NEXT_PUBLIC_HORIZON_URL), + ].filter(Boolean))]; + const mediaOrigins = [...new Set([ + ...DEFAULT_MEDIA_ORIGINS, + configuredOrigin(process.env.NEXT_PUBLIC_GATEWAY_URL), + ].filter(Boolean))]; + return [ + "default-src 'self'", + "base-uri 'none'", + "object-src 'none'", + "frame-ancestors 'none'", + "form-action 'self'", + "frame-src 'none'", + `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${development ? " 'unsafe-eval'" : ""}`, + "script-src-attr 'none'", + `style-src 'self' 'nonce-${nonce}'${development ? " 'unsafe-inline'" : ""}`, + "style-src-attr 'unsafe-inline'", + "font-src 'self' data:", + `img-src 'self' data: blob: ${mediaOrigins.join(" ")}`, + `media-src 'self' blob: ${mediaOrigins.join(" ")}`, + `connect-src 'self' ${connectOrigins.join(" ")}`, + "worker-src 'self' blob:", + "manifest-src 'self'", + "require-trusted-types-for 'script'", + "trusted-types nextjs nextjs#bundler", + "upgrade-insecure-requests", + "report-uri /api/csp-report", + "report-to csp-endpoint", + ].join("; "); +} + +export const STATIC_SECURITY_HEADERS = { + "Cross-Origin-Embedder-Policy": "credentialless", + "Cross-Origin-Opener-Policy": "same-origin-allow-popups", + "Cross-Origin-Resource-Policy": "same-origin", + "Permissions-Policy": + "camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", +}; + +export function applyBrowserSecurityHeaders(response, { csp } = {}) { + if (csp) response.headers.set("Content-Security-Policy", csp); + for (const [name, value] of Object.entries(STATIC_SECURITY_HEADERS)) { + response.headers.set(name, value); + } + response.headers.set("Reporting-Endpoints", 'csp-endpoint="/api/csp-report"'); + return response; +} + +const CSP_REPORT_WINDOW_MS = 10 * 60 * 1000; +const seenReports = new Map(); + +function privateUrl(value) { + if (!value || value === "inline" || value === "eval") return value || null; + try { + const url = new URL(value); + return `${url.origin}${url.pathname}`.slice(0, 500); + } catch { + return "invalid-url"; + } +} + +export function normalizeCspReport(payload) { + const body = payload?.["csp-report"] || payload?.body || payload; + if (!body || typeof body !== "object") return null; + return { + effectiveDirective: String( + body["effective-directive"] || body.effectiveDirective || body["violated-directive"] || "unknown", + ).slice(0, 100), + blockedUrl: privateUrl(body["blocked-uri"] || body.blockedURL), + documentUrl: privateUrl(body["document-uri"] || body.documentURL), + sourceUrl: privateUrl(body["source-file"] || body.sourceFile), + disposition: String(body.disposition || "enforce").slice(0, 20), + statusCode: Number(body["status-code"] || body.statusCode || 0), + }; +} + +export function shouldRecordCspReport( + report, + { now = Date.now(), random = Math.random, sampleRate } = {}, +) { + if (!report) return false; + const rate = sampleRate ?? Number(process.env.CSP_REPORT_SAMPLE_RATE || "0.1"); + if (!Number.isFinite(rate) || rate <= 0 || random() > Math.min(rate, 1)) return false; + for (const [key, timestamp] of seenReports) { + if (now - timestamp > CSP_REPORT_WINDOW_MS) seenReports.delete(key); + } + if (seenReports.size >= 1_000) seenReports.delete(seenReports.keys().next().value); + const key = ["effectiveDirective", "blockedUrl", "documentUrl", "sourceUrl", "disposition"] + .map((field) => report[field]).join("|"); + if (now - seenReports.get(key) <= CSP_REPORT_WINDOW_MS) return false; + seenReports.set(key, now); + return true; +} diff --git a/src/lib/security/input.js b/src/lib/security/input.js new file mode 100644 index 0000000..9cc02a7 --- /dev/null +++ b/src/lib/security/input.js @@ -0,0 +1,112 @@ +const CONTROL_AND_BIDI_CHARS = + /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u202A-\u202E\u2066-\u2069]/g; +const PRIVATE_IPV4 = /^(?:127\.|10\.|0\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/; +const SAFE_FILE_CHARS = /[^\p{L}\p{N}._()\- ]/gu; + +export const REMOTE_IMAGE_HOSTS = Object.freeze([ + "gateway.pinata.cloud", + "ipfs.io", + "www.gravatar.com", +]); + +function configuredImageHost() { + try { + return new URL(process.env.NEXT_PUBLIC_GATEWAY_URL).hostname; + } catch { + return null; + } +} + +export function normalizePlainText(value, { maxLength = 5000 } = {}) { + if (value === undefined || value === null) return ""; + return String(value) + .normalize("NFKC") + .replace(CONTROL_AND_BIDI_CHARS, "") + .replaceAll("<", "<") + .replaceAll(">", ">") + .trim() + .slice(0, maxLength); +} + +function isPublicHostname(hostname) { + const lower = hostname.toLowerCase().replace(/\.$/, ""); + return lower.includes(".") && lower !== "localhost" && !lower.endsWith(".localhost") && + !lower.endsWith(".local") && !PRIVATE_IPV4.test(lower) && !lower.startsWith("["); +} + +export function normalizeExternalUrl( + value, + { allowedHosts, allowSubdomains = false, maxLength = 2048 } = {}, +) { + const clean = normalizePlainText(value, { maxLength }); + if (!clean) return null; + + let url; + try { + url = new URL(clean); + } catch { + throw new TypeError("URL must be absolute"); + } + + if (url.protocol !== "https:" || url.username || url.password || url.port) { + throw new TypeError("URL must use public HTTPS without credentials or a custom port"); + } + if (!isPublicHostname(url.hostname)) throw new TypeError("URL host is not public"); + + if (allowedHosts?.length) { + const hostname = url.hostname.toLowerCase(); + const allowed = allowedHosts.some((candidate) => hostname === candidate.toLowerCase() || + (allowSubdomains && hostname.endsWith(`.${candidate.toLowerCase()}`))); + if (!allowed) throw new TypeError("URL host is not allowlisted"); + } + + url.hash = ""; + return url.toString(); +} + +export function normalizeRemoteImageUrl(value) { + const clean = normalizePlainText(value, { maxLength: 2048 }); + if (!clean) return null; + if (clean.startsWith("/") && !clean.startsWith("//") && !clean.includes("\\")) { + if (/\.svg(?:$|[?#])/i.test(clean)) { + throw new TypeError("SVG is not accepted as user-controlled media"); + } + return clean; + } + const url = normalizeExternalUrl(value, { + allowedHosts: [...REMOTE_IMAGE_HOSTS, configuredImageHost()].filter(Boolean), + }); + if (/\.svg(?:$|[?#])/i.test(url)) { + throw new TypeError("SVG is not accepted as user-controlled media"); + } + return url; +} + +export function normalizeRedirectPath(value, { fallback = "/" } = {}) { + const clean = normalizePlainText(value, { maxLength: 2048 }); + if (!clean.startsWith("/") || clean.startsWith("//") || clean.includes("\\")) return fallback; + try { + const url = new URL(clean, "https://eduvault.invalid"); + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return fallback; + } +} + +export function normalizeDownloadFilename(value, { fallback = "download", maxLength = 160 } = {}) { + const basename = normalizePlainText(value, { maxLength: maxLength * 2 }) + .replaceAll("\\", "/") + .split("/") + .pop().replace(SAFE_FILE_CHARS, "-") + .replace(/\s+/g, " ") + .replace(/^\.+/, "") + .trim() + .slice(0, maxLength); + return basename || fallback; +} + +export function contentDispositionAttachment(filename) { + const safe = normalizeDownloadFilename(filename); + const ascii = safe.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_"); + return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(safe)}`; +} diff --git a/src/lib/wallet/intent.js b/src/lib/wallet/intent.js new file mode 100644 index 0000000..c8c6b35 --- /dev/null +++ b/src/lib/wallet/intent.js @@ -0,0 +1,95 @@ +import { Address, Networks, scValToNative, TransactionBuilder } from "@stellar/stellar-sdk"; + +export class WalletIntentError extends Error { + constructor(code, message) { + super(message); + this.name = "WalletIntentError"; + this.code = code; + } +} + +function mismatch(field) { + throw new WalletIntentError("wallet_intent_mismatch", `Wallet intent mismatch: ${field}`); +} + +function normalizedDecimal(value) { + const match = String(value ?? "").trim().match(/^(\d+)(?:\.(\d+))?$/); + if (!match) mismatch("amount"); + const fraction = (match[2] || "").replace(/0+$/, ""); + return `${BigInt(match[1]).toString()}${fraction ? `.${fraction}` : ""}`; +} + +export function formatWalletIntent(intent) { + const network = intent.networkPassphrase === Networks.PUBLIC + ? "Stellar Public Network" + : intent.networkPassphrase === Networks.TESTNET + ? "Stellar Testnet" + : "Unknown (signing blocked)"; + const summary = String(intent.summary || "") + .replace(/[\u0000-\u001F\u007F\u202A-\u202E\u2066-\u2069]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 200); + const unit = intent.operation === "payment" ? intent.asset : "contract units"; + return [ + `Requested action: ${summary}`, + `Network: ${network}`, + intent.contractId && `Contract: ${intent.contractId}`, + intent.destination && `Recipient: ${intent.destination}`, + intent.amount !== undefined && `Amount: ${intent.amount} ${unit || ""}`.trim(), + intent.functionName && `Action: ${intent.functionName}`, + ].filter(Boolean).join("\n"); +} + +export function verifyWalletTransactionIntent({ + xdr, + address, + networkPassphrase, + intent, +}) { + if (!intent || typeof intent !== "object" || !String(intent.summary || "").trim()) { + throw new WalletIntentError("wallet_intent_required", "A human-readable wallet intent is required"); + } + if (intent.networkPassphrase !== networkPassphrase) mismatch("network"); + + let transaction; + try { + transaction = TransactionBuilder.fromXDR(xdr, networkPassphrase); + } catch { + throw new WalletIntentError("wallet_xdr_invalid", "The transaction payload is invalid"); + } + + if (transaction.source !== address) mismatch("signing account"); + const operations = transaction.operations || []; + if (operations.length !== (intent.operationCount ?? 1)) mismatch("operation count"); + const operation = operations[intent.operationIndex ?? 0]; + if (!operation || operation.type !== intent.operation) mismatch("operation type"); + if (operation.source && operation.source !== address) mismatch("operation source"); + + if (operation.type === "payment") { + if (intent.destination && operation.destination !== intent.destination) mismatch("recipient"); + if (intent.amount !== undefined && normalizedDecimal(operation.amount) !== normalizedDecimal(intent.amount)) { + mismatch("amount"); + } + const assetCode = operation.asset?.isNative?.() ? "XLM" : operation.asset?.code; + if (intent.asset && assetCode !== intent.asset) mismatch("asset"); + if (intent.assetIssuer && operation.asset?.issuer !== intent.assetIssuer) mismatch("asset issuer"); + } else if (operation.type === "invokeHostFunction") { + let invocation; + try { + invocation = operation.func.invokeContract(); + } catch { + mismatch("contract invocation"); + } + const contractId = Address.fromScAddress(invocation.contractAddress()).toString(); + if (!intent.contractId || contractId !== intent.contractId) mismatch("contract"); + if (intent.functionName && invocation.functionName().toString() !== intent.functionName) mismatch("contract function"); + if (intent.amount !== undefined) { + if (!Number.isSafeInteger(intent.amountArgIndex)) mismatch("amount argument index"); + const actualAmount = scValToNative(invocation.args()[intent.amountArgIndex]); + if (BigInt(actualAmount) !== BigInt(intent.amount)) mismatch("amount"); + } + } + + return { source: transaction.source, operation: operation.type }; +} diff --git a/src/providers/WalletProvider.jsx b/src/providers/WalletProvider.jsx index 646d397..18459d1 100644 --- a/src/providers/WalletProvider.jsx +++ b/src/providers/WalletProvider.jsx @@ -12,6 +12,11 @@ import { KitEventType, StellarWalletsKit } from '@creit-tech/stellar-wallets-kit import { ensureKitInitialized, NETWORK_PASSPHRASE } from '@/lib/wallet/kit'; import { fetchBalances, BalancesStatus } from '@/lib/wallet/balance'; +import { + formatWalletIntent, + verifyWalletTransactionIntent, + WalletIntentError, +} from '@/lib/wallet/intent'; export const WalletStatus = Object.freeze({ Initializing: 'initializing', @@ -311,7 +316,20 @@ export function WalletProvider({ children }) { const signTransaction = useCallback( async (xdr, opts) => { - const address = opts?.address ?? assertConnected(); + const connectedAddress = assertConnected(); + const address = opts?.address ?? connectedAddress; + if (address !== connectedAddress) { + throw new WalletIntentError('wallet_intent_mismatch', 'Signing address does not match the connected wallet'); + } + verifyWalletTransactionIntent({ + xdr, + address, + networkPassphrase: NETWORK_PASSPHRASE, + intent: opts?.intent, + }); + if (!window.confirm(`Review transaction before signing:\n\n${formatWalletIntent(opts.intent)}`)) { + throw new WalletIntentError('wallet_intent_rejected', 'Transaction cancelled before wallet signing'); + } const { signedTxXdr } = await StellarWalletsKit.signTransaction(xdr, { address, networkPassphrase: NETWORK_PASSPHRASE, diff --git a/src/proxy.js b/src/proxy.js index 05e4f83..5ab793c 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -9,6 +9,11 @@ import { NextResponse } from "next/server"; import { isProtectedDashboardPath, verifyDashboardToken } from "@/lib/auth/session"; import { logger } from "@/lib/logger"; import { slidingWindowRateLimit } from "@/lib/rateLimit"; +import { + applyBrowserSecurityHeaders, + buildContentSecurityPolicy, + createCspNonce, +} from "@/lib/security/csp"; /** * Per-route rate limit rules. @@ -90,9 +95,18 @@ function applyRateLimiting(request) { } export async function proxy(req) { + const nonce = createCspNonce(); + const csp = buildContentSecurityPolicy(nonce); + const forwardedHeaders = new Headers(req.headers); + forwardedHeaders.set("Content-Security-Policy", csp); + forwardedHeaders.set("x-nonce", nonce); + + const next = () => NextResponse.next({ request: { headers: forwardedHeaders } }); + const secure = (response) => applyBrowserSecurityHeaders(response, { csp }); + // ── Rate limiting for API routes ──────────────────────────────────────── const rateLimitResponse = applyRateLimiting(req); - if (rateLimitResponse) return rateLimitResponse; + if (rateLimitResponse) return secure(rateLimitResponse); // ── Dashboard auth protection ─────────────────────────────────────────── const token = req.cookies.get("auth_token")?.value; @@ -106,24 +120,22 @@ export async function proxy(req) { }, 'Incoming request'); if (!isProtectedDashboardPath(pathname)) { - return NextResponse.next(); - } - - const secret = process.env.JWT_SECRET; - if (!token || !secret) { - const url = new URL("/", req.url); - return NextResponse.redirect(url); + return secure(next()); } - const verification = await verifyDashboardToken(token, secret); - if (!verification.valid) { + const authorized = token && process.env.JWT_SECRET && + (await verifyDashboardToken(token, process.env.JWT_SECRET)).valid; + if (!authorized) { const url = new URL("/", req.url); - return NextResponse.redirect(url); + return secure(NextResponse.redirect(url)); } - return NextResponse.next(); + return secure(next()); } export const config = { - matcher: ["/dashboard/:path*", "/api/:path*"], + matcher: [ + "/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)", + "/dashboard/:path*", + ], }; diff --git a/tests/backend/browser-security.test.mjs b/tests/backend/browser-security.test.mjs new file mode 100644 index 0000000..4937729 --- /dev/null +++ b/tests/backend/browser-security.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { test } from "node:test"; +import { + Account, Asset, Keypair, Networks, Operation, StrKey, TransactionBuilder, nativeToScVal, +} from "@stellar/stellar-sdk"; +import { + applyBrowserSecurityHeaders, buildContentSecurityPolicy, createCspNonce, + normalizeCspReport, shouldRecordCspReport, +} from "../../src/lib/security/csp.js"; +import { + contentDispositionAttachment, normalizeExternalUrl, normalizePlainText, + normalizeRedirectPath, normalizeRemoteImageUrl, +} from "../../src/lib/security/input.js"; +import { verifyWalletTransactionIntent } from "../../src/lib/wallet/intent.js"; + +function xdr(source, operation) { + return new TransactionBuilder(new Account(source, "1"), { + fee: "100", networkPassphrase: Networks.TESTNET, + }).addOperation(operation).setTimeout(30).build().toXDR(); +} + +test("strict CSP uses unique nonces and required browser headers", () => { + const nonces = new Set(Array.from({ length: 16 }, createCspNonce)); + const csp = buildContentSecurityPolicy([...nonces][0], { development: false }); + assert.equal(nonces.size, 16); + assert.match(csp, /script-src 'self' 'nonce-[a-f0-9]{32}' 'strict-dynamic'/); + assert.doesNotMatch(csp, /unsafe-eval|script-src[^;]*unsafe-inline|cdn\.jsdelivr\.net/); + for (const rule of ["frame-src 'none'", "form-action 'self'", "require-trusted-types-for 'script'", "trusted-types nextjs nextjs#bundler"]) { + assert.ok(csp.includes(rule)); + } + + const headers = applyBrowserSecurityHeaders(new Response(), { csp }).headers; + assert.equal(headers.get("x-frame-options"), "DENY"); + assert.equal(headers.get("cross-origin-opener-policy"), "same-origin-allow-popups"); + assert.equal(headers.get("cross-origin-embedder-policy"), "credentialless"); + assert.equal(headers.get("cross-origin-resource-policy"), "same-origin"); + assert.equal(headers.get("x-content-type-options"), "nosniff"); + assert.match(headers.get("strict-transport-security"), /includeSubDomains/); +}); + +test("untrusted text, URLs, SVG, redirects, and filenames are normalized", () => { + assert.equal(normalizePlainText("