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
7 changes: 5 additions & 2 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,8 @@ jobs:
- name: Run tests
run: npm test --if-present

- name: Build frontend
run: npm run build --if-present
- name: Build frontend
run: npm run build --if-present

- name: Run browser security tests
run: node --test tests/browser/*.test.mjs
23 changes: 23 additions & 0 deletions docs/browser-security.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 12 additions & 17 deletions next.config.mjs
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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" },
Expand All @@ -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" },
],
},
];
Expand Down
30 changes: 30 additions & 0 deletions src/app/api/csp-report/route.js
Original file line number Diff line number Diff line change
@@ -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" } });
}
5 changes: 3 additions & 2 deletions src/app/api/delivery/stream/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -189,4 +190,4 @@ export async function GET(request) {
});
}
);
}
}
5 changes: 3 additions & 2 deletions src/app/api/materials/upload/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { auditLog } from '@/lib/api/audit'
import { withApiHardening } from '@/lib/api/hardening'
import {
normalizeStringList,
normalizeImageField,
sanitizeObject,
validateUploadPayload,
validateUploadFileMetadata,
Expand Down Expand Up @@ -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 }),
Expand Down
22 changes: 10 additions & 12 deletions src/app/api/profile/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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') {
Expand All @@ -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 (
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/upload/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { auditLog } from '@/lib/api/audit'
import { withApiHardening } from '@/lib/api/hardening'
import {
normalizeStringList,
normalizeImageField,
sanitizeObject,
validateUploadFileMetadata,
validateUploadPayload,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -483,4 +484,4 @@ export async function POST(request) {
}
},
)
}
}
7 changes: 5 additions & 2 deletions src/app/layout.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: themeInitScript }} />
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[9999] focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white focus:rounded-lg focus:shadow-lg focus:text-sm focus:font-bold"
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useStellarTransaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export function useStellarTransaction() {
}, [clearTransaction]);

const execute = useCallback(
async (unsignedXdr, { description = "Transaction", explorerBaseUrl } = {}) => {
async (unsignedXdr, { description = "Transaction", explorerBaseUrl, intent } = {}) => {
if (!isConnected) {
const error = new Error(
"Wallet not connected. Please connect your Stellar wallet first.",
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions src/lib/api/storage.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizeExternalUrl, REMOTE_IMAGE_HOSTS } from "../security/input.js";

export class StorageError extends Error {
constructor(message, details = {}) {
super(message);
Expand Down Expand Up @@ -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;
}

/**
Expand Down
44 changes: 34 additions & 10 deletions src/lib/api/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand All @@ -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 = {}) {
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading