Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

346 lines
12 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getSessionCookie } from 'better-auth/cookies'
import { type NextRequest, NextResponse } from 'next/server'
import { sendToProfound } from './lib/analytics/profound'
import { getEnv } from './lib/core/config/env'
import { isAuthDisabled, isDev, isHosted } from './lib/core/config/env-flags'
import { generateRuntimeCSP } from './lib/core/security/csp'
import { getClientIp } from './lib/core/utils/request'
import { isNonCanonicalSimHost } from './lib/core/utils/urls'
const logger = createLogger('Proxy')
export interface CorsPolicy {
origin: string
credentials: boolean
methods: string
headers: string
}
const DEFAULT_API_ALLOWED_HEADERS =
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization'
const WORKFLOW_EXECUTE_HEADERS =
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, X-Execution-Id, X-Execution-Mode, X-Execution-Timeout-Seconds'
/** Subpaths under /api/chat/* that serve the workspace UI, not embeds. */
const EMBED_RESERVED_SEGMENTS = new Set(['manage', 'validate'])
/** True for /api/chat/[identifier] and any deeper subroute. */
function isEmbedPath(pathname: string): boolean {
const segments = pathname.split('/')
if (segments.length < 4) return false
if (segments[1] !== 'api') return false
if (segments[2] !== 'chat') return false
const identifier = segments[3]
if (!identifier || EMBED_RESERVED_SEGMENTS.has(identifier)) return false
return true
}
interface CorsRule {
match: (pathname: string) => boolean
policy: (request: NextRequest) => CorsPolicy
}
const CORS_RULES: readonly CorsRule[] = [
{
match: (p) => p.startsWith('/api/auth/oauth2/'),
policy: () => ({
origin: '*',
credentials: false,
methods: 'GET, POST, OPTIONS',
headers: 'Content-Type, Authorization, Accept',
}),
},
{
match: (p) => p === '/api/mcp/copilot',
policy: () => ({
origin: '*',
credentials: false,
methods: 'GET, POST, OPTIONS, DELETE',
headers: 'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept',
}),
},
{
match: (p) => isEmbedPath(p),
policy: (request) => {
const requestOrigin = request.headers.get('origin')
return {
origin: requestOrigin || '*',
credentials: !!requestOrigin,
methods: 'GET, POST, PUT, OPTIONS',
headers: 'Content-Type, X-Requested-With',
}
},
},
{
match: (p) => /^\/api\/workflows\/[^/]+\/execute$/.test(p),
policy: () => ({
origin: '*',
credentials: false,
methods: 'GET,POST,OPTIONS,PUT',
headers: WORKFLOW_EXECUTE_HEADERS,
}),
},
]
/** Single source of truth for /api/* CORS — resolved at request time, not baked at build. */
export function resolveApiCorsPolicy(request: NextRequest): CorsPolicy {
const { pathname } = request.nextUrl
for (const rule of CORS_RULES) {
if (rule.match(pathname)) return rule.policy(request)
}
return {
origin: getEnv('NEXT_PUBLIC_APP_URL') || 'http://localhost:3001',
credentials: true,
methods: 'GET,POST,OPTIONS,PUT,DELETE',
headers: DEFAULT_API_ALLOWED_HEADERS,
}
}
const CORS_PREFLIGHT_MAX_AGE = '86400'
function applyCorsHeaders(response: NextResponse, policy: CorsPolicy): void {
response.headers.set('Access-Control-Allow-Origin', policy.origin)
response.headers.set('Access-Control-Allow-Credentials', String(policy.credentials))
response.headers.set('Access-Control-Allow-Methods', policy.methods)
response.headers.set('Access-Control-Allow-Headers', policy.headers)
if (policy.origin !== '*') {
response.headers.set('Vary', 'Origin')
}
}
/** Next's auto-OPTIONS doesn't carry middleware headers, so we answer preflight here. */
function buildPreflightResponse(policy: CorsPolicy): NextResponse {
const response = new NextResponse(null, { status: 204 })
applyCorsHeaders(response, policy)
response.headers.set('Access-Control-Max-Age', CORS_PREFLIGHT_MAX_AGE)
return response
}
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 && !isDev) {
// Self-hosted production: 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 'home' query parameter (e.g., ?home)
// This allows intentional navigation to the homepage from anywhere in the app
if (hasActiveSession) {
const isBrowsingHome = url.searchParams.has('home')
if (!isBrowsingHome) {
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)
)
}
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('X-Frame-Options', 'SAMEORIGIN')
return response
}
/**
* Handles security filtering for suspicious user agents
*/
function handleSecurityFiltering(request: NextRequest): NextResponse | null {
const userAgent = request.headers.get('user-agent') || ''
const { pathname } = request.nextUrl
const isWebhookEndpoint =
pathname.startsWith('/api/webhooks/trigger/') ||
pathname.startsWith('/api/webhooks/tiktok') ||
pathname.startsWith('/api/webhooks/agentmail')
const isMcpEndpoint = pathname.startsWith('/api/mcp/')
const isMcpOauthDiscoveryEndpoint =
pathname.startsWith('/.well-known/oauth-authorization-server') ||
pathname.startsWith('/.well-known/oauth-protected-resource')
const isSuspicious = SUSPICIOUS_UA_PATTERNS.some((pattern) => pattern.test(userAgent))
// Block suspicious requests, but exempt machine-to-machine endpoints that may
// legitimately omit User-Agent headers (webhooks and MCP protocol discovery/calls).
if (isSuspicious && !isWebhookEndpoint && !isMcpEndpoint && !isMcpOauthDiscoveryEndpoint) {
logger.warn('Blocked suspicious request', {
userAgent,
ip: getClientIp(request),
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('/api/')) {
const policy = resolveApiCorsPolicy(request)
if (request.method === 'OPTIONS') {
return buildPreflightResponse(policy)
}
const response = NextResponse.next()
applyCorsHeaders(response, policy)
return response
}
const sessionCookie = getSessionCookie(request)
const hasActiveSession = isAuthDisabled || !!sessionCookie
const redirect = handleRootPathRedirects(request, hasActiveSession)
if (redirect) return track(request, redirect)
if (url.pathname === '/login' || url.pathname === '/signup') {
if (hasActiveSession) {
return track(request, NextResponse.redirect(new URL('/workspace', request.url)))
}
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('X-Frame-Options', 'SAMEORIGIN')
return track(request, response)
}
// Chat pages are publicly accessible embeds — CSP is set in next.config.ts headers
if (url.pathname.startsWith('/chat/')) {
return track(request, NextResponse.next())
}
if (url.pathname.startsWith('/workspace')) {
if (!hasActiveSession) {
return track(request, NextResponse.redirect(new URL('/login', request.url)))
}
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('X-Frame-Options', 'SAMEORIGIN')
return track(request, response)
}
const invitationRedirect = handleInvitationRedirects(request, hasActiveSession)
if (invitationRedirect) return track(request, invitationRedirect)
const securityBlock = handleSecurityFiltering(request)
if (securityBlock) return track(request, securityBlock)
const response = NextResponse.next()
response.headers.set('Vary', 'User-Agent')
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('X-Frame-Options', 'SAMEORIGIN')
return track(request, response)
}
/**
* Keeps non-production sim.ai deployments out of search results.
*
* `noindex` rather than a robots.txt `Disallow` is deliberate: a disallowed URL
* can still be indexed when linked externally, and blocking the crawl stops
* search engines from ever seeing the directive that removes pages already in
* the index. robots.txt is excluded from this proxy's matcher so it keeps
* serving the crawlable rules this header depends on.
*/
function applyIndexingPolicy(request: NextRequest, response: NextResponse): void {
const host =
request.headers.get('x-forwarded-host')?.split(',')[0]?.trim() ||
request.headers.get('host') ||
request.nextUrl.host
if (isNonCanonicalSimHost(host)) {
response.headers.set('X-Robots-Tag', 'noindex, nofollow')
}
}
/**
* Sends request data to Profound analytics (fire-and-forget) and returns the response.
*/
function track(request: NextRequest, response: NextResponse): NextResponse {
applyIndexingPolicy(request, response)
sendToProfound(request, response.status)
return response
}
export const config = {
matcher: [
'/', // 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
'/api/:path*', // Runtime CORS
// Catch-all for other pages, excluding static assets and public directories
'/((?!api/|api$|_next/static|_next/image|ingest|favicon.ico|logo/|landing/|static/|footer/|social/|enterprise/|favicon/|twitter/|robots.txt|sitemap.xml).*)',
],
}