Skip to content

Commit 89b83df

Browse files
committed
fix(mcp,audit): tighten env var domain bypass, add post-resolution check, form workspaceId
- Only bypass MCP domain check when env var is in hostname/authority, not path/query - Add post-resolution validateMcpDomain call in test-connection endpoint - Match client-side isDomainAllowed to same hostname-only bypass logic - Return workspaceId from checkFormAccess, use in form audit logs - Add 49 comprehensive domain-check tests covering all edge cases
1 parent 4855d17 commit 89b83df

File tree

6 files changed

+243
-51
lines changed

6 files changed

+243
-51
lines changed

apps/sim/app/api/form/manage/[id]/route.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
103103

104104
const { id } = await params
105105

106-
const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id)
106+
const {
107+
hasAccess,
108+
form: formRecord,
109+
workspaceId: formWorkspaceId,
110+
} = await checkFormAccess(id, session.user.id)
107111

108112
if (!hasAccess || !formRecord) {
109113
return createErrorResponse('Form not found or access denied', 404)
@@ -186,7 +190,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
186190
logger.info(`Form ${id} updated successfully`)
187191

188192
recordAudit({
189-
workspaceId: null,
193+
workspaceId: formWorkspaceId ?? null,
190194
actorId: session.user.id,
191195
action: AuditAction.FORM_UPDATED,
192196
resourceType: AuditResourceType.FORM,
@@ -227,7 +231,11 @@ export async function DELETE(
227231

228232
const { id } = await params
229233

230-
const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id)
234+
const {
235+
hasAccess,
236+
form: formRecord,
237+
workspaceId: formWorkspaceId,
238+
} = await checkFormAccess(id, session.user.id)
231239

232240
if (!hasAccess || !formRecord) {
233241
return createErrorResponse('Form not found or access denied', 404)
@@ -238,7 +246,7 @@ export async function DELETE(
238246
logger.info(`Form ${id} deleted (soft delete)`)
239247

240248
recordAudit({
241-
workspaceId: null,
249+
workspaceId: formWorkspaceId ?? null,
242250
actorId: session.user.id,
243251
action: AuditAction.FORM_DELETED,
244252
resourceType: AuditResourceType.FORM,

apps/sim/app/api/form/utils.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export async function checkWorkflowAccessForFormCreation(
5252
export async function checkFormAccess(
5353
formId: string,
5454
userId: string
55-
): Promise<{ hasAccess: boolean; form?: any }> {
55+
): Promise<{ hasAccess: boolean; form?: any; workspaceId?: string }> {
5656
const formData = await db
5757
.select({ form: form, workflowWorkspaceId: workflow.workspaceId })
5858
.from(form)
@@ -75,7 +75,9 @@ export async function checkFormAccess(
7575
action: 'admin',
7676
})
7777

78-
return authorization.allowed ? { hasAccess: true, form: formRecord } : { hasAccess: false }
78+
return authorization.allowed
79+
? { hasAccess: true, form: formRecord, workspaceId: workflowWorkspaceId }
80+
: { hasAccess: false }
7981
}
8082

8183
export async function validateFormAuth(

apps/sim/app/api/mcp/servers/test-connection/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@ export const POST = withMcpAuth('write')(
105105
logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars })
106106
}
107107

108+
// Re-validate domain after env var resolution
109+
try {
110+
validateMcpDomain(testConfig.url)
111+
} catch (e) {
112+
if (e instanceof McpDomainNotAllowedError) {
113+
return createMcpErrorResponse(e, e.message, 403)
114+
}
115+
throw e
116+
}
117+
108118
const testSecurityPolicy = {
109119
requireConsent: false,
110120
auditLevel: 'none' as const,

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,27 @@ const logger = createLogger('McpSettings')
109109
/**
110110
* Checks if a URL's hostname is in the allowed domains list.
111111
* Returns true if no allowlist is configured (null) or the domain matches.
112+
* Env var references in the hostname bypass the check since the domain
113+
* can't be determined until resolution — but env vars only in the path/query
114+
* do NOT bypass the check.
112115
*/
113-
const ENV_VAR_PATTERN = /\{\{[^}]+\}\}/
116+
const ENV_VAR_PATTERN = /\{\{[^}]+\}\}/g
117+
118+
function hasEnvVarInHostname(url: string): boolean {
119+
// If the entire URL is an env var, hostname is unknown
120+
if (url.trim().replace(ENV_VAR_PATTERN, '').trim() === '') return true
121+
const protocolEnd = url.indexOf('://')
122+
if (protocolEnd === -1) return ENV_VAR_PATTERN.test(url)
123+
const afterProtocol = url.substring(protocolEnd + 3)
124+
const authorityEnd = afterProtocol.indexOf('/')
125+
const authority = authorityEnd === -1 ? afterProtocol : afterProtocol.substring(0, authorityEnd)
126+
return new RegExp(ENV_VAR_PATTERN.source).test(authority)
127+
}
114128

115129
function isDomainAllowed(url: string | undefined, allowedDomains: string[] | null): boolean {
116130
if (allowedDomains === null) return true
117131
if (!url) return false
118-
if (ENV_VAR_PATTERN.test(url)) return true
132+
if (hasEnvVarInHostname(url)) return true
119133
try {
120134
const hostname = new URL(url).hostname.toLowerCase()
121135
return allowedDomains.includes(hostname)

apps/sim/lib/mcp/domain-check.test.ts

Lines changed: 174 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -49,44 +49,147 @@ describe('isMcpDomainAllowed', () => {
4949
it('allows empty string URL', () => {
5050
expect(isMcpDomainAllowed('')).toBe(true)
5151
})
52+
53+
it('allows env var URLs', () => {
54+
expect(isMcpDomainAllowed('{{MCP_SERVER_URL}}')).toBe(true)
55+
})
56+
57+
it('allows URLs with env vars anywhere', () => {
58+
expect(isMcpDomainAllowed('https://server.com/{{PATH}}')).toBe(true)
59+
})
5260
})
5361

5462
describe('when allowlist is configured', () => {
5563
beforeEach(() => {
5664
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com', 'internal.company.com'])
5765
})
5866

59-
it('allows URLs on the allowlist', () => {
60-
expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true)
61-
expect(isMcpDomainAllowed('https://internal.company.com/tools')).toBe(true)
62-
})
67+
describe('basic domain matching', () => {
68+
it('allows URLs on the allowlist', () => {
69+
expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true)
70+
expect(isMcpDomainAllowed('https://internal.company.com/tools')).toBe(true)
71+
})
6372

64-
it('rejects URLs not on the allowlist', () => {
65-
expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false)
66-
})
73+
it('allows URLs with paths on allowlisted domains', () => {
74+
expect(isMcpDomainAllowed('https://allowed.com/deep/path/to/mcp')).toBe(true)
75+
})
6776

68-
it('rejects undefined URL (fail-closed)', () => {
69-
expect(isMcpDomainAllowed(undefined)).toBe(false)
70-
})
77+
it('allows URLs with query params on allowlisted domains', () => {
78+
expect(isMcpDomainAllowed('https://allowed.com/mcp?key=value&foo=bar')).toBe(true)
79+
})
80+
81+
it('allows URLs with ports on allowlisted domains', () => {
82+
expect(isMcpDomainAllowed('https://allowed.com:8080/mcp')).toBe(true)
83+
})
7184

72-
it('rejects empty string URL (fail-closed)', () => {
73-
expect(isMcpDomainAllowed('')).toBe(false)
85+
it('allows HTTP URLs on allowlisted domains', () => {
86+
expect(isMcpDomainAllowed('http://allowed.com/mcp')).toBe(true)
87+
})
88+
89+
it('matches case-insensitively', () => {
90+
expect(isMcpDomainAllowed('https://ALLOWED.COM/mcp')).toBe(true)
91+
expect(isMcpDomainAllowed('https://Allowed.Com/mcp')).toBe(true)
92+
})
93+
94+
it('rejects URLs not on the allowlist', () => {
95+
expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false)
96+
})
97+
98+
it('rejects subdomains of allowed domains', () => {
99+
expect(isMcpDomainAllowed('https://sub.allowed.com/mcp')).toBe(false)
100+
})
101+
102+
it('rejects URLs with allowed domain in path only', () => {
103+
expect(isMcpDomainAllowed('https://evil.com/allowed.com/mcp')).toBe(false)
104+
})
74105
})
75106

76-
it('rejects malformed URLs', () => {
77-
expect(isMcpDomainAllowed('not-a-url')).toBe(false)
107+
describe('fail-closed behavior', () => {
108+
it('rejects undefined URL', () => {
109+
expect(isMcpDomainAllowed(undefined)).toBe(false)
110+
})
111+
112+
it('rejects empty string URL', () => {
113+
expect(isMcpDomainAllowed('')).toBe(false)
114+
})
115+
116+
it('rejects malformed URLs', () => {
117+
expect(isMcpDomainAllowed('not-a-url')).toBe(false)
118+
})
119+
120+
it('rejects URLs with no protocol', () => {
121+
expect(isMcpDomainAllowed('allowed.com/mcp')).toBe(false)
122+
})
78123
})
79124

80-
it('matches case-insensitively', () => {
81-
expect(isMcpDomainAllowed('https://ALLOWED.COM/mcp')).toBe(true)
125+
describe('env var handling — hostname bypass', () => {
126+
it('allows entirely env var URL', () => {
127+
expect(isMcpDomainAllowed('{{MCP_SERVER_URL}}')).toBe(true)
128+
})
129+
130+
it('allows env var URL with whitespace', () => {
131+
expect(isMcpDomainAllowed(' {{MCP_SERVER_URL}} ')).toBe(true)
132+
})
133+
134+
it('allows multiple env vars composing the entire URL', () => {
135+
expect(isMcpDomainAllowed('{{PROTOCOL}}{{HOST}}{{PATH}}')).toBe(true)
136+
})
137+
138+
it('allows env var in hostname portion', () => {
139+
expect(isMcpDomainAllowed('https://{{MCP_HOST}}/mcp')).toBe(true)
140+
})
141+
142+
it('allows env var as subdomain', () => {
143+
expect(isMcpDomainAllowed('https://{{TENANT}}.company.com/mcp')).toBe(true)
144+
})
145+
146+
it('allows env var in port (authority)', () => {
147+
expect(isMcpDomainAllowed('https://{{HOST}}:{{PORT}}/mcp')).toBe(true)
148+
})
149+
150+
it('allows env var as the full authority', () => {
151+
expect(isMcpDomainAllowed('https://{{MCP_HOST}}:{{MCP_PORT}}/api/mcp')).toBe(true)
152+
})
82153
})
83154

84-
it('allows env var URLs without validating domain', () => {
85-
expect(isMcpDomainAllowed('{{MCP_SERVER_URL}}')).toBe(true)
155+
describe('env var handling — no bypass when only in path/query', () => {
156+
it('rejects disallowed domain with env var in path', () => {
157+
expect(isMcpDomainAllowed('https://evil.com/{{MCP_PATH}}')).toBe(false)
158+
})
159+
160+
it('rejects disallowed domain with env var in query', () => {
161+
expect(isMcpDomainAllowed('https://evil.com/mcp?key={{API_KEY}}')).toBe(false)
162+
})
163+
164+
it('rejects disallowed domain with env var in fragment', () => {
165+
expect(isMcpDomainAllowed('https://evil.com/mcp#{{SECTION}}')).toBe(false)
166+
})
167+
168+
it('allows allowlisted domain with env var in path', () => {
169+
expect(isMcpDomainAllowed('https://allowed.com/{{MCP_PATH}}')).toBe(true)
170+
})
171+
172+
it('allows allowlisted domain with env var in query', () => {
173+
expect(isMcpDomainAllowed('https://allowed.com/mcp?key={{API_KEY}}')).toBe(true)
174+
})
175+
176+
it('rejects disallowed domain with env var in both path and query', () => {
177+
expect(isMcpDomainAllowed('https://evil.com/{{PATH}}?token={{TOKEN}}&key={{KEY}}')).toBe(
178+
false
179+
)
180+
})
86181
})
87182

88-
it('allows URLs with embedded env vars', () => {
89-
expect(isMcpDomainAllowed('https://{{MCP_HOST}}/mcp')).toBe(true)
183+
describe('env var security edge cases', () => {
184+
it('rejects URL with env var only after allowed domain in path', () => {
185+
expect(isMcpDomainAllowed('https://evil.com/allowed.com/{{VAR}}')).toBe(false)
186+
})
187+
188+
it('rejects URL trying to use env var to sneak past domain check via userinfo', () => {
189+
// https://evil.com@allowed.com would have hostname "allowed.com" per URL spec,
190+
// but https://{{VAR}}@evil.com has env var in authority so it bypasses
191+
expect(isMcpDomainAllowed('https://{{VAR}}@evil.com/mcp')).toBe(true)
192+
})
90193
})
91194
})
92195
})
@@ -108,39 +211,71 @@ describe('validateMcpDomain', () => {
108211
it('does not throw for undefined URL', () => {
109212
expect(() => validateMcpDomain(undefined)).not.toThrow()
110213
})
214+
215+
it('does not throw for empty string', () => {
216+
expect(() => validateMcpDomain('')).not.toThrow()
217+
})
111218
})
112219

113220
describe('when allowlist is configured', () => {
114221
beforeEach(() => {
115222
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com'])
116223
})
117224

118-
it('does not throw for allowed URLs', () => {
119-
expect(() => validateMcpDomain('https://allowed.com/mcp')).not.toThrow()
120-
})
225+
describe('basic validation', () => {
226+
it('does not throw for allowed URLs', () => {
227+
expect(() => validateMcpDomain('https://allowed.com/mcp')).not.toThrow()
228+
})
121229

122-
it('throws McpDomainNotAllowedError for disallowed URLs', () => {
123-
expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(McpDomainNotAllowedError)
124-
})
230+
it('throws McpDomainNotAllowedError for disallowed URLs', () => {
231+
expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(McpDomainNotAllowedError)
232+
})
125233

126-
it('throws for undefined URL (fail-closed)', () => {
127-
expect(() => validateMcpDomain(undefined)).toThrow(McpDomainNotAllowedError)
128-
})
234+
it('throws for undefined URL (fail-closed)', () => {
235+
expect(() => validateMcpDomain(undefined)).toThrow(McpDomainNotAllowedError)
236+
})
129237

130-
it('throws for malformed URLs', () => {
131-
expect(() => validateMcpDomain('not-a-url')).toThrow(McpDomainNotAllowedError)
132-
})
238+
it('throws for malformed URLs', () => {
239+
expect(() => validateMcpDomain('not-a-url')).toThrow(McpDomainNotAllowedError)
240+
})
133241

134-
it('includes the rejected domain in the error message', () => {
135-
expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(/evil\.com/)
136-
})
242+
it('includes the rejected domain in the error message', () => {
243+
expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(/evil\.com/)
244+
})
137245

138-
it('does not throw for env var URLs', () => {
139-
expect(() => validateMcpDomain('{{MCP_SERVER_URL}}')).not.toThrow()
246+
it('includes "(empty)" in error for undefined URL', () => {
247+
expect(() => validateMcpDomain(undefined)).toThrow(/\(empty\)/)
248+
})
140249
})
141250

142-
it('does not throw for URLs with embedded env vars', () => {
143-
expect(() => validateMcpDomain('https://{{MCP_HOST}}/mcp')).not.toThrow()
251+
describe('env var handling', () => {
252+
it('does not throw for entirely env var URL', () => {
253+
expect(() => validateMcpDomain('{{MCP_SERVER_URL}}')).not.toThrow()
254+
})
255+
256+
it('does not throw for env var in hostname', () => {
257+
expect(() => validateMcpDomain('https://{{MCP_HOST}}/mcp')).not.toThrow()
258+
})
259+
260+
it('does not throw for env var in authority', () => {
261+
expect(() => validateMcpDomain('https://{{HOST}}:{{PORT}}/mcp')).not.toThrow()
262+
})
263+
264+
it('throws for disallowed URL with env var only in path', () => {
265+
expect(() => validateMcpDomain('https://evil.com/{{MCP_PATH}}')).toThrow(
266+
McpDomainNotAllowedError
267+
)
268+
})
269+
270+
it('throws for disallowed URL with env var only in query', () => {
271+
expect(() => validateMcpDomain('https://evil.com/mcp?key={{API_KEY}}')).toThrow(
272+
McpDomainNotAllowedError
273+
)
274+
})
275+
276+
it('does not throw for allowed URL with env var in path', () => {
277+
expect(() => validateMcpDomain('https://allowed.com/{{PATH}}')).not.toThrow()
278+
})
144279
})
145280
})
146281
})

0 commit comments

Comments
 (0)