mirror of
https://github.com/simstudioai/sim.git
synced 2026-11-02 22:52:17 +08:00
* fix(v2): serve HEAD, advertise PATCH, and document the reachable 403 Three HTTP-semantics defects on the v2 surface, all found by probing the published contract rather than the happy path. **HEAD answered 500 on every v2 endpoint.** Next implements a missing `HEAD` export by aliasing it onto `GET` and dropping the body when it sends, so a route's `GET` legitimately runs with `request.method === 'HEAD'`. The builders' method guard compared that against the contract's declared method and threw, so `HEAD /api/v2/workflows` and every sibling replied 500 — which is what health checkers, uptime monitors, link checkers, and some CDNs send, all of them reading the API as hard-down. RFC 9110 §9.3.2 makes HEAD identical to GET but for the body, which is exactly what running the GET path produces. Fixed once in `methodMatchesContract`, shared by all five route builders; every other mismatch stays a hard error so a handler exported under the wrong verb still fails loudly. **CORS advertised `GET,POST,OPTIONS,PUT,DELETE`** while the v2 spec has 17 `PATCH` operations, so a browser preflight for any of them was rejected. It also advertised `PUT`, which two operations use — the shape of a hand-maintained list outgrown by its surface. The list stays hand-written because middleware cannot import the contract tree without pulling Zod into the edge bundle, but it is now pinned by a test that sweeps the real contracts and fails on any method it omits. **Six operations omitted a 403 their siblings documented** — three knowledge reads and three file-upload operations. Traced from the code rather than the spec: `requirePermission` throws `NoWorkspaceAccessError` for no access at all (concealed as 404) but `InsufficientWorkspacePermissionsError` for access below `minimumRole` (a real 403), and `PersonalApiKeysDisabledError` reaches every operation a personal API key can call. So 403 was reachable on all six and the omission was an accident of hand-assembled error lists, not a policy. They now use the shared `RESOURCE_ERRORS` / `RESOURCE_CONFLICT_ERRORS` sets, and two operations spelling those same sets by hand were normalized onto them. All 128 documented operations now declare 403. The rules for HEAD, for the 403/404 split, and for using the shared error sets are recorded in `.agents/skills/v2-api-conventions/SKILL.md`. * test(proxy): update the CORS policy assertion to the served method list `proxy.test.ts` pinned the previous hand-written method string, so widening `resolveApiCorsPolicy` to advertise PATCH and HEAD left it asserting a list the middleware no longer returns. The literal is kept rather than imported from `proxy.ts` so the test still pins the exact wire value independently of the implementation. * refactor(v2): retire the error sets that could omit Forbidden The three knowledge reads and three upload operations lost their `403` by assembling `[...VALIDATED_ERRORS, ...]` by hand, and `VALIDATED_ERRORS` / `STANDARD_ERRORS` were the only exported sets that omit `Forbidden`. Migrating the last consumers to the shared `RESOURCE_*` sets left both unreferenced, so deleting them turns the fix from a one-time cleanup into an invariant: there is no longer a building block from which a workspace-scoped operation can assemble an error list without `Forbidden`. Regenerating the specs produces no diff, so the migration is output-neutral. Also folds `method-match.test.ts` into `definition.test.ts` to match the repo's `feature.ts` -> `feature.test.ts` convention, types `contractMethod` as `HttpMethod` so a contract declaring `HEAD` is unrepresentable, and drops the duplicated Next-aliasing rationale so `methodMatchesContract`'s TSDoc is its single home. * fix(cors): expose the API response headers a browser client needs Without `Access-Control-Expose-Headers` a browser can read only the six CORS-safelisted response headers, so the rate-limit budget, the `Retry-After` a 429 or 503 asks the caller to observe, and the request/run correlation ids were all on the wire but invisible to `fetch()`. Server-to-server callers were unaffected, which is why it went unnoticed. Exposed on the default `/api` policy only. The per-route `CORS_RULES` entries are wildcard-origin public endpoints and opt in individually if they ever need it, so this does not widen what an anonymous cross-origin caller can read from them.
397 lines
14 KiB
TypeScript
397 lines
14 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
|
|
/** Response headers a browser client may read; omitted leaves the CORS default. */
|
|
exposeHeaders?: string
|
|
}
|
|
|
|
/**
|
|
* Every method the `/api` surface actually answers, for the default CORS policy.
|
|
*
|
|
* Hand-written rather than derived from the contract registry because this
|
|
* module is edge middleware: importing `lib/api/contracts` would pull Zod and
|
|
* the whole contract tree into the middleware bundle. Nothing enforces the
|
|
* correspondence — the per-route `CORS_RULES` entries below are unenforced the
|
|
* same way — so a contract that introduces a new method must add it here in the
|
|
* same change. This list previously omitted `PATCH` while 17 v2 operations used
|
|
* it, so a browser preflight for any of them failed.
|
|
*
|
|
* `HEAD` is included because Next answers it from each route's `GET` handler,
|
|
* which the route builders permit via `methodMatchesContract`.
|
|
*/
|
|
const DEFAULT_API_ALLOWED_METHODS = 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS'
|
|
|
|
/**
|
|
* Response headers the `/api` surface sets that a browser client must be able to read.
|
|
*
|
|
* Without `Access-Control-Expose-Headers` a browser can read only the six
|
|
* CORS-safelisted response headers, so everything here is on the wire but
|
|
* invisible to `fetch()` — the rate-limit budget, the retry delay a 429 or 503
|
|
* asks the caller to observe, and the ids needed to correlate a run or a support
|
|
* report. Server-to-server callers are unaffected, which is why the gap is easy
|
|
* to miss.
|
|
*/
|
|
const DEFAULT_API_EXPOSED_HEADERS =
|
|
'Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-Id, X-Run-Id'
|
|
|
|
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'
|
|
|
|
/** v2 execute: run identity and modes use the v2 wire names while streaming negotiates its protocol. */
|
|
const WORKFLOW_EXECUTE_V2_HEADERS =
|
|
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, X-Run-Id, X-Sim-Stream-Protocol'
|
|
|
|
/** 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,
|
|
}),
|
|
},
|
|
{
|
|
// Mirrors the v1 rule: public execute endpoints are wildcard-origin and
|
|
// credential-free — the default credentialed policy would both block
|
|
// browser API-key calls and open a cookie-bearing CSRF surface.
|
|
match: (p) => /^\/api\/v2\/workflows\/[^/]+\/execute$/.test(p),
|
|
policy: () => ({
|
|
origin: '*',
|
|
credentials: false,
|
|
methods: 'POST,OPTIONS',
|
|
headers: WORKFLOW_EXECUTE_V2_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: DEFAULT_API_ALLOWED_METHODS,
|
|
headers: DEFAULT_API_ALLOWED_HEADERS,
|
|
exposeHeaders: DEFAULT_API_EXPOSED_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.exposeHeaders) {
|
|
response.headers.set('Access-Control-Expose-Headers', policy.exposeHeaders)
|
|
}
|
|
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).*)',
|
|
],
|
|
}
|