Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
6d09bdd
fix(knowledge): require write access for batch chunk operations
waleedlatif1 Jun 10, 2026
de605eb
fix(env): restrict decrypted workspace env vars to secret admins
waleedlatif1 Jun 10, 2026
b8c7f3d
fix(files): block cross-tenant deletion via client-controlled context
waleedlatif1 Jun 10, 2026
3ed97a4
fix(telegram): verify X-Telegram-Bot-Api-Secret-Token on inbound webh…
waleedlatif1 Jun 10, 2026
ec3e66e
fix(security): pin DNS for Agiloft directExecution and Grafana update…
waleedlatif1 Jun 10, 2026
a772066
fix(api): enforce workspace allowPersonalApiKeys policy on v1 surface
waleedlatif1 Jun 10, 2026
2e9a09c
fix(billing): close usage-cap admission race with atomic reservation
waleedlatif1 Jun 10, 2026
9417348
fix(workflows): validate folderId belongs to workflow's workspace on …
waleedlatif1 Jun 10, 2026
5a482a4
fix(folders): validate parentId against workspace on create/update/re…
waleedlatif1 Jun 10, 2026
61d8f49
chore(knowledge): drop non-TSDoc inline comments from chunks route
waleedlatif1 Jun 10, 2026
5b6cae9
fix(webhooks): fail closed when HMAC signing secret is not configured
waleedlatif1 Jun 10, 2026
00e29d0
style(files): trim verbose inline comments on delete authorization fix
waleedlatif1 Jun 10, 2026
be0963c
fix(auth): close account-enumeration oracle on email sign-up
waleedlatif1 Jun 10, 2026
6d50823
style(tools): drop non-TSDoc inline comments from Grafana/Agiloft SSR…
waleedlatif1 Jun 10, 2026
b9aeab6
chore(api): trim extraneous inline comments in v1 logs/files routes
waleedlatif1 Jun 10, 2026
2979061
fix(billing): exclude table-cell dispatch from admission reservation
waleedlatif1 Jun 10, 2026
86e26b8
fix(chat): rate-limit and constant-time password auth for public chats
waleedlatif1 Jun 10, 2026
595b678
fix(security): cap JSON request body size and gate public chat endpoint
waleedlatif1 Jun 10, 2026
62764cb
fix(chat): rate-limit and constant-time password auth for public chats
waleedlatif1 Jun 10, 2026
ac56525
fix(billing): never block a lone execution on usage headroom
waleedlatif1 Jun 10, 2026
26f3cba
refactor(env): document workspace env masking, drop inline comments
waleedlatif1 Jun 10, 2026
87dface
refactor(env): convert PUT/DELETE authz comments to TSDoc
waleedlatif1 Jun 10, 2026
41f133a
fix(telegram): keep legacy webhooks working via Telegram source-IP fa…
waleedlatif1 Jun 10, 2026
536af73
fix(chat): restore constant-time password auth and IP rate limit
waleedlatif1 Jun 10, 2026
9b246ba
revert(webhooks): undo trigger auth hardening pending compat plan
waleedlatif1 Jun 10, 2026
8e3984c
test(chat): make RateLimiter mock a constructable class
waleedlatif1 Jun 10, 2026
b9d004a
fix(billing): release admission slot on pre-execution aborts; cluster…
waleedlatif1 Jun 10, 2026
b232229
improvement(files): log missing owner metadata distinctly on profile-…
waleedlatif1 Jun 10, 2026
cbfe114
fix(billing): release admission slot when async enqueue fails
waleedlatif1 Jun 10, 2026
cb0beb3
fix(api): make body-size caps NaN-safe and raise chat input/attachmen…
waleedlatif1 Jun 10, 2026
7e012af
fix(hooks): restore void return in useInlineRename onSave type
waleedlatif1 Jun 10, 2026
74a39b3
fix(billing,api): release chat reservation slot on early exit; preser…
waleedlatif1 Jun 10, 2026
7927336
fix(icons): make Infisical icon black for contrast; regenerate docs
waleedlatif1 Jun 10, 2026
eaf7392
fix(billing): release reserved slot on execute-route 503 and setup throw
waleedlatif1 Jun 10, 2026
8975698
fix(icons): make Linkup icon black for contrast
waleedlatif1 Jun 10, 2026
95d724d
fix(billing): release reserved slot if inline async job never starts
waleedlatif1 Jun 10, 2026
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
4 changes: 2 additions & 2 deletions apps/docs/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2492,7 +2492,7 @@ export function LinkupIcon(props: SVGProps<SVGSVGElement>) {
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 154 107' fill='none'>
<path
d='M150.68 72.71C146.61 70.25 137.91 69.54 124.79 70.61C128.99 57.68 133.76 35.39 121.32 25.15C115.89 20.67 107.47 19.04 97.62 20.56C94.68 21.01 91.58 21.74 88.39 22.73C78.87 8.28 66.3 0 53.86 0C39.43 0 26.13 9.34 16.41 26.29C5.68 45.01 0 71.96 0 104.23V104.53L3.6 106.94L3.88 106.83C30.58 95.56 67.58 85.07 100.59 79.4C101.6 87.64 102.12 95.99 102.12 104.24V104.52L105.49 107L105.76 106.91C106.26 106.75 155.16 90.88 153.98 77.59C153.86 76.2 153.18 74.23 150.68 72.71ZM148.41 78.09C148.72 81.54 133.24 91.06 111.84 98.89C115.97 92.1 119.82 84.17 122.78 76.36C135.66 75.14 144.53 75.55 147.79 77.53C148.38 77.88 148.41 78.09 148.41 78.09ZM116.67 77.01C114.08 83.38 110.95 89.63 107.54 95.25C107.33 89.51 106.91 83.88 106.3 78.46C109.92 77.9 113.41 77.41 116.67 77.01ZM117.77 29.5C125.38 35.76 125.78 51.32 118.87 71.18C114.75 71.63 110.28 72.24 105.6 72.98C103.05 55.17 98.28 39.97 91.42 27.75C94.57 26.82 96.97 26.35 98.46 26.11C106.72 24.84 113.58 26.04 117.77 29.5ZM53.86 5.62C65.06 5.62 74.89 12 83.09 24.59C57.7 34.54 30.32 59.41 5.78 94.8C7.43 51.48 23.03 5.62 53.86 5.62M10.19 98.24C40.75 53.93 68.2 36.44 86.07 29.59C92.45 41.24 97.2 56.55 99.84 73.93C70.52 79.03 35.64 88.5 10.19 98.24Z'
fill='currentColor'
fill='#000000'
/>
</svg>
)
Expand Down Expand Up @@ -4967,7 +4967,7 @@ export function InfisicalIcon(props: SVGProps<SVGSVGElement>) {
<svg {...props} viewBox='20 25 233 132' xmlns='http://www.w3.org/2000/svg'>
<path
d='m191.6 39.4c-20.3 0-37.15 13.21-52.9 30.61-12.99-16.4-29.8-30.61-51.06-30.61-27.74 0-50.44 23.86-50.44 51.33 0 26.68 21.43 51.8 48.98 51.8 20.55 0 37.07-13.86 51.32-31.81 12.69 16.97 29.1 31.41 53.2 31.41 27.13 0 49.85-22.96 49.85-51.4 0-27.12-20.44-51.33-48.95-51.33zm-104.3 77.94c-14.56 0-25.51-12.84-25.51-26.07 0-13.7 10.95-28.29 25.51-28.29 14.93 0 25.71 11.6 37.6 27.34-11.31 15.21-22.23 27.02-37.6 27.02zm104.4 0.25c-15 0-25.28-11.13-37.97-27.37 12.69-16.4 22.01-27.24 37.59-27.24 14.97 0 24.79 13.25 24.79 27.26 0 13-10.17 27.35-24.41 27.35z'
fill='currentColor'
fill='#000000'
/>
</svg>
)
Expand Down
1 change: 0 additions & 1 deletion apps/docs/content/docs/en/tools/servicenow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,6 @@ Attach a file to a ServiceNow record
| `recordSysId` | string | Yes | sys_id of the record to attach the file to |
| `fileName` | string | Yes | Name to give the uploaded file \(e.g., logs.txt\) |
| `file` | file | No | File to upload \(UserFile object\) |
| `fileContent` | string | No | Base64-encoded file content \(legacy\) |

#### Output

Expand Down
28 changes: 27 additions & 1 deletion apps/sim/app/api/chat/[identifier]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { deployedChatPostContract } from '@/lib/api/contracts/chats'
import { parseRequest } from '@/lib/api/server'
import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { env } from '@/lib/core/config/env'
import { validateAuthToken } from '@/lib/core/security/deployment'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
Expand Down Expand Up @@ -40,13 +43,21 @@ function toChatConfigResponse(deployment: ChatConfigSource) {
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'

const CHAT_MAX_REQUEST_BYTES = Number.parseInt(env.CHAT_MAX_REQUEST_BYTES, 10) || 220 * 1024 * 1024

export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
const { identifier } = await context.params
const requestId = generateRequestId()

const ticket = tryAdmit()
if (!ticket) {
return admissionRejectedResponse()
}

try {
const parsed = await parseRequest(deployedChatPostContract, request, context, {
maxBodyBytes: CHAT_MAX_REQUEST_BYTES,
validationErrorResponse: (err) => {
const message = err.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')
return createErrorResponse(`Invalid request body: ${message}`, 400, 'VALIDATION_ERROR')
Expand Down Expand Up @@ -125,7 +136,14 @@ export const POST = withRouteHandler(

const authResult = await validateChatAuth(requestId, deployment, request, parsedBody)
if (!authResult.authorized) {
return createErrorResponse(authResult.error || 'Authentication required', 401)
const response = createErrorResponse(
authResult.error || 'Authentication required',
authResult.status || 401
)
if (authResult.status === 429 && authResult.retryAfterMs !== undefined) {
response.headers.set('Retry-After', String(Math.ceil(authResult.retryAfterMs / 1000)))
}
return response
Comment thread
waleedlatif1 marked this conversation as resolved.
}

const { input, password, email, conversationId, files } = parsedBody
Expand Down Expand Up @@ -177,6 +195,9 @@ export const POST = withRouteHandler(
const workspaceId = workflowRecord?.workspaceId
if (!workspaceId) {
logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`)
// preprocessExecution reserved a billing concurrency slot; release it on
// this early exit since no LoggingSession will finalize to free it.
await releaseExecutionSlot(executionId)
return createErrorResponse('Workflow has no associated workspace', 500)
}

Expand Down Expand Up @@ -283,11 +304,16 @@ export const POST = withRouteHandler(
return streamResponse
} catch (error: any) {
logger.error(`[${requestId}] Error processing chat request:`, error)
// Setup failed before the workflow stream took over slot release;
// free the reserved billing slot (idempotent if already released).
await releaseExecutionSlot(executionId)
return createErrorResponse(error.message || 'Failed to process request', 500)
}
} catch (error: any) {
logger.error(`[${requestId}] Error processing chat request:`, error)
return createErrorResponse(error.message || 'Failed to process request', 500)
} finally {
ticket.release()
}
}
)
Expand Down
35 changes: 35 additions & 0 deletions apps/sim/app/api/chat/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,21 @@ const {
mockSetDeploymentAuthCookie,
mockIsEmailAllowed,
mockGetSession,
mockCheckRateLimitDirect,
} = vi.hoisted(() => ({
mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
mockValidateAuthToken: vi.fn().mockReturnValue(false),
mockSetDeploymentAuthCookie: vi.fn(),
mockIsEmailAllowed: vi.fn(),
mockGetSession: vi.fn(),
mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }),
}))

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

vi.mock('@/lib/auth', () => ({
Expand Down Expand Up @@ -149,6 +157,7 @@ describe('Chat API Utils', () => {
describe('Chat auth validation', () => {
beforeEach(() => {
mockDecryptSecret.mockResolvedValue({ decrypted: 'correct-password' })
mockCheckRateLimitDirect.mockResolvedValue({ allowed: true })
})

it('should allow access to public chats', async () => {
Expand Down Expand Up @@ -235,6 +244,32 @@ describe('Chat API Utils', () => {
expect(result.error).toBe('Invalid password')
})

it('should return 429 when the password attempt rate limit is exceeded', async () => {
mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 60_000 })

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: 'any-guess',
})

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

it('should request email auth for email-protected chats', async () => {
const deployment = {
id: 'chat-id',
Expand Down
37 changes: 35 additions & 2 deletions apps/sim/app/api/chat/utils.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@
import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest, NextResponse } from 'next/server'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import {
isEmailAllowed,
setDeploymentAuthCookie,
validateAuthToken,
} from '@/lib/core/security/deployment'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getClientIp } from '@/lib/core/utils/request'

const logger = createLogger('ChatAuthUtils')

const rateLimiter = new RateLimiter()

/**
* Throttles unauthenticated password guesses per client IP against a single
* deployment, mirroring the OTP/SSO IP limits.
*/
const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 10,
refillRate: 10,
refillIntervalMs: 15 * 60_000,
}

export function setChatAuthCookie(
response: NextResponse,
chatId: string,
Expand Down Expand Up @@ -88,7 +104,7 @@ export async function validateChatAuth(
deployment: any,
request: NextRequest,
parsedBody?: any
): Promise<{ authorized: boolean; error?: string }> {
): Promise<{ authorized: boolean; error?: string; status?: number; retryAfterMs?: number }> {
const authType = deployment.authType || 'public'

if (authType === 'public') {
Expand Down Expand Up @@ -129,8 +145,25 @@ export async function validateChatAuth(
return { authorized: false, error: 'Authentication configuration error' }
}

const ip = getClientIp(request)
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-password:ip:${deployment.id}:${ip}`,
PASSWORD_IP_RATE_LIMIT
)
if (!ipRateLimit.allowed) {
logger.warn(
`[${requestId}] Password attempt IP rate limit exceeded for chat ${deployment.id} from ${ip}`
)
return {
authorized: false,
error: 'Too many attempts. Please try again later.',
status: 429,
retryAfterMs: ipRateLimit.retryAfterMs ?? PASSWORD_IP_RATE_LIMIT.refillIntervalMs,
}
}

const { decrypted } = await decryptSecret(deployment.password)
if (password !== decrypted) {
if (!safeCompare(password, decrypted)) {
Comment thread
waleedlatif1 marked this conversation as resolved.
return { authorized: false, error: 'Invalid password' }
}

Expand Down
69 changes: 64 additions & 5 deletions apps/sim/app/api/files/authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@
import { dbChainMock, dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetFileMetadataByKey, mockGetUserEntityPermissions } = vi.hoisted(() => ({
mockGetFileMetadataByKey: vi.fn(),
mockGetUserEntityPermissions: vi.fn(),
}))
const { mockGetFileMetadataByKey, mockGetUserEntityPermissions, mockGetFileMetadata } = vi.hoisted(
() => ({
mockGetFileMetadataByKey: vi.fn(),
mockGetUserEntityPermissions: vi.fn(),
mockGetFileMetadata: vi.fn(),
})
)

vi.mock('@sim/db', () => dbChainMock)

vi.mock('@/lib/uploads', () => ({
getFileMetadata: vi.fn(),
getFileMetadata: mockGetFileMetadata,
}))

vi.mock('@/lib/uploads/config', () => ({
Expand Down Expand Up @@ -151,3 +154,59 @@ describe('verifyKBFileWriteAccess (binding-only delete authorization)', () => {
await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(false)
})
})

describe('public-context access (profile-pictures / og-images / workspace-logos)', () => {
beforeEach(() => {
vi.clearAllMocks()
})

function read(cloudKey: string, context: 'profile-pictures' | 'og-images' | 'workspace-logos') {
return verifyFileAccess(cloudKey, USER_ID, undefined, context, false)
}

function write(cloudKey: string, context: 'profile-pictures' | 'og-images' | 'workspace-logos') {
return verifyFileAccess(cloudKey, USER_ID, undefined, context, false, { requireWrite: true })
}

it('grants public reads without any ownership check', async () => {
await expect(read('og-images/banner.png', 'og-images')).resolves.toBe(true)
await expect(read('profile-pictures/123-avatar.png', 'profile-pictures')).resolves.toBe(true)
await expect(read('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(true)
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
expect(mockGetFileMetadata).not.toHaveBeenCalled()
})

it('denies a cross-tenant delete that names a workspace key under a public context', async () => {
await expect(write('workspace/victim-ws/123-report.pdf', 'og-images')).resolves.toBe(false)
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
})

it('grants a profile-picture delete only to the owning user', async () => {
mockGetFileMetadata.mockResolvedValue({ userId: USER_ID })
await expect(write('profile-pictures/123-avatar.png', 'profile-pictures')).resolves.toBe(true)
})

it('denies a profile-picture delete for a non-owner', async () => {
mockGetFileMetadata.mockResolvedValue({ userId: 'other-user' })
await expect(write('profile-pictures/123-avatar.png', 'profile-pictures')).resolves.toBe(false)
})

it('grants a workspace-logo delete to write/admin on the owning workspace', async () => {
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1' })
mockGetUserEntityPermissions.mockResolvedValue('admin')
await expect(write('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(true)
expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER_ID, 'workspace', 'ws-1')
})

it('denies a workspace-logo delete for a non-member of the owning workspace', async () => {
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'victim-ws' })
mockGetUserEntityPermissions.mockResolvedValue(null)
await expect(write('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(false)
})

it('denies a workspace-logo delete when no ownership binding exists', async () => {
mockGetFileMetadataByKey.mockResolvedValue(null)
await expect(write('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(false)
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
})
})
Loading
Loading