Skip to content
Open
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
14 changes: 14 additions & 0 deletions __tests__/security-headers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('security headers', () => {
expect(byKey['Content-Security-Policy']).toContain('connect-src');
expect(byKey['Content-Security-Policy']).toContain('https://horizon-testnet.stellar.org');
expect(byKey['Content-Security-Policy']).toContain('https://soroban-testnet.stellar.org');
expect(byKey['Content-Security-Policy']).toContain("frame-src 'none'");
expect(byKey['Content-Security-Policy']).toContain("frame-ancestors 'none'");
expect(byKey['X-Content-Type-Options']).toBe('nosniff');
expect(byKey['X-Frame-Options']).toBe('DENY');
Expand All @@ -37,4 +38,17 @@ describe('security headers', () => {
expect(csp).toContain('https://horizon.example.com');
expect(csp).toContain('https://soroban.example.com');
});

it('uses a nonce in script-src and drops unsafe-inline when a nonce is supplied', () => {
const csp = getSecurityHeaders('abc123').find((h) => h.key === 'Content-Security-Policy').value;

expect(csp).toContain("script-src 'self' 'nonce-abc123'");
expect(csp).not.toContain("'unsafe-inline'");
});

it('falls back to unsafe-inline in script-src when no nonce is supplied', () => {
const csp = getSecurityHeaders().find((h) => h.key === 'Content-Security-Policy').value;

expect(csp).toContain("script-src 'self' 'unsafe-inline'");
});
});
39 changes: 39 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { NextResponse, type NextRequest } from 'next/server';
import { generateCspNonce } from './src/lib/nonce.js';
import { getSecurityHeaders } from './security-headers.js';

export function middleware(request: NextRequest) {
const nonce = generateCspNonce();
const headers = getSecurityHeaders(nonce);

const response = NextResponse.next({
request: {
headers: new Headers(request.headers),
},
});

// Expose the nonce to the app router so layout.tsx can bind it to the
// anti-flicker theme script.
response.headers.set('x-nonce', nonce);

// Override the static CSP with the per-request nonce variant.
for (const { key, value } of headers) {
if (key === 'Content-Security-Policy') {
response.headers.set(key, value);
}
}

return response;
}

export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
'/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
};
17 changes: 15 additions & 2 deletions security-headers.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
const DEFAULT_HORIZON_URL = 'https://horizon-testnet.stellar.org';
const DEFAULT_SOROBAN_RPC_URL = 'https://soroban-testnet.stellar.org';

function getSecurityHeaders() {
/**
* Build the app's security headers.
*
* @param {string} [nonce] - Optional per-request CSP nonce. When provided,
* `script-src` is locked to `'self'` and `'nonce-<nonce>'` instead of
* `'unsafe-inline'`. The nonce should be a cryptographically secure,
* base64-url-safe value generated on every request.
*/
function getSecurityHeaders(nonce) {
const connectSrcOrigins = Array.from(
new Set(
[
Expand All @@ -12,12 +20,17 @@ function getSecurityHeaders() {
)
);

const scriptSrc = nonce
? `'self' 'nonce-${nonce}'`
: "'self' 'unsafe-inline'";

const contentSecurityPolicy = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
`script-src ${scriptSrc}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
`connect-src ${connectSrcOrigins.join(' ')}`,
"frame-src 'none'",
"frame-ancestors 'none'",
].join('; ');

Expand Down
9 changes: 7 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import './globals.css';
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { Inter } from 'next/font/google';
import type { ReactElement, ReactNode } from 'react';
import { WalletProvider } from '@/context/WalletContext';
Expand All @@ -24,15 +25,19 @@ interface RootLayoutProps {
children: ReactNode;
}

export default function RootLayout({ children }: RootLayoutProps): ReactElement {
export default async function RootLayout({ children }: RootLayoutProps): Promise<ReactElement> {
// The middleware sets a per-request nonce so the anti-flicker theme script
// can be whitelisted in the CSP without keeping 'unsafe-inline' enabled.
const nonce = (await headers()).get('x-nonce') ?? undefined;

return (
<html lang="en" className={inter.className} suppressHydrationWarning>
<head>
{/*
Anti-flicker script: runs synchronously before first paint to apply
the correct dark/light class from localStorage, preventing theme flash.
*/}
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 transition-colors duration-200">
<I18nProvider>
Expand Down
77 changes: 0 additions & 77 deletions src/components/IPFSDocViewer/IPFSDocViewer.tsx

This file was deleted.

147 changes: 0 additions & 147 deletions src/components/IPFSDocViewer/__tests__/IPFSDocViewer.test.tsx

This file was deleted.

3 changes: 0 additions & 3 deletions src/components/IPFSDocViewer/index.ts

This file was deleted.

22 changes: 0 additions & 22 deletions src/components/IPFSDocViewer/ipfsDocViewer.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/components/leaderboard/Leaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ function Leaderboard(): ReactElement {
const container = document.getElementById(`avatar-${id}`);
if (!container) continue;
// Clear existing
container.innerHTML = '';
container.replaceChildren();
if (p?.avatarUrl) {
const img = document.createElement('img');
img.src = p.avatarUrl;
Expand Down
Loading