Skip to content
Merged
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
11 changes: 9 additions & 2 deletions apps/sim/app/api/audit-logs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import {
buildFilterConditions,
buildOrgScopeCondition,
getOrgWorkspaceIds,
queryAuditLogs,
} from '@/app/api/v1/audit-logs/query'

Expand All @@ -29,7 +30,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return authResult.response
}

const { orgMemberIds } = authResult.context
const { organizationId, orgMemberIds } = authResult.context

const parsed = await parseRequest(
listAuditLogsContract,
Expand Down Expand Up @@ -57,7 +58,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
cursor,
} = parsed.data.query

const scopeCondition = await buildOrgScopeCondition(orgMemberIds, includeDeparted)
const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId)
const scopeCondition = buildOrgScopeCondition({
organizationId,
orgWorkspaceIds,
orgMemberIds,
includeDeparted,
})
const filterConditions = buildFilterConditions({
action,
resourceType,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/mcp/servers/test-connection/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export const POST = withRouteHandler(

// Skip unauth connect when the server returns an RFC 9728 OAuth challenge.
if (testConfig.url) {
const detectedAuthType = await detectMcpAuthType(testConfig.url)
const detectedAuthType = await detectMcpAuthType(testConfig.url, resolvedIP)
if (detectedAuthType === 'oauth') {
result.authRequired = true
result.authType = 'oauth'
Expand Down
126 changes: 126 additions & 0 deletions apps/sim/app/api/tools/clickhouse/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClickHouseConnectionConfig } from '@/tools/clickhouse/types'

const { mockValidateDatabaseHost, mockSecureFetchWithPinnedIP, mockValidateSqlWhereClause } =
vi.hoisted(() => ({
mockValidateDatabaseHost: vi.fn(),
mockSecureFetchWithPinnedIP: vi.fn(),
mockValidateSqlWhereClause: vi.fn(),
}))

vi.mock('@/lib/core/security/input-validation.server', () => ({
validateDatabaseHost: mockValidateDatabaseHost,
secureFetchWithPinnedIP: mockSecureFetchWithPinnedIP,
validateSqlWhereClause: mockValidateSqlWhereClause,
}))

import { executeClickHouseInsert, executeClickHouseQuery } from '@/app/api/tools/clickhouse/utils'

function makeConfig(
overrides: Partial<ClickHouseConnectionConfig> = {}
): ClickHouseConnectionConfig {
return {
host: 'clickhouse.example.com',
port: 8123,
database: 'default',
username: 'default',
password: 'secret',
secure: false,
...overrides,
}
}

function okResponse(body: string, summary?: string) {
return {
ok: true,
status: 200,
statusText: 'OK',
text: async () => body,
headers: {
get: (name: string) =>
name.toLowerCase() === 'x-clickhouse-summary' ? (summary ?? null) : null,
},
}
}

describe('clickhouseRequest DNS pinning', () => {
beforeEach(() => {
vi.clearAllMocks()
mockValidateDatabaseHost.mockResolvedValue({
isValid: true,
resolvedIP: '93.184.216.34',
originalHostname: 'clickhouse.example.com',
})
mockValidateSqlWhereClause.mockReturnValue({ isValid: true })
mockSecureFetchWithPinnedIP.mockResolvedValue(okResponse('{"data":[{"x":1}],"rows":1}'))
})

it('pins the connection to the validated IP, not the attacker-controlled hostname', async () => {
await executeClickHouseQuery(makeConfig({ host: 'rebind.attacker.example' }), 'SELECT 1')

expect(mockValidateDatabaseHost).toHaveBeenCalledWith('rebind.attacker.example', 'host')
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1)

const [url, pinnedIP, options] = mockSecureFetchWithPinnedIP.mock.calls[0]
// The actual TCP target is the validated IP — re-resolution of the hostname can never happen.
expect(pinnedIP).toBe('93.184.216.34')
// The hostname is preserved only in the URL (for Host header / TLS SNI), never used to connect.
expect(url).toContain('rebind.attacker.example')
expect(options.method).toBe('POST')
})

it('never issues the request when host validation fails (no SSRF window)', async () => {
mockValidateDatabaseHost.mockResolvedValue({
isValid: false,
error: 'host resolves to a blocked IP address',
})

await expect(executeClickHouseQuery(makeConfig(), 'SELECT 1')).rejects.toThrow(
'host resolves to a blocked IP address'
)
expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
})

it('uses https and disallows http redirects when secure is true', async () => {
await executeClickHouseQuery(makeConfig({ secure: true, port: 8443 }), 'SELECT 1')

const [url, , options] = mockSecureFetchWithPinnedIP.mock.calls[0]
expect(url).toMatch(/^https:\/\//)
expect(options.allowHttp).toBe(false)
})

it('allows http for the initial request when secure is false', async () => {
await executeClickHouseQuery(makeConfig({ secure: false }), 'SELECT 1')

const [url, , options] = mockSecureFetchWithPinnedIP.mock.calls[0]
expect(url).toMatch(/^http:\/\//)
expect(options.allowHttp).toBe(true)
})

it('sends the statement as the body with a matching Content-Length and auth headers', async () => {
await executeClickHouseInsert(makeConfig(), 'events', { id: 1 })

const [, , options] = mockSecureFetchWithPinnedIP.mock.calls[0]
expect(options.body).toContain('INSERT INTO `events` FORMAT JSONEachRow')
expect(options.headers['Content-Length']).toBe(String(Buffer.byteLength(options.body, 'utf-8')))
expect(options.headers['X-ClickHouse-User']).toBe('default')
expect(options.headers['X-ClickHouse-Key']).toBe('secret')
})

it('propagates non-ok responses as errors with the body text', async () => {
mockSecureFetchWithPinnedIP.mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request',
text: async () => 'Code: 62. DB::Exception: Syntax error',
headers: { get: () => null },
})

await expect(executeClickHouseQuery(makeConfig(), 'SELECT 1')).rejects.toThrow(
'Code: 62. DB::Exception: Syntax error'
)
})
})
34 changes: 16 additions & 18 deletions apps/sim/app/api/tools/clickhouse/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
secureFetchWithPinnedIP,
validateDatabaseHost,
validateSqlWhereClause,
} from '@/lib/core/security/input-validation.server'
Expand Down Expand Up @@ -81,24 +82,21 @@ async function clickhouseRequest(
url.searchParams.set('readonly', '1')
}

const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)

let response: Response
try {
response = await fetch(url.toString(), {
method: 'POST',
headers: {
'X-ClickHouse-User': config.username,
'X-ClickHouse-Key': config.password,
'Content-Type': 'text/plain; charset=utf-8',
},
body: statement,
signal: controller.signal,
})
} finally {
clearTimeout(timeout)
}
// Pin the connection to the IP that passed validation. Without this, fetch()
// would re-resolve `config.host` and a DNS-rebinding hostname could point the
// actual request at an internal/private address after validation succeeded.
const response = await secureFetchWithPinnedIP(url.toString(), hostValidation.resolvedIP!, {
method: 'POST',
headers: {
'X-ClickHouse-User': config.username,
'X-ClickHouse-Key': config.password,
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': String(Buffer.byteLength(statement, 'utf-8')),
},
body: statement,
timeout: REQUEST_TIMEOUT_MS,
allowHttp: !config.secure,
})

const text = await response.text()

Expand Down
129 changes: 129 additions & 0 deletions apps/sim/app/api/v1/audit-logs/[id]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* @vitest-environment node
*
* Tests for GET /api/v1/audit-logs/[id] — verifies the lookup is constrained
* by the organization scope and 404s for rows outside it.
*/
import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockCheckRateLimit,
mockValidateEnterpriseAuditAccess,
mockBuildOrgScopeCondition,
mockGetOrgWorkspaceIds,
} = vi.hoisted(() => ({
mockCheckRateLimit: vi.fn(),
mockValidateEnterpriseAuditAccess: vi.fn(),
mockBuildOrgScopeCondition: vi.fn(),
mockGetOrgWorkspaceIds: vi.fn(),
}))

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

vi.mock('@/app/api/v1/middleware', () => ({
checkRateLimit: mockCheckRateLimit,
createRateLimitResponse: vi.fn(),
}))

vi.mock('@/app/api/v1/audit-logs/auth', () => ({
validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess,
}))

vi.mock('@/app/api/v1/audit-logs/query', () => ({
buildOrgScopeCondition: mockBuildOrgScopeCondition,
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,
}))

vi.mock('@/app/api/v1/logs/meta', () => ({
getUserLimits: vi.fn().mockResolvedValue({}),
createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })),
}))

import { GET } from '@/app/api/v1/audit-logs/[id]/route'

const ORG_ID = 'org-1'
const MEMBER_IDS = ['admin-1', 'member-1']
const ORG_WORKSPACE_IDS = ['ws-org-1']
const SCOPE_SENTINEL = { type: 'org-scope-sentinel' }

const AUDIT_ROW = {
id: 'log-1',
workspaceId: 'ws-org-1',
actorId: 'member-1',
actorName: 'Member',
actorEmail: 'member@example.com',
action: 'workflow.created',
resourceType: 'workflow',
resourceId: 'wf-1',
resourceName: 'My Workflow',
description: 'Created workflow',
metadata: {},
ipAddress: '127.0.0.1',
userAgent: 'test',
createdAt: new Date('2026-01-01T00:00:00Z'),
}

function callRoute(id: string) {
const request = createMockRequest(
'GET',
undefined,
{},
`http://localhost:3000/api/v1/audit-logs/${id}`
)
return GET(request, { params: Promise.resolve({ id }) })
}

describe('GET /api/v1/audit-logs/[id]', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'admin-1' })
mockValidateEnterpriseAuditAccess.mockResolvedValue({
success: true,
context: { organizationId: ORG_ID, orgMemberIds: MEMBER_IDS },
})
mockGetOrgWorkspaceIds.mockResolvedValue(ORG_WORKSPACE_IDS)
mockBuildOrgScopeCondition.mockReturnValue(SCOPE_SENTINEL)
})

it('constrains the lookup with the org scope condition (includeDeparted)', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([AUDIT_ROW])

const response = await callRoute('log-1')

expect(response.status).toBe(200)
expect(mockBuildOrgScopeCondition).toHaveBeenCalledWith({
organizationId: ORG_ID,
orgWorkspaceIds: ORG_WORKSPACE_IDS,
orgMemberIds: MEMBER_IDS,
includeDeparted: true,
})
expect(dbChainMockFns.where).toHaveBeenCalledWith(
expect.objectContaining({
type: 'and',
conditions: expect.arrayContaining([SCOPE_SENTINEL]),
})
)
})

it('returns 404 when the row is outside the organization scope', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])

const response = await callRoute('log-outside-org')

expect(response.status).toBe(404)
const body = await response.json()
expect(body.error).toBe('Audit log not found')
})

it('excludes ipAddress and userAgent from the response', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([AUDIT_ROW])

const response = await callRoute('log-1')
const body = await response.json()

expect(body.data.id).toBe('log-1')
expect(body.data.ipAddress).toBeUndefined()
expect(body.data.userAgent).toBeUndefined()
})
})
Loading
Loading