Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions apps/docs/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { openai } from '@ai-sdk/openai'
import {
parseTrustedProxies,
parseTrustForwardedHeaders,
resolveClientIp,
UNKNOWN_CLIENT_IP,
} from '@sim/security/client-ip'
import {
convertToModelMessages,
jsonSchema,
Expand Down Expand Up @@ -69,11 +75,35 @@ const RATE_LIMIT_MAX = 20
const RATE_LIMIT_WINDOW_MS = 60_000
const rateLimitHits = new Map<string, { count: number; resetAt: number }>()

/** Resolve the client IP from forwarding headers, falling back to a shared bucket. */
/**
* Reverse-proxy hops trusted for forwarded-IP resolution, named after the main
* app's setting so the two behave alike where both are deployed. The docs site
* ships separately and does not normally set it, so this is usually empty —
* which is safe (the rightmost, proxy-written hop wins) but coarse: if the docs
* edge presents more than one hop, visitors share one bucket. Set it here too if
* that shows up as spurious 429s.
*/
const trustedProxies = parseTrustedProxies(process.env.AUTH_TRUSTED_PROXIES)

/**
* Mirrors the main app's `TRUST_PROXY_HEADERS`. Every rule about which hop to
* read presumes a proxy wrote one of them; with nothing in front, the header is
* caller-authored and this limiter guards paid inference, so decline to guess
* and let all callers share one bucket. Defaults to true — the docs site is
* served behind an edge that sets the header.
*/
const trustForwardedHeaders = parseTrustForwardedHeaders(process.env.TRUST_PROXY_HEADERS)

/**
* Resolve the client IP from forwarding headers, falling back to a shared
* bucket. Walks the chain right to left: the leftmost `X-Forwarded-For` entry is
* caller-supplied, so keying this limit on it would let anyone rotate the header
* to mint a fresh bucket per request — and, on this endpoint, unmetered model
* spend plus unbounded growth of `rateLimitHits`. See {@link resolveClientIp}.
*/
function getClientIp(req: Request): string {
const forwarded = req.headers.get('x-forwarded-for')
if (forwarded) return forwarded.split(',')[0].trim()
return req.headers.get('x-real-ip') ?? 'unknown'
if (!trustForwardedHeaders) return UNKNOWN_CLIENT_IP
return resolveClientIp(req, trustedProxies)
Comment thread
waleedlatif1 marked this conversation as resolved.
}

/** Fixed-window check. Returns retry-after seconds when the caller is over the limit, else null. */
Expand Down
1 change: 1 addition & 0 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@ai-sdk/react": "2.0.205",
"@sim/db": "workspace:*",
"@sim/emcn": "workspace:*",
"@sim/security": "workspace:*",
"@sim/workflow-renderer": "workspace:*",
"ai": "5.0.203",
"class-variance-authority": "^0.7.1",
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=http://localhost:3000
# INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL
# TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins.
# AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients.
# AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. When set, Better Auth and Sim's own per-IP throttles both walk x-forwarded-for right to left, skip these hops, and use the first untrusted address as the client IP (the leftmost entry is caller-supplied and would otherwise let anyone mint a fresh rate-limit bucket per request). Unset, the two differ: Better Auth trusts only single-value headers, while Sim's throttles key on the rightmost, proxy-written entry — never spoofable, but a multi-hop chain collapses callers onto the edge addresses. Use your proxies' actual addresses, NOT broad private ranges that also cover clients: a caller whose own address falls inside a trusted range makes the whole chain trusted.
# TRUST_PROXY_HEADERS=false # Optional: set false when the app is exposed directly with NO reverse proxy in front. With nothing appending the peer address, x-forwarded-for/x-real-ip are written entirely by the caller, so believing them lets anyone rotate a header for a fresh per-IP rate-limit bucket per request. While false, getClientIp reports 'unknown' and per-IP limits become one shared bucket (blunt, but fails closed). Defaults to true.

# Chat (Optional)
# COPILOT_API_KEY= # Mint one at https://sim.ai. Without it the Sim Chat block, prompt jobs, and Inbox cannot run
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/chat/[identifier]/otp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import {
OTP_IP_RATE_LIMIT,
storeOTP,
} from '@/lib/core/security/otp'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { getClientIp } from '@/lib/core/utils/client-ip'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { setChatAuthCookie } from '@/app/api/chat/utils'
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/chat/[identifier]/sso/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { parseRequest } from '@/lib/api/server'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { isEmailAllowed } from '@/lib/core/security/deployment'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { getClientIp } from '@/lib/core/utils/client-ip'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'

Expand Down
95 changes: 95 additions & 0 deletions apps/sim/app/api/chat/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,21 @@ const {
mockSetDeploymentAuthCookie,
mockIsEmailAllowed,
mockCheckRateLimitDirect,
mockResetRateLimitBucket,
} = vi.hoisted(() => ({
mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
mockValidateAuthToken: vi.fn().mockReturnValue(false),
mockSetDeploymentAuthCookie: vi.fn(),
mockIsEmailAllowed: vi.fn(),
mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }),
mockResetRateLimitBucket: vi.fn().mockResolvedValue(undefined),
}))

vi.mock('@/lib/core/rate-limiter', () => ({
RateLimiter: class {
checkRateLimitDirect = mockCheckRateLimitDirect
resetRateLimitBucket = mockResetRateLimitBucket
},
}))

Expand Down Expand Up @@ -212,6 +215,98 @@ describe('Chat API Utils', () => {
expect(result.authorized).toBe(true)
})

it('clears the per-resource failure counter once a password verifies', async () => {
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}

const mockRequest = {
method: 'POST',
cookies: { get: vi.fn().mockReturnValue(null) },
} as any

await validateChatAuth('request-id', deployment, mockRequest, {
password: 'correct-password',
})

expect(mockResetRateLimitBucket).toHaveBeenCalledWith('chat-password:resource:chat-id')
})

it('leaves the per-resource failure counter consumed when the password is wrong', async () => {
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}

const mockRequest = {
method: 'POST',
cookies: { get: vi.fn().mockReturnValue(null) },
} as any

const result = await validateChatAuth('request-id', deployment, mockRequest, {
password: 'wrong-password',
})

expect(result.authorized).toBe(false)
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
'chat-password:resource:chat-id',
expect.objectContaining({ maxTokens: 500 }),
{ failClosed: true }
)
expect(mockResetRateLimitBucket).not.toHaveBeenCalled()
})

it('checks the per-resource ceiling fail-closed so an outage cannot lift it', async () => {
// It is the only bound on distributed guessing at the secret; failing open
// would silently remove it during exactly the outage an attacker waits for.
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}
const mockRequest = {
method: 'POST',
cookies: { get: vi.fn().mockReturnValue(null) },
} as any

await validateChatAuth('request-id', deployment, mockRequest, {
password: 'correct-password',
})

const resourceCall = mockCheckRateLimitDirect.mock.calls.find((call: unknown[]) =>
String(call[0]).includes(':resource:')
)
expect(resourceCall?.[2]).toEqual({ failClosed: true })
})

it('rejects guesses once the per-resource counter is exhausted, without decrypting', async () => {
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}

const mockRequest = {
method: 'POST',
cookies: { get: vi.fn().mockReturnValue(null) },
} as any

mockCheckRateLimitDirect.mockImplementation(async (key: string) =>
key.includes(':resource:') ? { allowed: false, retryAfterMs: 900_000 } : { allowed: true }
)

const result = await validateChatAuth('request-id', deployment, mockRequest, {
password: 'guess',
})

expect(result.authorized).toBe(false)
expect(result.status).toBe(429)
expect(decryptSecret).not.toHaveBeenCalled()
})

it('should reject incorrect password', async () => {
const deployment = {
id: 'chat-id',
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/contact/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { isTurnstileConfigured, verifyTurnstileToken } from '@/lib/core/security/turnstile'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { getClientIp } from '@/lib/core/utils/client-ip'
import { generateRequestId } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/demo-requests/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { parseRequest } from '@/lib/api/server'
import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { getClientIp } from '@/lib/core/utils/client-ip'
import { generateRequestId } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/files/public/[token]/otp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import {
OTP_IP_RATE_LIMIT,
storeOTP,
} from '@/lib/core/security/otp'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { getClientIp } from '@/lib/core/utils/client-ip'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/files/public/[token]/sso/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { parseRequest } from '@/lib/api/server'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { isEmailAllowed } from '@/lib/core/security/deployment'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { getClientIp } from '@/lib/core/utils/client-ip'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'

Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/help/integration-request/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { getClientIp } from '@/lib/core/utils/client-ip'
import { generateRequestId } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/speech/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { env } from '@/lib/core/config/env'
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/env-flags'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { validateAuthToken } from '@/lib/core/security/deployment'
import { getClientIp } from '@/lib/core/utils/request'
import { getClientIp } from '@/lib/core/utils/client-ip'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'

Expand Down
5 changes: 3 additions & 2 deletions apps/sim/lib/analytics/profound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
* @see https://docs.tryprofound.com/agent-analytics/custom
*/
import { createLogger } from '@sim/logger'
import { UNKNOWN_CLIENT_IP } from '@sim/security/client-ip'
import { env } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/env-flags'
import { getClientIp } from '@/lib/core/utils/request'
import { getClientIp } from '@/lib/core/utils/client-ip'
import { getBaseDomain } from '@/lib/core/utils/urls'

const logger = createLogger('ProfoundAnalytics')
Expand Down Expand Up @@ -104,7 +105,7 @@ export function sendToProfound(request: Request, statusCode: number): void {
status_code: statusCode,
ip: (() => {
const resolved = getClientIp(request)
return resolved === 'unknown' ? '0.0.0.0' : resolved
return resolved === UNKNOWN_CLIENT_IP ? '0.0.0.0' : resolved
})(),
user_agent: request.headers.get('user-agent') || '',
...(Object.keys(queryParams).length > 0 && { query_params: queryParams }),
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/auth/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { safeCompare } from '@sim/security/compare'
import { jwtVerify, SignJWT } from 'jose'
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
import { getClientIp } from '@/lib/core/utils/request'
import { getClientIp } from '@/lib/core/utils/client-ip'

const logger = createLogger('CronAuth')

Expand Down
3 changes: 2 additions & 1 deletion apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,8 @@ export const env = createEnv({
REACT_SCAN_ENABLED: z.boolean().optional(), // Enable React Scan for performance debugging (dev only)

// Network / proxy trust
AUTH_TRUSTED_PROXIES: z.string().optional(), // Comma-separated reverse-proxy IPs or CIDR ranges. When set, Better Auth walks the forwarded-IP chain right to left, skips these trusted hops, and uses the first untrusted address as the client IP. Leave unset to trust only single-value IP headers.
AUTH_TRUSTED_PROXIES: z.string().optional(), // Comma-separated reverse-proxy IPs or CIDR ranges. When set, Better Auth and getClientIp (per-IP rate-limit keys, audit rows) both walk the forwarded-IP chain right to left, skip these trusted hops, and use the first untrusted address as the client IP. Leave unset and the two differ: Better Auth trusts only single-value IP headers (recording no IP for a multi-hop chain), while getClientIp keys on the rightmost, proxy-written entry — never the caller-supplied leftmost one.
TRUST_PROXY_HEADERS: z.boolean().optional(), // Whether x-forwarded-for / x-real-ip may be believed at all. Default true: the app is assumed to sit behind a proxy that appends the peer address. Set false when it is exposed directly (no proxy), where those headers are written entirely by the caller — getClientIp then reports 'unknown' so per-IP limits become one shared bucket instead of a per-request bypass.

// SSO Configuration (for script-based registration)
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality
Expand Down
20 changes: 20 additions & 0 deletions apps/sim/lib/core/rate-limiter/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,26 @@ export class RateLimiter {
}
}

/**
* Clears a single bucket addressed by its exact storage key — the counterpart
* to {@link checkRateLimitDirect}. Lets a caller keep a failure-only counter
* (consume on every attempt, reset once the attempt succeeds) so legitimate
* traffic never walks the bucket down.
*
* Never throws: a reset that fails leaves tokens consumed, which only makes
* the limit stricter.
*/
async resetRateLimitBucket(storageKey: string): Promise<void> {
try {
await this.storage.resetBucket(storageKey)
} catch (error) {
logger.warn('Failed to reset rate limit bucket', {
storageKey,
error: toError(error).message,
})
}
}

async resetRateLimit(rateLimitKey: string): Promise<void> {
try {
await Promise.all([
Expand Down
21 changes: 10 additions & 11 deletions apps/sim/lib/core/rate-limiter/route-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* @vitest-environment node
*/
import { type ClientIpHeaderSource, resolveClientIp } from '@sim/security/client-ip'
import { createMockRequest, requestUtilsMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'

Expand All @@ -22,12 +23,14 @@ vi.mock('@/lib/core/rate-limiter/storage', async () => {
}
})

/**
* Route the globally-mocked `getClientIp` through the real resolver, so these
* assertions exercise the actual forwarded-header semantics rather than a
* hand-rolled restatement of them that could drift from the implementation.
*/
function passThroughClientIp() {
requestUtilsMockFns.mockGetClientIp.mockImplementation(
(req: { headers: { get(name: string): string | null } }) =>
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
req.headers.get('x-real-ip')?.trim() ||
'unknown'
requestUtilsMockFns.mockGetClientIp.mockImplementation((req: ClientIpHeaderSource) =>
resolveClientIp(req)
)
}

Expand Down Expand Up @@ -106,7 +109,7 @@ describe('route-helpers rate limiting', () => {
passThroughClientIp()
})

it('uses the X-Forwarded-For client IP in the bucket key', async () => {
it('keys on the proxy-appended hop, not the caller-supplied leftmost one', async () => {
consume.mockResolvedValueOnce({
allowed: true,
tokensRemaining: 9,
Expand All @@ -118,11 +121,7 @@ describe('route-helpers rate limiting', () => {

await enforceIpRateLimit('public-bucket', request)

expect(consume).toHaveBeenCalledWith(
'route:public-bucket:ip:203.0.113.7',
1,
expect.any(Object)
)
expect(consume).toHaveBeenCalledWith('route:public-bucket:ip:10.0.0.1', 1, expect.any(Object))
})

it('folds spoofed `X-Forwarded-For: unknown` into a single shared bucket', async () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/core/rate-limiter/route-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter/storage'
import { getClientIp } from '@/lib/core/utils/request'
import { getClientIp } from '@/lib/core/utils/client-ip'

const logger = createLogger('RouteRateLimit')
const rateLimiter = new RateLimiter()
Expand Down
Loading
Loading