From 9bbcb58c79bfde8df88aeeb8cb4c17d7aca4e54c Mon Sep 17 00:00:00 2001 From: WhiteX Date: Sat, 14 Jun 2025 00:52:17 +0300 Subject: [PATCH] feat: implement API response sanitizer and enhance middleware for cross-domain handling --- Dockerfile_coolify | 101 ++++++++++++++++++++++ apps/web/components/Avatar/SafeAvatar.tsx | 60 +++++++++++++ apps/web/middleware.js | 31 ++++++- apps/web/pages/_document.js | 2 + apps/web/public/api-response-sanitizer.js | 94 ++++++++++++++++++++ 5 files changed, 284 insertions(+), 4 deletions(-) create mode 100644 apps/web/components/Avatar/SafeAvatar.tsx create mode 100644 apps/web/public/api-response-sanitizer.js diff --git a/Dockerfile_coolify b/Dockerfile_coolify index ecf82a7d..ad4490b7 100644 --- a/Dockerfile_coolify +++ b/Dockerfile_coolify @@ -78,6 +78,10 @@ RUN if [ -f pnpm-lock.yaml ]; then \ else echo "Lockfile not found." && exit 1; \ fi +# Make sure the images directory exists in public folder with a placeholder avatar +RUN mkdir -p /app/web/public/images && \ + echo "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAABmJLR0QA/wD/AP+gvaeTAAADc0lEQVRoge2ZS0hUURjHf2fGcSaViDSJoKKSoiAzaGE9VrXIjRZBm2hVRG2iB0SPVUW7IM0ehLWIdlYQQY+FipWZWj0siJ60ygiziGye8870cWfuPJ1774yO9v/g4pz7+L7z/b/vnnvuOQMtWrT4lxH/OkHQWlpfqdedlo5T49wl1V9eclXkSLCtXeGDNvL5AFwoaa0CXhX0Qdpup8L7ddeiFNlL9gJLbOMCuOWkPXfSGvFvLWlLgAceUwFccdIGnbRG/HuB0x66KcBxJ21RmbsGOA/oveTx75YjwJMKthFgk5N2DICTNiJ8tmnGNdc7acNeOW3jw062J/Cu2HAKmOmkDQggpXxnGy4A9rhpRw5vZBJQWrEYs1qclECn/ekuYIHLbsSN+0CldLZhvQtnaQ7uEVXXgRNSytlCyhe28XrgdgMmcUWpdJr/5CRErbQe2GQbv6CUej0+Pl5XDD/UXQMKdVhpx4AO4KpS6pVdJ6X8KIR4YhveAOxvyGyOKDUBwKuXFxXwAdghhLhn1wkh7hGsEVv9DF4pTsmIMnz6M3mAZUqpm1LKjN1HSvm8XC5fsY0vBY7OH2p0pGJMtwExgA3AGSnlErvO5/M9EkI8tw3vALY3alSVT8mINhznfSNm3wFb/g+0ULIGNQ3a7wZ6hRDdNl1BKbVfCPHWNn4A2Duz2cwCLUMa9GnB7/evklKuUEq9K5VK72dmZsrVtKVSaSkwB3gqhPjmdW4t0DIkzuQdWnGMrvMZdFQxNniMrgDvMrDdQXcAOBTFtypp6SsZkDMg40DGOMZ0+ON0A1uBJNAP9ETxbwhaNgETZkUlwLqI/o1By1YgbdRfBvZE9G8MWnYAl4z664DvX/AJA1qeNupHgb1R/BuDlgfR923AL8w7HImwA2vpNRq/MO9wJMIOLKRH4x4GOqP4NwYt0/b5Y8DhiP6NQcvzRv0VYF8U/8agZR+Qwpw/tkfxbwxa7gLOovn9wN4o/g1By7TZWag+hzQctHwe7T2EudJrCUJDy3NgqGVIg/4RsMUFeYeWwE2rDoxhLvMqbAeOT/fkzYGJsNORT4tn+HzAHvSHAe0I4W7ZMA5cLBaLw+l0Op/L5Ubz+fw787ex6bpbNLuUT6Ct7Z4QYgnQabQV0BMuhxDilVKqh/8gYLO02q92i/8sPwESO6wpKl0UfgAAAABJRU5ErkJggg==" | base64 -d > /app/web/public/images/empty_avatar.png + # Final image FROM base AS runner RUN addgroup --system --gid 1001 system \ @@ -229,6 +233,103 @@ cat > /app/web/public/api-interceptor.js << EOF\n\ })();\n\ EOF\n\ \n\ +# Create API response sanitizer\n\ +cat > /app/web/public/api-response-sanitizer.js << EOF\n\ +/**\n\ + * API Response Sanitizer\n\ + * \n\ + * This script specifically handles API responses to ensure they don't contain\n\ + * URLs pointing to the wrong domain.\n\ + */\n\ +(function() {\n\ + console.log('[Domain Isolation] Installing API response sanitizer...');\n\ + \n\ + // Save reference to the original fetch\n\ + const originalFetch = window.fetch;\n\ +\n\ + /**\n\ + * Recursively sanitize objects to replace URLs from wrong domains\n\ + */\n\ + function sanitizeObject(obj, currentDomain) {\n\ + if (!obj || typeof obj !== 'object') return obj;\n\ + \n\ + // Handle arrays\n\ + if (Array.isArray(obj)) {\n\ + return obj.map(item => sanitizeObject(item, currentDomain));\n\ + }\n\ + \n\ + // Handle objects\n\ + const result = {};\n\ + \n\ + for (const [key, value] of Object.entries(obj)) {\n\ + // Check if this is a URL string value\n\ + if (typeof value === 'string' && \n\ + (value.startsWith('http://') || value.startsWith('https://'))) {\n\ + try {\n\ + const url = new URL(value);\n\ + if (url.hostname !== currentDomain && \n\ + !url.hostname.includes('api-gateway.umami.dev')) {\n\ + console.log(`[Sanitizer] Found cross-domain URL: ${value}`);\n\ + const newValue = value.replace(url.hostname, currentDomain);\n\ + result[key] = newValue;\n\ + continue;\n\ + }\n\ + } catch (e) {\n\ + // Not a valid URL, keep original value\n\ + }\n\ + }\n\ + \n\ + // Process nested objects/arrays\n\ + if (value && typeof value === 'object') {\n\ + result[key] = sanitizeObject(value, currentDomain);\n\ + } else {\n\ + result[key] = value;\n\ + }\n\ + }\n\ + \n\ + return result;\n\ + }\n\ + \n\ + // Override fetch to sanitize responses\n\ + window.fetch = async function(...args) {\n\ + const currentDomain = window.location.hostname;\n\ + \n\ + // Call original fetch\n\ + const response = await originalFetch.apply(this, args);\n\ + \n\ + // Clone the response so we can read it multiple times\n\ + const clonedResponse = response.clone();\n\ + \n\ + // Only process JSON responses from API endpoints\n\ + const contentType = response.headers.get('content-type');\n\ + if (contentType && contentType.includes('application/json')) {\n\ + \n\ + try {\n\ + // Read and parse the response\n\ + const originalData = await clonedResponse.json();\n\ + \n\ + // Sanitize the data\n\ + const sanitizedData = sanitizeObject(originalData, currentDomain);\n\ + \n\ + // Create a new response with sanitized data\n\ + return new Response(JSON.stringify(sanitizedData), {\n\ + status: response.status,\n\ + statusText: response.statusText,\n\ + headers: response.headers\n\ + });\n\ + } catch (e) {\n\ + console.error('[Domain Isolation] Error sanitizing response:', e);\n\ + return response; // Return original response on error\n\ + }\n\ + }\n\ + \n\ + return response;\n\ + };\n\ + \n\ + console.log('[Domain Isolation] API response sanitizer installed');\n\ +})();\n\ +EOF\n\ +\n\ echo "Enhanced patching of NextAuth cookies and domains..."\n\ find /app/web/.next -type f -name "*.js" -exec sed -i "s/domain:[^,}]*,/domain: undefined,/g" {} \\;\n\ find /app/web/.next -type f -name "*.js" -exec sed -i "s/domain: *process.env.LEARNHOUSE_COOKIE_DOMAIN/domain: undefined/g" {} \\;\n\ diff --git a/apps/web/components/Avatar/SafeAvatar.tsx b/apps/web/components/Avatar/SafeAvatar.tsx new file mode 100644 index 00000000..9f05769a --- /dev/null +++ b/apps/web/components/Avatar/SafeAvatar.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import Image from 'next/image' + +interface SafeAvatarProps { + src?: string + alt: string + size?: number + className?: string +} + +/** + * SafeAvatar component that ensures correct domain for avatar images + */ +const SafeAvatar: React.FC = ({ + src, + alt, + size = 40, + className +}) => { + // Default empty avatar path that uses relative URL (domain-safe) + const defaultAvatarSrc = '/images/empty_avatar.png' + + // Handle potentially cross-domain avatar URLs + const sanitizedSrc = React.useMemo(() => { + if (!src) return defaultAvatarSrc + + try { + // Check if the URL has a domain + const url = new URL(src, window.location.origin) + + // If the URL is from a different domain, use the default avatar + if (url.hostname !== window.location.hostname) { + console.warn(`[SafeAvatar] Detected cross-domain avatar: ${src}`) + return defaultAvatarSrc + } + + return src + } catch (e) { + // If parsing fails, just use the src as is (could be a relative path) + return src + } + }, [src]) + + return ( + {alt} { + // If image fails to load, fallback to default + const target = e.target as HTMLImageElement + target.src = defaultAvatarSrc + }} + /> + ) +} + +export default SafeAvatar \ No newline at end of file diff --git a/apps/web/middleware.js b/apps/web/middleware.js index 729b81ae..5c143ba7 100644 --- a/apps/web/middleware.js +++ b/apps/web/middleware.js @@ -4,6 +4,33 @@ import { NextResponse } from 'next/server'; export function middleware(request) { // Get the current hostname from the request headers const currentHostname = request.headers.get('host'); + + // Always inspect for cross-domain requests regardless of referrer + const url = request.nextUrl.clone(); + const path = url.pathname; + + // Check for common patterns that might indicate cross-domain content + // 1. Handle image files that might be requested from the wrong domain + if (path.endsWith('.png') || path.endsWith('.jpg') || path.endsWith('.jpeg') || + path.endsWith('.gif') || path.endsWith('.webp') || path.endsWith('.svg')) { + // Ensure image path is properly routed to current domain + if (path.includes('empty_avatar.png')) { + console.log(`Intercepting image request: ${path}`); + // Rewrite all empty_avatar.png requests to use the local domain + return NextResponse.rewrite(new URL(`/images/empty_avatar.png`, request.url)); + } + } + + // 2. Check if request is going to the wrong domain through API path + if (path.includes('/api/') && request.headers.has('referer')) { + const refererUrl = new URL(request.headers.get('referer')); + // If referer domain doesn't match the requested API domain, redirect + if (refererUrl.hostname !== currentHostname) { + console.log(`Redirecting cross-domain API request: ${path}`); + const newUrl = new URL(path, `https://${currentHostname}`); + return NextResponse.redirect(newUrl); + } + } // Get the referrer URL if it exists const referer = request.headers.get('referer'); @@ -19,10 +46,6 @@ export function middleware(request) { console.log(`Cross-domain request detected: ${refererHostname} -> ${currentHostname}`); // For path segments that might include another domain - const url = request.nextUrl.clone(); - const path = url.pathname; - - // Check if the path includes another domain name (simple check for static files) if (path.includes('/next/static/') || path.includes('/api/')) { // Ensure all paths use the current hostname // This prevents asset URL problems when different hostnames appear in the path diff --git a/apps/web/pages/_document.js b/apps/web/pages/_document.js index ec01a7b9..efb49dbf 100644 --- a/apps/web/pages/_document.js +++ b/apps/web/pages/_document.js @@ -10,6 +10,8 @@ export default function Document() {