From 712e4fae526571b62eb5d92b3da3d2272ad0424e Mon Sep 17 00:00:00 2001 From: martinshub-tech Date: Mon, 31 Aug 2026 09:51:17 +0100 Subject: [PATCH 1/2] feat(security): implement Next.js Edge Middleware for admin routes --- frontend/src/middleware.ts | 162 ++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 2 deletions(-) diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index ba799381..6c121df6 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -2,9 +2,167 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; const NONCE_HEADER = 'x-nonce'; -const CSP_REPORT_HEADER = 'Content-Security-Policy-Report-Only'; +const ADMIN_ROUTE_PREFIX = '/admin'; +const LOGIN_ROUTE = '/login'; -export function middleware(request: NextRequest) { +// Security settings +const ALLOWED_ADMIN_IPS = (process.env.ALLOWED_ADMIN_IPS || '').split(',').filter(Boolean); +const ALLOWED_ADMIN_COUNTRIES = (process.env.ALLOWED_ADMIN_COUNTRIES || '').split(',').filter(Boolean); +const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-for-dev'; + +// Rate Limiting (In-memory simple rate limit for edge, resets per worker) +const rateLimitMap = new Map(); +const MAX_REQUESTS = 100; +const WINDOW_MS = 60 * 1000; + +function checkRateLimit(ip: string): boolean { + const now = Date.now(); + const record = rateLimitMap.get(ip); + if (!record || now > record.resetTime) { + rateLimitMap.set(ip, { count: 1, resetTime: now + WINDOW_MS }); + return true; + } + if (record.count >= MAX_REQUESTS) { + return false; + } + record.count += 1; + return true; +} + +// Bot Detection +const BOT_USER_AGENTS = ['bot', 'crawler', 'spider', 'headless', 'scraper']; +function isBot(userAgent: string): boolean { + const ua = userAgent.toLowerCase(); + return BOT_USER_AGENTS.some((bot) => ua.includes(bot)); +} + +// Web Crypto JWT Validation +function base64UrlDecode(str: string): string { + let base64 = str.replace(/-/g, '+').replace(/_/g, '/'); + const pad = base64.length % 4; + if (pad) { + base64 += '='.repeat(4 - pad); + } + return atob(base64); +} + +function str2ab(str: string): ArrayBuffer { + const buf = new ArrayBuffer(str.length); + const bufView = new Uint8Array(buf); + for (let i = 0, strLen = str.length; i < strLen; i++) { + bufView[i] = str.charCodeAt(i); + } + return buf; +} + +async function verifyJWT(token: string, secret: string) { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + + const [headerB64, payloadB64, signatureB64] = parts; + const header = JSON.parse(base64UrlDecode(headerB64)); + const payload = JSON.parse(base64UrlDecode(payloadB64)); + + if (header.alg !== 'HS256') return null; + if (payload.exp && Date.now() >= payload.exp * 1000) return null; + + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + enc.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'] + ); + + const signature = str2ab(base64UrlDecode(signatureB64)); + const data = enc.encode(`${headerB64}.${payloadB64}`); + + const isValid = await crypto.subtle.verify('HMAC', key, signature, data); + return isValid ? payload : null; + } catch { + return null; + } +} + +function isValidRedirectUrl(url: string, requestUrl: URL): boolean { + try { + const parsed = new URL(url, requestUrl.origin); + return parsed.origin === requestUrl.origin; + } catch { + return false; + } +} + +export async function middleware(request: NextRequest) { + const ip = request.ip || request.headers.get('x-forwarded-for') || '127.0.0.1'; + const userAgent = request.headers.get('user-agent') || ''; + + // 1. Bot detection + if (isBot(userAgent)) { + return new NextResponse('Bot detected', { status: 403 }); + } + + // 2. Rate limiting + if (!checkRateLimit(ip)) { + return new NextResponse('Too many requests', { status: 429 }); + } + + // 3. Admin routes protection + if (request.nextUrl.pathname.startsWith(ADMIN_ROUTE_PREFIX)) { + const country = request.geo?.country; + + // Geo-fencing + if (ALLOWED_ADMIN_COUNTRIES.length > 0 && country && !ALLOWED_ADMIN_COUNTRIES.includes(country)) { + return new NextResponse('Access denied from your region', { status: 403 }); + } + + // IP Allowlisting + if (ALLOWED_ADMIN_IPS.length > 0 && !ALLOWED_ADMIN_IPS.includes(ip) && ip !== '127.0.0.1' && ip !== '::1') { + return new NextResponse('IP not allowed', { status: 403 }); + } + + // JWT and Session Validation + const authHeader = request.headers.get('authorization'); + const token = authHeader?.startsWith('Bearer ') + ? authHeader.substring(7) + : request.cookies.get('admin_token')?.value; + + let unauthorized = true; + + if (token) { + const payload = await verifyJWT(token, JWT_SECRET); + if (payload && payload.role === 'admin') { + const sessionNonce = request.cookies.get('session_nonce')?.value; + // Validate session nonce if present in token + if (!payload.nonce || payload.nonce === sessionNonce) { + unauthorized = false; + } + } + } + + if (unauthorized) { + const loginUrl = new URL(LOGIN_ROUTE, request.url); + const returnPath = request.nextUrl.pathname + request.nextUrl.search; + + if (isValidRedirectUrl(returnPath, request.nextUrl)) { + loginUrl.searchParams.set('returnTo', returnPath); + } + return NextResponse.redirect(loginUrl); + } + } + + // 4. Open Redirect protection on Login + if (request.nextUrl.pathname === LOGIN_ROUTE) { + const returnTo = request.nextUrl.searchParams.get('returnTo'); + if (returnTo && !isValidRedirectUrl(returnTo, request.nextUrl)) { + request.nextUrl.searchParams.delete('returnTo'); + return NextResponse.redirect(request.nextUrl); + } + } + + // 5. Existing CSP Logic const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); const response = NextResponse.next(); response.headers.set(NONCE_HEADER, nonce); From 7f3664ade2c6896a7a33695d60762b92dca81ffe Mon Sep 17 00:00:00 2001 From: martinshub-tech Date: Mon, 31 Aug 2026 09:54:22 +0100 Subject: [PATCH 2/2] feat: implement semantic CSS variables and high-contrast OLED theme --- frontend/src/app/globals.css | 48 +- frontend/src/app/layout.tsx | 20 +- .../src/components/playground/CodeEditor.tsx | 57 +- frontend/src/components/theme/ThemeToggle.tsx | 8 +- frontend/src/hooks/useThemeMode.ts | 15 +- frontend/src/lib/theme/providers.tsx | 11 - frontend/src/lib/theme/themeColors.ts | 49 +- pnpm-lock.yaml | 760 +++++++++++++++++- 8 files changed, 917 insertions(+), 51 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 2b365ee6..871294d6 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -8,6 +8,27 @@ } :root { + --bg: #ffffff; + --bg-panel: rgba(240, 240, 240, 0.78); + --bg-panel-strong: #e5e5e5; + --text-strong: #000000; + --text: #1a1a1a; + --muted: #666666; + --line: rgba(0, 0, 0, 0.11); + --brand: #d8481f; + --brand-strong: #c2410c; + --brand-soft: #ffedd5; + --accent: #0f766e; + --success: #059669; + --warning: #d97706; + --body-bg: radial-gradient(circle at top left, rgba(240, 100, 45, 0.05), transparent 28%), + radial-gradient(circle at 80% 10%, rgba(37, 181, 164, 0.05), transparent 22%), + linear-gradient(180deg, #f8fafc 0%, #ffffff 45%, #f1f5f9 100%); + --background: var(--bg); + --foreground: var(--text); +} + +.dark { --bg: #09111d; --bg-panel: rgba(14, 21, 32, 0.78); --bg-panel-strong: #121d2b; @@ -21,6 +42,28 @@ --accent: #25b5a4; --success: #34c789; --warning: #f3a63c; + --body-bg: radial-gradient(circle at top left, rgba(240, 100, 45, 0.18), transparent 28%), + radial-gradient(circle at 80% 10%, rgba(37, 181, 164, 0.12), transparent 22%), + linear-gradient(180deg, #07101a 0%, #09111d 45%, #0b1320 100%); + --background: var(--bg); + --foreground: var(--text); +} + +.oled { + --bg: #000000; + --bg-panel: #000000; + --bg-panel-strong: #000000; + --text-strong: #ffffff; + --text: #ffffff; + --muted: #cccccc; + --line: #555555; + --brand: #ffaa00; + --brand-strong: #ffcc00; + --brand-soft: #ff8800; + --accent: #00ffff; + --success: #00ff00; + --warning: #ffcc00; + --body-bg: #000000; --background: var(--bg); --foreground: var(--text); } @@ -62,10 +105,7 @@ html { body { min-height: 100vh; margin: 0; - background: - radial-gradient(circle at top left, rgba(240, 100, 45, 0.18), transparent 28%), - radial-gradient(circle at 80% 10%, rgba(37, 181, 164, 0.12), transparent 22%), - linear-gradient(180deg, #07101a 0%, #09111d 45%, #0b1320 100%); + background: var(--body-bg); color: var(--text); font-family: var(--font-sans), sans-serif; } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 76e154df..1f255358 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -9,7 +9,7 @@ import { OfflineSyncHandler } from '@/components/OfflineSyncHandler'; import { SkipLink } from '@/components/ui/SkipLink'; import { AuthProvider } from '@/contexts/AuthContext'; import { NotificationProvider } from '@/contexts/NotificationContext'; -import { ThemeProvider } from '@/contexts/ThemeContext'; +import { Providers as ThemeProvider } from '@/lib/theme/providers'; import { TutorialProvider } from '@/contexts/TutorialContext'; import { WalletProvider } from '@/contexts/WalletContext'; import { Web3OnboardingProvider } from '@/contexts/Web3OnboardingContext'; @@ -43,24 +43,6 @@ export default function RootLayout({ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;700;900&display=swap" rel="stylesheet" /> -