Skip to content

Commit 4de3e07

Browse files
committed
fix(security): harden KB file access, SSO domain registration, webhook path isolation, env secrets, and CSV export
1 parent c6d500d commit 4de3e07

12 files changed

Lines changed: 813 additions & 38 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createEnvMock, createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
mockGetSession,
9+
mockRegisterSSOProvider,
10+
mockHasSSOAccess,
11+
mockValidateUrlWithDNS,
12+
dbState,
13+
memberTable,
14+
ssoProviderTable,
15+
} = vi.hoisted(() => ({
16+
mockGetSession: vi.fn(),
17+
mockRegisterSSOProvider: vi.fn(),
18+
mockHasSSOAccess: vi.fn(),
19+
mockValidateUrlWithDNS: vi.fn(),
20+
dbState: { members: [] as any[], providers: [] as any[] },
21+
memberTable: {
22+
userId: 'member.userId',
23+
organizationId: 'member.organizationId',
24+
role: 'member.role',
25+
},
26+
ssoProviderTable: {
27+
id: 'sso.id',
28+
providerId: 'sso.providerId',
29+
domain: 'sso.domain',
30+
issuer: 'sso.issuer',
31+
userId: 'sso.userId',
32+
organizationId: 'sso.organizationId',
33+
oidcConfig: 'sso.oidcConfig',
34+
samlConfig: 'sso.samlConfig',
35+
},
36+
}))
37+
38+
function makeBuilder(rows: any[]): any {
39+
const thenable: any = Promise.resolve(rows)
40+
thenable.where = () => makeBuilder(rows)
41+
thenable.limit = () => Promise.resolve(rows)
42+
thenable.orderBy = () => Promise.resolve(rows)
43+
return thenable
44+
}
45+
46+
vi.mock('@sim/db', () => ({
47+
db: {
48+
select: () => ({
49+
from: (table: unknown) =>
50+
makeBuilder(table === memberTable ? dbState.members : dbState.providers),
51+
}),
52+
},
53+
member: memberTable,
54+
ssoProvider: ssoProviderTable,
55+
}))
56+
57+
vi.mock('@/lib/auth', () => ({
58+
getSession: mockGetSession,
59+
auth: { api: { registerSSOProvider: mockRegisterSSOProvider } },
60+
}))
61+
62+
vi.mock('@/lib/billing', () => ({
63+
hasSSOAccess: mockHasSSOAccess,
64+
}))
65+
66+
vi.mock('@/lib/auth/sso/domain', () => ({
67+
normalizeSSODomain: (input: unknown): string | null => {
68+
if (typeof input !== 'string') return null
69+
const value = input.trim().toLowerCase()
70+
return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value) ? value : null
71+
},
72+
}))
73+
74+
vi.mock('@/lib/core/security/input-validation.server', () => ({
75+
validateUrlWithDNS: mockValidateUrlWithDNS,
76+
secureFetchWithPinnedIP: vi.fn(),
77+
}))
78+
79+
vi.mock('@/lib/core/config/env', () => createEnvMock({ SSO_ENABLED: 'true' }))
80+
81+
import { POST } from '@/app/api/auth/sso/register/route'
82+
83+
const OIDC_BODY = {
84+
providerType: 'oidc' as const,
85+
providerId: 'acme-oidc',
86+
issuer: 'https://idp.acme.com',
87+
domain: 'acme.com',
88+
clientId: 'client-id',
89+
clientSecret: 'client-secret',
90+
authorizationEndpoint: 'https://idp.acme.com/authorize',
91+
tokenEndpoint: 'https://idp.acme.com/token',
92+
userInfoEndpoint: 'https://idp.acme.com/userinfo',
93+
jwksEndpoint: 'https://idp.acme.com/jwks',
94+
}
95+
96+
function request(body: Record<string, unknown>) {
97+
return createMockRequest('POST', body)
98+
}
99+
100+
describe('POST /api/auth/sso/register', () => {
101+
beforeEach(() => {
102+
vi.clearAllMocks()
103+
dbState.members = []
104+
dbState.providers = []
105+
mockGetSession.mockResolvedValue({ user: { id: 'u1' } })
106+
mockHasSSOAccess.mockResolvedValue(true)
107+
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' })
108+
mockRegisterSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' })
109+
})
110+
111+
it('rejects callers without an Enterprise plan', async () => {
112+
mockHasSSOAccess.mockResolvedValue(false)
113+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
114+
expect(res.status).toBe(403)
115+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
116+
})
117+
118+
it('rejects callers who are not an admin/owner of the target org', async () => {
119+
dbState.members = [{ organizationId: 'org1', role: 'member' }]
120+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
121+
expect(res.status).toBe(403)
122+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
123+
})
124+
125+
it('rejects an invalid domain', async () => {
126+
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
127+
const res = await POST(request({ ...OIDC_BODY, domain: 'not-a-domain', orgId: 'org1' }))
128+
expect(res.status).toBe(400)
129+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
130+
})
131+
132+
it('rejects a domain already registered by another organization', async () => {
133+
dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }]
134+
dbState.providers = [{ domain: 'acme.com', userId: 'u-victim', organizationId: 'org-victim' }]
135+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' }))
136+
const json = await res.json()
137+
expect(res.status).toBe(409)
138+
expect(json.code).toBe('SSO_DOMAIN_ALREADY_REGISTERED')
139+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
140+
})
141+
142+
it('matches conflicts across casing variants', async () => {
143+
dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }]
144+
dbState.providers = [{ domain: 'ACME.com', userId: 'u-victim', organizationId: 'org-victim' }]
145+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' }))
146+
expect(res.status).toBe(409)
147+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
148+
})
149+
150+
it('registers when the domain is unclaimed', async () => {
151+
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
152+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
153+
expect(res.status).toBe(200)
154+
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
155+
})
156+
157+
it('allows the owning tenant to update its own provider for the same domain', async () => {
158+
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
159+
dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }]
160+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
161+
expect(res.status).toBe(200)
162+
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
163+
})
164+
165+
it('normalizes the domain before persisting it', async () => {
166+
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
167+
const res = await POST(request({ ...OIDC_BODY, domain: 'ACME.com', orgId: 'org1' }))
168+
expect(res.status).toBe(200)
169+
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
170+
const config = mockRegisterSSOProvider.mock.calls[0][0].body
171+
expect(config.domain).toBe('acme.com')
172+
})
173+
})

apps/sim/app/api/auth/sso/register/route.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
66
import { ssoRegistrationContract } from '@/lib/api/contracts/auth'
77
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
88
import { auth, getSession } from '@/lib/auth'
9+
import { normalizeSSODomain } from '@/lib/auth/sso/domain'
910
import { hasSSOAccess } from '@/lib/billing'
1011
import { env } from '@/lib/core/config/env'
1112
import {
@@ -51,7 +52,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5152
if (!parsed.success) return parsed.response
5253

5354
const body = parsed.data.body
54-
const { providerId, issuer, domain, providerType, mapping, orgId } = body
55+
const { providerId, issuer, providerType, mapping, orgId } = body
5556

5657
if (orgId) {
5758
const [membership] = await db
@@ -67,6 +68,45 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6768
}
6869
}
6970

71+
const domain = normalizeSSODomain(body.domain)
72+
if (!domain) {
73+
return NextResponse.json({ error: 'Enter a valid domain like company.com' }, { status: 400 })
74+
}
75+
76+
const isOwnedByCaller = (provider: {
77+
userId: string | null
78+
organizationId: string | null
79+
}): boolean => {
80+
if (orgId) return provider.organizationId === orgId
81+
return provider.userId === session.user.id && !provider.organizationId
82+
}
83+
84+
const existingProviders = await db
85+
.select({
86+
domain: ssoProvider.domain,
87+
userId: ssoProvider.userId,
88+
organizationId: ssoProvider.organizationId,
89+
})
90+
.from(ssoProvider)
91+
const conflictingProvider = existingProviders.find(
92+
(provider) => normalizeSSODomain(provider.domain) === domain && !isOwnedByCaller(provider)
93+
)
94+
95+
if (conflictingProvider) {
96+
logger.warn('Rejected SSO registration for domain owned by another tenant', {
97+
domain,
98+
orgId,
99+
userId: session.user.id,
100+
})
101+
return NextResponse.json(
102+
{
103+
error: 'This domain is already registered for SSO by another organization.',
104+
code: 'SSO_DOMAIN_ALREADY_REGISTERED',
105+
},
106+
{ status: 409 }
107+
)
108+
}
109+
70110
const headers: Record<string, string> = {}
71111
request.headers.forEach((value, key) => {
72112
headers[key] = value
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Security regression tests for knowledge-base file authorization.
5+
*
6+
* The historical bug: `verifyKBFileAccess` authorized access to a storage object
7+
* whenever ANY active document's `fileUrl` contained the requested key as a
8+
* substring, in a workspace the caller could reach. That let a tenant plant a
9+
* document referencing another tenant's storage key (even via an unrelated
10+
* external URL) and read/delete the victim's file.
11+
*
12+
* These tests pin the fixed behavior: authorization requires a document whose
13+
* fileUrl canonically resolves to EXACTLY the requested key, and access is
14+
* decided against the OWNING (earliest) document's workspace only.
15+
*/
16+
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
17+
import { beforeEach, describe, expect, it, vi } from 'vitest'
18+
19+
vi.mock('@sim/db', () => dbChainMock)
20+
21+
const { mockGetUserEntityPermissions } = vi.hoisted(() => ({
22+
mockGetUserEntityPermissions: vi.fn(),
23+
}))
24+
25+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
26+
getUserEntityPermissions: mockGetUserEntityPermissions,
27+
}))
28+
29+
const { mockGetFileMetadataByKey } = vi.hoisted(() => ({
30+
mockGetFileMetadataByKey: vi.fn(),
31+
}))
32+
33+
vi.mock('@/lib/uploads/server/metadata', () => ({
34+
getFileMetadataByKey: mockGetFileMetadataByKey,
35+
}))
36+
37+
vi.mock('@/lib/uploads', () => ({
38+
getFileMetadata: vi.fn().mockResolvedValue({}),
39+
}))
40+
41+
import { verifyFileAccess } from '@/app/api/files/authorization'
42+
43+
const VICTIM_KEY = 'kb/1780162789495-victim-secret.txt'
44+
const ATTACKER_USER = 'attacker-user'
45+
46+
/** Internal serve URL that legitimately resolves to VICTIM_KEY. */
47+
const internalUrlFor = (key: string) =>
48+
`/api/files/serve/s3/${encodeURIComponent(key)}?context=knowledge-base`
49+
50+
describe('verifyKBFileAccess', () => {
51+
beforeEach(() => {
52+
vi.clearAllMocks()
53+
resetDbChainMock()
54+
mockGetFileMetadataByKey.mockResolvedValue(null)
55+
})
56+
57+
it('grants access to the workspace that owns the storage key', async () => {
58+
dbChainMockFns.limit.mockResolvedValueOnce([
59+
{ workspaceId: 'ws-owner', fileUrl: internalUrlFor(VICTIM_KEY) },
60+
])
61+
mockGetUserEntityPermissions.mockResolvedValue('read')
62+
63+
const granted = await verifyFileAccess(VICTIM_KEY, 'owner-user', undefined, 'knowledge-base')
64+
65+
expect(granted).toBe(true)
66+
expect(mockGetUserEntityPermissions).toHaveBeenCalledWith('owner-user', 'workspace', 'ws-owner')
67+
})
68+
69+
it('denies access via an external URL that merely contains the key as a substring', async () => {
70+
// The demonstrated PoC: a planted document whose fileUrl is an unrelated
71+
// external URL containing the victim key. It must never authorize storage.
72+
dbChainMockFns.limit.mockResolvedValueOnce([
73+
{
74+
workspaceId: 'ws-attacker',
75+
fileUrl: `https://attacker.example/anything/${VICTIM_KEY}/marker`,
76+
},
77+
])
78+
mockGetUserEntityPermissions.mockResolvedValue('admin')
79+
80+
const granted = await verifyFileAccess(VICTIM_KEY, ATTACKER_USER, undefined, 'knowledge-base')
81+
82+
expect(granted).toBe(false)
83+
})
84+
85+
it('denies a later document planted in another workspace (ownership pins to earliest doc)', async () => {
86+
// db query orders by uploadedAt asc, so the owning (victim) document comes
87+
// first; the attacker's later internal-URL document must not grant access.
88+
dbChainMockFns.limit.mockResolvedValueOnce([
89+
{ workspaceId: 'ws-victim', fileUrl: internalUrlFor(VICTIM_KEY) },
90+
{ workspaceId: 'ws-attacker', fileUrl: internalUrlFor(VICTIM_KEY) },
91+
])
92+
mockGetUserEntityPermissions.mockImplementation(
93+
async (_userId: string, _type: string, workspaceId: string) =>
94+
workspaceId === 'ws-attacker' ? 'admin' : null
95+
)
96+
97+
const granted = await verifyFileAccess(VICTIM_KEY, ATTACKER_USER, undefined, 'knowledge-base')
98+
99+
expect(granted).toBe(false)
100+
// Authorization is decided against the owning workspace only.
101+
expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(
102+
ATTACKER_USER,
103+
'workspace',
104+
'ws-victim'
105+
)
106+
expect(mockGetUserEntityPermissions).not.toHaveBeenCalledWith(
107+
ATTACKER_USER,
108+
'workspace',
109+
'ws-attacker'
110+
)
111+
})
112+
113+
it('denies access when a substring document points at a different key', async () => {
114+
dbChainMockFns.limit.mockResolvedValueOnce([
115+
{ workspaceId: 'ws-attacker', fileUrl: internalUrlFor(`${VICTIM_KEY}-decoy`) },
116+
])
117+
mockGetUserEntityPermissions.mockResolvedValue('admin')
118+
119+
const granted = await verifyFileAccess(VICTIM_KEY, ATTACKER_USER, undefined, 'knowledge-base')
120+
121+
expect(granted).toBe(false)
122+
})
123+
124+
it('denies access when no document references the key', async () => {
125+
dbChainMockFns.limit.mockResolvedValueOnce([])
126+
127+
const granted = await verifyFileAccess(VICTIM_KEY, ATTACKER_USER, undefined, 'knowledge-base')
128+
129+
expect(granted).toBe(false)
130+
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
131+
})
132+
133+
it('denies when the owning document has no workspace (fail closed)', async () => {
134+
dbChainMockFns.limit.mockResolvedValueOnce([
135+
{ workspaceId: null, fileUrl: internalUrlFor(VICTIM_KEY) },
136+
])
137+
138+
const granted = await verifyFileAccess(VICTIM_KEY, ATTACKER_USER, undefined, 'knowledge-base')
139+
140+
expect(granted).toBe(false)
141+
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
142+
})
143+
144+
it('does not let a later workspace document override a null-workspace owner', async () => {
145+
// The earliest (owning) document belongs to a workspace-less KB; a later
146+
// document planted in the attacker's workspace must not become the owner.
147+
dbChainMockFns.limit.mockResolvedValueOnce([
148+
{ workspaceId: null, fileUrl: internalUrlFor(VICTIM_KEY) },
149+
{ workspaceId: 'ws-attacker', fileUrl: internalUrlFor(VICTIM_KEY) },
150+
])
151+
mockGetUserEntityPermissions.mockResolvedValue('admin')
152+
153+
const granted = await verifyFileAccess(VICTIM_KEY, ATTACKER_USER, undefined, 'knowledge-base')
154+
155+
expect(granted).toBe(false)
156+
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
157+
})
158+
})

0 commit comments

Comments
 (0)