mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-29 16:58:11 -05:00
Next.js rewrites can strip request bodies for large payloads (1MB+), causing 400 errors from CloudFront. PostHog session recordings require up to 64MB per message. Moving the proxy to middleware ensures proper body passthrough. Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
230 lines
7.1 KiB
TypeScript
230 lines
7.1 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { getSessionCookie } from 'better-auth/cookies'
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
import { isAuthDisabled, isHosted } from './lib/core/config/feature-flags'
|
|
import { generateRuntimeCSP } from './lib/core/security/csp'
|
|
|
|
const logger = createLogger('Proxy')
|
|
|
|
const SUSPICIOUS_UA_PATTERNS = [
|
|
/^\s*$/, // Empty user agents
|
|
/\.\./, // Path traversal attempt
|
|
/<\s*script/i, // Potential XSS payloads
|
|
/^\(\)\s*{/, // Command execution attempt
|
|
/\b(sqlmap|nikto|gobuster|dirb|nmap)\b/i, // Known scanning tools
|
|
] as const
|
|
|
|
/**
|
|
* Handles authentication-based redirects for root paths
|
|
*/
|
|
function handleRootPathRedirects(
|
|
request: NextRequest,
|
|
hasActiveSession: boolean
|
|
): NextResponse | null {
|
|
const url = request.nextUrl
|
|
|
|
if (url.pathname !== '/') {
|
|
return null
|
|
}
|
|
|
|
if (!isHosted) {
|
|
// Self-hosted: Always redirect based on session
|
|
if (hasActiveSession) {
|
|
return NextResponse.redirect(new URL('/workspace', request.url))
|
|
}
|
|
return NextResponse.redirect(new URL('/login', request.url))
|
|
}
|
|
|
|
// For root path, redirect authenticated users to workspace
|
|
// Unless they have a 'from' query parameter (e.g., ?from=nav, ?from=settings)
|
|
// This allows intentional navigation to the homepage from anywhere in the app
|
|
if (hasActiveSession) {
|
|
const from = url.searchParams.get('from')
|
|
if (!from) {
|
|
return NextResponse.redirect(new URL('/workspace', request.url))
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Handles invitation link redirects for unauthenticated users
|
|
*/
|
|
function handleInvitationRedirects(
|
|
request: NextRequest,
|
|
hasActiveSession: boolean
|
|
): NextResponse | null {
|
|
if (!request.nextUrl.pathname.startsWith('/invite/')) {
|
|
return null
|
|
}
|
|
|
|
if (
|
|
!hasActiveSession &&
|
|
!request.nextUrl.pathname.endsWith('/login') &&
|
|
!request.nextUrl.pathname.endsWith('/signup') &&
|
|
!request.nextUrl.search.includes('callbackUrl')
|
|
) {
|
|
const token = request.nextUrl.searchParams.get('token')
|
|
const inviteId = request.nextUrl.pathname.split('/').pop()
|
|
const callbackParam = encodeURIComponent(`/invite/${inviteId}${token ? `?token=${token}` : ''}`)
|
|
return NextResponse.redirect(
|
|
new URL(`/login?callbackUrl=${callbackParam}&invite_flow=true`, request.url)
|
|
)
|
|
}
|
|
return NextResponse.next()
|
|
}
|
|
|
|
/**
|
|
* Handles workspace invitation API endpoint access
|
|
*/
|
|
function handleWorkspaceInvitationAPI(
|
|
request: NextRequest,
|
|
hasActiveSession: boolean
|
|
): NextResponse | null {
|
|
if (!request.nextUrl.pathname.startsWith('/api/workspaces/invitations')) {
|
|
return null
|
|
}
|
|
|
|
if (request.nextUrl.pathname.includes('/accept') && !hasActiveSession) {
|
|
const token = request.nextUrl.searchParams.get('token')
|
|
if (token) {
|
|
return NextResponse.redirect(new URL(`/invite/${token}?token=${token}`, request.url))
|
|
}
|
|
}
|
|
return NextResponse.next()
|
|
}
|
|
|
|
/**
|
|
* Handles security filtering for suspicious user agents
|
|
*/
|
|
function handleSecurityFiltering(request: NextRequest): NextResponse | null {
|
|
const userAgent = request.headers.get('user-agent') || ''
|
|
const isWebhookEndpoint = request.nextUrl.pathname.startsWith('/api/webhooks/trigger/')
|
|
const isSuspicious = SUSPICIOUS_UA_PATTERNS.some((pattern) => pattern.test(userAgent))
|
|
|
|
// Block suspicious requests, but exempt webhook endpoints from User-Agent validation
|
|
if (isSuspicious && !isWebhookEndpoint) {
|
|
logger.warn('Blocked suspicious request', {
|
|
userAgent,
|
|
ip: request.headers.get('x-forwarded-for') || 'unknown',
|
|
url: request.url,
|
|
method: request.method,
|
|
pattern: SUSPICIOUS_UA_PATTERNS.find((pattern) => pattern.test(userAgent))?.toString(),
|
|
})
|
|
|
|
return new NextResponse(null, {
|
|
status: 403,
|
|
statusText: 'Forbidden',
|
|
headers: {
|
|
'Content-Type': 'text/plain',
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'X-Frame-Options': 'DENY',
|
|
'Content-Security-Policy': "default-src 'none'",
|
|
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
|
Pragma: 'no-cache',
|
|
Expires: '0',
|
|
},
|
|
})
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export async function proxy(request: NextRequest) {
|
|
const url = request.nextUrl
|
|
|
|
if (url.pathname.startsWith('/ingest/')) {
|
|
const hostname = url.pathname.startsWith('/ingest/static/')
|
|
? 'us-assets.i.posthog.com'
|
|
: 'us.i.posthog.com'
|
|
|
|
const targetPath = url.pathname.replace(/^\/ingest/, '')
|
|
const targetUrl = `https://${hostname}${targetPath}${url.search}`
|
|
|
|
return NextResponse.rewrite(new URL(targetUrl), {
|
|
request: {
|
|
headers: new Headers({
|
|
...Object.fromEntries(request.headers),
|
|
host: hostname,
|
|
}),
|
|
},
|
|
})
|
|
}
|
|
|
|
const sessionCookie = getSessionCookie(request)
|
|
const hasActiveSession = isAuthDisabled || !!sessionCookie
|
|
|
|
const redirect = handleRootPathRedirects(request, hasActiveSession)
|
|
if (redirect) return redirect
|
|
|
|
if (url.pathname === '/login' || url.pathname === '/signup') {
|
|
if (hasActiveSession) {
|
|
return NextResponse.redirect(new URL('/workspace', request.url))
|
|
}
|
|
const response = NextResponse.next()
|
|
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
|
|
return response
|
|
}
|
|
|
|
if (url.pathname.startsWith('/chat/')) {
|
|
return NextResponse.next()
|
|
}
|
|
|
|
// Allow public access to template pages for SEO
|
|
if (url.pathname.startsWith('/templates')) {
|
|
return NextResponse.next()
|
|
}
|
|
|
|
if (url.pathname.startsWith('/workspace')) {
|
|
// Allow public access to workspace template pages - they handle their own redirects
|
|
if (url.pathname.match(/^\/workspace\/[^/]+\/templates/)) {
|
|
return NextResponse.next()
|
|
}
|
|
|
|
if (!hasActiveSession) {
|
|
return NextResponse.redirect(new URL('/login', request.url))
|
|
}
|
|
return NextResponse.next()
|
|
}
|
|
|
|
const invitationRedirect = handleInvitationRedirects(request, hasActiveSession)
|
|
if (invitationRedirect) return invitationRedirect
|
|
|
|
const workspaceInvitationRedirect = handleWorkspaceInvitationAPI(request, hasActiveSession)
|
|
if (workspaceInvitationRedirect) return workspaceInvitationRedirect
|
|
|
|
const securityBlock = handleSecurityFiltering(request)
|
|
if (securityBlock) return securityBlock
|
|
|
|
const response = NextResponse.next()
|
|
response.headers.set('Vary', 'User-Agent')
|
|
|
|
if (
|
|
url.pathname.startsWith('/workspace') ||
|
|
url.pathname.startsWith('/chat') ||
|
|
url.pathname === '/'
|
|
) {
|
|
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
'/ingest/:path*', // PostHog proxy for session recording
|
|
'/', // Root path for self-hosted redirect logic
|
|
'/terms', // Whitelabel terms redirect
|
|
'/privacy', // Whitelabel privacy redirect
|
|
'/w', // Legacy /w redirect
|
|
'/w/:path*', // Legacy /w/* redirects
|
|
'/workspace/:path*', // New workspace routes
|
|
'/login',
|
|
'/signup',
|
|
'/invite/:path*', // Match invitation routes
|
|
// Catch-all for other pages, excluding static assets and public directories
|
|
'/((?!_next/static|_next/image|favicon.ico|logo/|static/|footer/|social/|enterprise/|favicon/|twitter/|robots.txt|sitemap.xml).*)',
|
|
],
|
|
}
|